From 67941fef50e62adc9fcec574a2efab82baf0e2d0 Mon Sep 17 00:00:00 2001 From: Jithin Zachariah Date: Wed, 29 Jul 2026 10:23:20 +0200 Subject: [PATCH] fix: apply AUTH0_INCLUDED_CONNECTIONS on export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AUTH0_INCLUDED_CONNECTIONS` was only honoured on import. The value was read into `assets.include.connections` on both code paths, but the only consumer was `filterIncluded` in the connections handler's `processChanges`, so exports wrote every connection on the tenant. Both `dump()` implementations now filter to the included connections, mirroring the existing exclude behaviour. In the directory handler, pruning is scoped to the listed names so files belonging to connections outside the include list survive a re-dump — those connections are unmanaged, matching how excluded connections are preserved today. Fixes #1441 --- src/context/directory/handlers/connections.ts | 20 +++++- src/context/yaml/handlers/connections.ts | 6 ++ test/context/directory/connections.test.js | 63 +++++++++++++++++++ test/context/yaml/connections.test.js | 20 ++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/context/directory/handlers/connections.ts b/src/context/directory/handlers/connections.ts index 518b85e49..268759dcf 100644 --- a/src/context/directory/handlers/connections.ts +++ b/src/context/directory/handlers/connections.ts @@ -75,6 +75,12 @@ async function dump(context: DirectoryContext): Promise { ); } + // Filter to included connections + const includedConnections = (context.assets.include && context.assets.include.connections) || []; + if (includedConnections.length) { + connections = connections.filter((connection) => includedConnections.includes(connection.name)); + } + const connectionsFolder = path.join(context.filePath, constants.CONNECTIONS_DIRECTORY); fs.ensureDirSync(connectionsFolder); @@ -131,11 +137,23 @@ async function dump(context: DirectoryContext): Promise { if (dumpedConnection.strategy === 'email') expectedFiles.add(`${connectionName}.html`); }); + // With an include list configured, connections outside it are unmanaged, so limit pruning + // to the listed names rather than every file in the folder. + const prunableFiles = includedConnections.length + ? new Set( + includedConnections.flatMap((name) => [`${sanitize(name)}.json`, `${sanitize(name)}.html`]) + ) + : null; + // Remove files that belong to connections no longer present (and not excluded) if (fs.existsSync(connectionsFolder)) { for (const existing of fs.readdirSync(connectionsFolder)) { const fullPath = path.join(connectionsFolder, existing); - if (fs.statSync(fullPath).isFile() && !expectedFiles.has(existing)) { + if ( + fs.statSync(fullPath).isFile() && + !expectedFiles.has(existing) && + (prunableFiles === null || prunableFiles.has(existing)) + ) { fs.removeSync(fullPath); } } diff --git a/src/context/yaml/handlers/connections.ts b/src/context/yaml/handlers/connections.ts index 33dddfe04..cb7483592 100644 --- a/src/context/yaml/handlers/connections.ts +++ b/src/context/yaml/handlers/connections.ts @@ -61,6 +61,12 @@ async function dump(context: YAMLContext): Promise { ); } + // Filter to included connections + const includedConnections = (context.assets.include && context.assets.include.connections) || []; + if (includedConnections.length) { + connections = connections.filter((connection) => includedConnections.includes(connection.name)); + } + return { connections: connections.map((connection) => { let dumpedConnection = { diff --git a/test/context/directory/connections.test.js b/test/context/directory/connections.test.js index af405d289..9a2c2d4e8 100644 --- a/test/context/directory/connections.test.js +++ b/test/context/directory/connections.test.js @@ -266,6 +266,69 @@ describe('#directory context connections', () => { expect(fs.existsSync(path.join(connectionsFolder, 'excludedConnection.json'))).to.equal(true); }); + it('should only dump included connections', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + await handler.dump(context); + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + + expect(fs.existsSync(path.join(connectionsFolder, 'includedConnection.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'unmanagedConnection.json'))).to.equal(false); + }); + + it('should preserve pre-existing files for connections outside the include list on re-dump', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + + // Simulate a prior export that wrote a connection now outside the include list + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + fs.ensureDirSync(connectionsFolder); + fs.writeFileSync( + path.join(connectionsFolder, 'unmanagedConnection.json'), + '{"name":"unmanagedConnection"}' + ); + + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + await handler.dump(context); + + expect(fs.existsSync(path.join(connectionsFolder, 'includedConnection.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'unmanagedConnection.json'))).to.equal(true); + }); + + it('should remove stale files for included connections no longer present on the tenant', async () => { + const dir = path.join(testDataDir, 'directory', 'connectionsIncludeStaleFiles'); + cleanThenMkdir(dir); + + // Simulate a previous export of two included connections, one since deleted on the tenant + const connectionsFolder = path.join(dir, constants.CONNECTIONS_DIRECTORY); + fs.ensureDirSync(connectionsFolder); + fs.writeFileSync(path.join(connectionsFolder, 'facebook.json'), '{"name":"facebook"}'); + fs.writeFileSync(path.join(connectionsFolder, 'github.json'), '{"name":"github"}'); + + const context = new Context({ AUTH0_INPUT_FILE: dir }, mockMgmtClient()); + context.assets.connections = [{ name: 'github', strategy: 'github' }]; + context.assets.include = { connections: ['facebook', 'github'] }; + + await handler.dump(context); + + expect(fs.existsSync(path.join(connectionsFolder, 'github.json'))).to.equal(true); + expect(fs.existsSync(path.join(connectionsFolder, 'facebook.json'))).to.equal(false); + }); + it('should remove stale connection files when connections are removed from source', async () => { const dir = path.join(testDataDir, 'directory', 'connectionsStaleFiles'); cleanThenMkdir(dir); diff --git a/test/context/yaml/connections.test.js b/test/context/yaml/connections.test.js index a8caa038e..18986496d 100644 --- a/test/context/yaml/connections.test.js +++ b/test/context/yaml/connections.test.js @@ -276,4 +276,24 @@ describe('#YAML context connections', () => { expect(dumped.connections).to.have.length(1); expect(dumped.connections[0].name).to.equal('includedConnection'); }); + + it('should only dump included connections', async () => { + const dir = path.join(testDataDir, 'yaml', 'connectionsDumpInclude'); + cleanThenMkdir(dir); + const context = new Context( + { AUTH0_INPUT_FILE: path.join(dir, 'tenant.yaml') }, + mockMgmtClient() + ); + + context.assets.connections = [ + { name: 'includedConnection', strategy: 'waad' }, + { name: 'unmanagedConnection', strategy: 'samlp' }, + ]; + context.assets.include = { connections: ['includedConnection'] }; + + const dumped = await handler.dump(context); + + expect(dumped.connections).to.have.length(1); + expect(dumped.connections[0].name).to.equal('includedConnection'); + }); });