Skip to content

Commit 5d5d80a

Browse files
Keithland89Keith McgraneCopilot
authored
Azure: scoped delegation in the Share panel no longer fails with 503 (#182)
With org-scoped access on, every /api/delegations request failed at once with 503 DatabaseUnavailable, even with the database online. mssql's ConnectionPool deep-copies its config with rfdc, which copies only own enumerable properties into plain objects, so the getToken that ManagedIdentityCredential defines on its prototype was lost. tedious then refused the copy: 'The "config.authentication.options.credential" property must be an instance of the token credential class.' That isn't an error a resuming database gives, so the store gave up on the first attempt. SqlScopeStore now hands the driver an object with a getToken of its own that calls the credential it uses, injected or the managed identity, and that survives the copy. The new test drives the real mssql and tedious with a credential whose getToken is on its class's prototype. A connector that refuses keeps it offline: no name lookup, no socket. It fails without the fix and passes with it. Co-authored-by: Keith Mcgrane <keithmcgrane@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1af87db commit 5d5d80a

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,18 @@ sets the number. On Fabric and Azure.
252252
**Reset filters**, and goes back to this default. Smaller tenants still start on **All dates**.
253253
Consumption Central and the Agent Evaluator keep every date until you choose a range.
254254

255+
**Fixed:**
256+
257+
- **Azure: scoped delegation in the Share panel no longer fails with 503.** With org-scoped access
258+
on, the panel couldn't show, give, take back or approve scopes: each request failed at once with
259+
"Couldn't reach the Analytics Hub database. Try again in a minute.", even with the database
260+
online, and the web app logged `SQL connection failed` with "must be an instance of the token
261+
credential class". The SQL driver copies its settings into plain objects, which dropped the
262+
managed identity's `getToken`, so it refused the identity before connecting. Now the web app
263+
hands the driver a credential with its own `getToken`, which survives the copy. The fault has
264+
been in the web app since 0.6.0, but the Share panel offers scoped delegation on Azure only since
265+
0.6.8-rc.2, so released versions, 0.6.7 included, couldn't reach it from the app.
266+
255267
## Analytics Hub installer 0.6.8-rc.2 (unreleased)
256268

257269
**Release candidate preparation only.** 0.6.8-rc.2 is not published, and 0.6.7 remains Latest. Its

‎azure/web/src/scope-store.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ const CONNECT_ATTEMPTS = 4;
1818
export class SqlScopeStore {
1919
constructor({ server, database, clientId, sqlModule, credential, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
2020
this.server = server; this.database = database; this.sleep = sleep;
21-
this.credential = credential || new ManagedIdentityCredential(clientId);
21+
const source = credential || new ManagedIdentityCredential(clientId);
22+
// mssql deep-copies the pool config into plain objects, dropping methods a credential's class
23+
// defines, getToken among them, and tedious then refuses it. A getToken of its own survives.
24+
this.credential = { getToken: (scopes, options) => source.getToken(scopes, options) };
2225
this.sqlModule = sqlModule; this.pool = null;
2326
}
2427

‎azure/web/test/delegations.test.js‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,44 @@ test('the SQL store keys rows by person and scope, and explains missing tables',
313313
assert.equal(insert.inputs.createdAt.toISOString(), '2025-01-02T03:04:05.000Z');
314314
fail = 2627; await assert.rejects(store.insert(row), (e) => e.statusCode === 409);
315315
fail = 208; await assert.rejects(store.list(), (e) => e.statusCode === 503 && e.code === 'AccessTablesMissing');
316+
});
317+
318+
test('the SQL store hands tedious a credential that survives mssql copying the pool config', async () => {
319+
// The real mssql and tedious. tedious checks the credential when the pool creates a connection,
320+
// then gets a connector that refuses, so the test looks up no name and opens no socket.
321+
const mssql = (await import('mssql')).default;
322+
const made = [];
323+
class RecordingPool extends mssql.ConnectionPool {
324+
constructor(config) {
325+
super({ ...config, options: { ...config.options, connector: async () => { throw new Error('no sockets in tests'); } } });
326+
made.push(this);
327+
}
328+
}
329+
// Like ManagedIdentityCredential, getToken is on the class's prototype, not on the instance.
330+
class PrototypeCredential {
331+
scopes = [];
332+
async getToken(scopes) { this.scopes.push(scopes); return { token: 'sql-token', expiresOnTimestamp: 1 }; }
333+
}
334+
const connect = async (credential) => {
335+
const warnings = [];
336+
const warn = console.warn;
337+
console.warn = (...args) => { warnings.push(args.join(' ')); };
338+
try {
339+
const store = new SqlScopeStore({ server: 'sql.example.invalid', database: 'db', sqlModule: { ConnectionPool: RecordingPool }, credential, sleep: async () => {} });
340+
await assert.rejects(store.connect(), (e) => e.statusCode === 503 && e.code === 'DatabaseUnavailable');
341+
} finally {
342+
console.warn = warn;
343+
}
344+
assert.ok(!warnings.some((w) => w.includes('must be an instance of the token credential class')), warnings.join('\n'));
345+
assert.ok(warnings.some((w) => w.includes('no sockets in tests')), `tedious took the credential and tried to connect: ${warnings.join('\n')}`);
346+
return made.at(-1).config.authentication.options.credential;
347+
};
348+
349+
const credential = new PrototypeCredential();
350+
const handed = await connect(credential);
351+
assert.equal(typeof handed.getToken, 'function');
352+
assert.deepEqual(await handed.getToken('https://database.windows.net/.default'), { token: 'sql-token', expiresOnTimestamp: 1 });
353+
assert.deepEqual(credential.scopes, ['https://database.windows.net/.default']);
354+
// With none given, the store uses the managed identity, which tedious takes the same way. It's never asked for a token.
355+
assert.equal(typeof (await connect(undefined)).getToken, 'function');
316356
});

0 commit comments

Comments
 (0)