Skip to content

fix: prevent client grant loss and duplication in directory format exports - #1473

Open
TheInfinity007 wants to merge 2 commits into
auth0:masterfrom
TheInfinity007:fix/client-grant-filename-subject-type
Open

fix: prevent client grant loss and duplication in directory format exports#1473
TheInfinity007 wants to merge 2 commits into
auth0:masterfrom
TheInfinity007:fix/client-grant-filename-subject-type

Conversation

@TheInfinity007

Copy link
Copy Markdown

🔧 Changes

Two related bugs in the clientGrants directory-format handler, one per commit.

1. Grant filenames omit subject_type, so grants overwrite each other

src/context/directory/handlers/clientGrants.ts built the filename from client name +
API name only. But a grant's identity is (client_id, audience, subject_type) — per the
identifiers list in src/tools/auth0/handlers/clientGrants.ts, which also contains
dedicated DELETE + CREATE handling because subject_type is immutable (it is in
stripUpdateFields).

So a client holding both a client and a user grant on the same audience produced a
single file: the second write silently overwrote the first. No error or warning is emitted —
the dump only validates that the generated name is non-empty. The lost grant is absent from
the export and becomes a deletion candidate on the next import, removed outright when
AUTH0_ALLOW_DELETE is enabled.

The directory serializer was strictly less expressive than the data model it serializes.

subject_type is now appended to the filename when present. Grants without the field keep
their existing filename.

2. Stale grant files are never removed

Unlike connections, the clientGrants dump never pruned its folder. Two consequences:

  • A grant deleted from the tenant kept its file, so the next import recreated the grant
    that had just been removed
    .
  • Combined with change 1, a grant whose filename changes is left behind under its old name
    while the new name is also written. parse then returns the same grant twice, and
    calculateChanges emits one create plus one update — the create targets a grant that
    already exists, so the import fails.

The dump now tracks written filenames and removes anything else in the folder, mirroring the
existing cleanup in connections.ts. Filenames for excluded clients are recorded before
those grants are filtered out, so excluding a client no longer deletes its previously dumped
file.

Name construction moved into a nameFor helper so the cleanup pass can derive the filenames
of excluded grants. No behaviour change beyond the above.

⚠️ Note on existing exports

Tenants whose grants carry subject_type will see files renamed on their next export
(Client-API.jsonClient-API-client.json). Commit 2 makes this safe — the old file is
removed rather than left to be parsed as a duplicate — but it will show up as a rename in
users' config repos.

If you'd prefer to avoid that churn, I'm happy to switch to appending subject_type only
where a collision actually exists. That limits renames to the tenants currently losing data,
at the cost of a filename that depends on sibling grants. Happy to go either way.

📚 References

Fixes #1472

🔬 Testing

Unit tests added to test/context/directory/clientGrants.test.js — 11 passing in that file,
1423 in the full suite (up from 1420):

  • grants differing only by subject_type dump to separate files
  • a grant without subject_type keeps its legacy filename (backward compatibility)
  • a stale file is removed when a grant's filename changes, and the folder parses back to
    exactly one asset
  • a file for a grant no longer present on the tenant is removed
  • files for excluded clients survive the cleanup pass

The first test fails on unmodified master, confirming the bug:

1) should dump grants differing only by subject_type to separate files
   AssertionError: expected [ Array(1) ] to have a length of 2 but got 1

The pre-existing dump tests assert exact filenames and pass unmodified, since their fixtures
omit subject_type.

To reproduce manually: give one client two grants on the same audience, one with
subject_type: client and one with subject_type: user, then
a0deploy export -c config.json -f directory -o ./out and inspect ./out/grants/. Only one
file exists for that client/audience pair. The same export with -f yaml correctly contains
both, since tenant.yaml stores clientGrants as a list.

Two known gaps left out to keep this focused — happy to file follow-ups:

  • dump() returns early when clientGrants.length === 0, before the cleanup runs, so
    deleting every grant prunes nothing while deleting some prunes correctly. The early
    return exists to avoid the clients/resourceServers API calls, which an existing test asserts.
  • default_for third-party grants have no client_id and produce filenames beginning with
    the literal string undefined.

📝 Checklist

  • All new/changed/fixed functionality is covered by tests (or N/A)
  • I have added documentation for all new/changed functionality (or N/A)

TheInfinity007 and others added 2 commits August 24, 2026 16:13
A client grant's identity is (client_id, audience, subject_type) — see the
`identifiers` list in src/tools/auth0/handlers/clientGrants.ts, which also
contains dedicated DELETE + CREATE handling because subject_type is immutable.

The directory dump encoded only client name and API name, so grants differing
solely by subject type (e.g. `client` vs `user` on the same client and
audience) resolved to the same filename and silently overwrote each other.
One of the two grants was lost from the export with no error or warning, and
would then be treated as a deletion candidate on the next import.

Append subject_type to the filename when present. Grants without the field
keep their existing filename, so exports from tenants that do not use
subject_type are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clientGrants dump never pruned files, unlike connections. Two consequences:

1. A grant deleted from the tenant kept its file, so the next import recreated
   the grant that had just been removed.
2. A grant whose filename changes is left behind under its old name while the
   new name is also written. `parse` then returns the same grant twice, and
   `calculateChanges` emits one create plus one update — the create targets a
   grant that already exists and the import fails.

The second case is reachable from the preceding commit, which changes filenames
for grants that carry a subject_type.

Track written filenames and remove anything else in the folder, mirroring the
existing cleanup in the connections handler. Filenames for excluded clients are
recorded before those grants are filtered out, so excluding a client no longer
deletes its previously dumped file.

Name construction moves into a `nameFor` helper so the cleanup pass can derive
the filenames of excluded grants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TheInfinity007
TheInfinity007 requested a review from a team as a code owner August 24, 2026 11:01
@TheInfinity007

Copy link
Copy Markdown
Author

The E2E tests as Node module failure is caused by fork PRs not receiving CircleCI's secret
environment variables — not by this change. All unit, lint, format and tsc jobs pass.

Cause

test/e2e/e2e.test.ts reads AUTH0_E2E_CLIENT_ID, falling back to '':

const AUTH0_CLIENT_ID = process.env['AUTH0_E2E_CLIENT_ID'] || '';

The clients and clientGrants handlers exclude the Management API client from changes via
this.config('AUTH0_CLIENT_ID') (clients.ts:709, clientGrants.ts:125). With an empty
value nothing is excluded, so the deploy attempts to PATCH/DELETE the "Deploy CLI" client
(Vp0gMRF8...) — an interaction the recordings don't contain, because they were recorded
with that client excluded. Hence Nock: No match for request on exactly those four tests.

The four failures are also all YAML-path tests, while this PR only touches the directory
context; both directory-format e2e tests pass.

Verification

On unmodified master (13d479f) in the same image CI uses (cimg/node:22.19.0):

Condition Result
no AUTH0_E2E_CLIENT_ID 11 passing, 4 failing (identical to this PR's run)
AUTH0_E2E_CLIENT_ID set 15 passing, 0 failing

That single variable flips it — no secret or domain needed, since lockdown mode already
hardcodes the domain and stubs the token as 'insecure'.

I also ruled out dependency drift: installing with --before=2026-08-24T08:00:00Z yields a
byte-identical 572-package tree and the same four failures.

Possible fixes

1. Default the client ID in lockdown mode (suggested)

The value isn't a secret — Vp0gMRF8... already appears in 7 of the 10 committed recording
files. It could be defaulted the same way the domain and access token already are:

const AUTH0_CLIENT_ID = shouldUseRecordings
  ? 'Vp0gMRF8PtMzekil38qWoj4Fjw2VjRZE'
  : process.env['AUTH0_E2E_CLIENT_ID'] || '';

One line, no CI changes, no credentials. Makes e2e:node-module pass for every fork PR and
for anyone running it locally. Arguably lockdown mode shouldn't depend on an env var for a
value already baked into the fixtures.

2. Enable "Pass secrets to builds from forked pull requests" in CircleCI

Works, but exposes every project secret to arbitrary fork builds — probably not a trade you
want for this.

3. Re-run the job with the env available, or rely on internal CI post-merge

Unblocks this PR but leaves the underlying issue for the next external contributor.

Happy to open a separate PR for option 1 if that's useful — I'd keep it out of this branch so
the two changes stay independently reviewable. And happy to be corrected if I've misread
something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client grants differing only by subject_type overwrite each other in directory-format exports

1 participant