Skip to content

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

Open
TheInfinity007 wants to merge 4 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 4 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.

@harshithRai harshithRai self-assigned this Aug 26, 2026

@harshithRai harshithRai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the really thorough writeup, and nice diagnosis on both bugs!
The subject_type-in-filename fix and the stale-file pruning both make sense, and the tests are solid.
I've left a couple of inline notes.
The main one is an edge case in the excluded-client handling that looks like a data-loss path, so I'd like to get that sorted before merging.

// pass below from removing files for clients the user deliberately excluded.
clientGrants
.filter((grant: ClientGrant) => excludedClientIds.has(grant.client_id))
.forEach((grant: ClientGrant) => expectedFiles.add(`${nameFor(grant)}.json`));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be a data-loss edge case here. This seeds expectedFiles with nameFor(grant), which produces the new name format (Client-API-client.json), but a file from a prior export is on disk under the old format (Client-API.json). So for an excluded client whose grant carries a subject_type, the first export after this ships: the old file isn't in expectedFiles, the cleanup pass deletes it, and since the client is excluded nothing replaces it. The file is gone.

connections.ts avoids this by seeding from the connection name (stable, format-independent) rather than a derived filename:

for (const name of excludedConnections) {
  expectedFiles.add(`${sanitize(name)}.json`);
  expectedFiles.add(`${sanitize(name)}.html`);
}

The new exclude test misses it because its fixture grant has no subject_type, so the old and new names match.

Let's preserve all pre-existing files for excluded clients regardless of current name (or skip pruning for excluded grants entirely), and add a test with a subject_type-bearing excluded grant that already exists under the old filename.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. You were right that seeding from nameFor reintroduces the format dependency.

// Remove files that belong to grants no longer present (and not excluded). Without this, a grant
// whose filename changes is left behind under its old name and parsed back as a duplicate on the
// next import, and grants deleted from the tenant are silently recreated.
for (const existing of fs.readdirSync(grantsFolder)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: this now deletes any non-.json file in the grants folder too, since expectedFiles only holds .json names. It matches the connections behavior so I'm fine with it, but this folder never pruned anything before, so it might be worth a line in the PR description so nobody's surprised.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed rather than documented. The prune now uses getFiles(grantsFolder, ['.json']), the same call parse uses, so dump only removes files it could have read back as grants.

Added a test that drops a README.md and a notes.txt into the folder alongside a stale grant file and asserts the stale .json goes while the other two stay.

TheInfinity007 and others added 2 commits August 27, 2026 22:24
The cleanup pass added in the previous commit walked every entry returned by
`readdirSync`, but `expectedFiles` only ever holds `${name}.json`. Any other
file in the grants folder — a README, notes — was therefore deleted on dump.

Connections needs the wider sweep because it writes `.html` email bodies and
records them in `expectedFiles`. Grants only ever write `.json`, so scanning
everything deletes user files without preventing any duplicate or recreated
grant: a non-JSON file is never read back by `parse` and so is not stale state.

Use `getFiles(grantsFolder, ['.json'])`, the same call `parse` uses, making the
invariant explicit: dump removes exactly what parse would have read back.

Reported in review on the preceding commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cleanup pass seeded `expectedFiles` for excluded clients with `nameFor(grant)`,
which produces the current name format. A file from an earlier export is on disk
under the old format, so for an excluded client whose grant carries a subject_type
the first export after the naming change deletes the old file — and because the
client is excluded, nothing writes it back. The file is gone.

`connections.ts` avoids this by seeding from the connection name, but that works
only because a connection's filename *is* its sanitized name. A grant's filename
is derived from the client name, the API name, the grant's subject_type and the
naming format itself, so it cannot be reconstructed from the exclude list.

Decide preservation from the file's contents instead: read each candidate and keep
it when its `client_id` identifies an excluded client, whatever the file is called.
This also covers a renamed resource server and a grant that has since been deleted
from the tenant, neither of which the name-derived seeding survived.

- Match both forms the field can take: the client name when `clientsOrig` was
  available at dump time, the raw client_id otherwise. Names come from the exclude
  list so that excluding a client absent from the tenant still protects its file.
- Read via `loadJSON` with the context mappings, as `parse` does, so keyword
  markers resolve before comparison.
- Keep unreadable files rather than deleting them, and never let one bad file fail
  the export — this is the first time `dump` reads these files at all.
- Log each removal, matching `dumpJSON` logging each write.

The `nameFor` seeding is dropped rather than kept as a fallback: it protects
nothing the contents check misses, since a grant absent from the export has no
name to derive.

Known gap: a client renamed on the tenant while excluded still loses its file. The
file records the old name and the exclude list must carry the current one, so
nothing links them.

The existing exclude test passed on a name coincidence — its fixture grant had no
subject_type, so the old and new names matched. It now carries one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

2 participants