Skip to content

Commit 883a2a2

Browse files
committed
fix(integrations): mssql guard gaps and Entra query, scope, and output findings
MSSQL read-only screen - Screen RENAME, documented T-SQL DDL for Azure Synapse dedicated SQL pools and Analytics Platform System, which are reachable over TDS with exactly the connection fields this block exposes. `SELECT 1 RENAME OBJECT dbo.t TO t2` was a schema change passing an operation advertised as read-only. - Screen the Service Broker family: RECEIVE as a word, and END/MOVE/GET CONVERSATION and SEND ON CONVERSATION as two-token phrases, since END closes every CASE. RECEIVE is a destructive read and END CONVERSATION WITH CLEANUP drops a conversation's messages. MSSQL routes and block - Build the insert statement before connecting, matching update and delete, so a bad identifier answers 400 instead of burning a TLS+login and returning 500. - Declare `truncated`/`truncationReason` on the block, which the tools declare and the routes emit but the block left unreferenceable. Microsoft Entra ID - Pair `$count=true` with `ConsistencyLevel: eventual` conditionally. Graph documents `hasMembersWithLicenseErrors`, `isLicenseReconciliationNeeded`, and `identities/any(i:i/issuer)` as filterable only *without* advanced query parameters, and documents advanced queries as unsupported in Azure AD B2C tenants, so the unconditional pair broke filters that previously worked. When continuing from a nextLink the pairing is read off the link itself. - Request `LicenseAssignment.Read.All` instead of `Directory.Read.All`. The latter was needed by `GET /subscribedSkus` alone, whose permission table names the former as least privileged and does not list the ReadWrite scope we hold. - Enumerate the block's real output keys instead of a single `response` object no tool emits.
1 parent 4551cc9 commit 883a2a2

16 files changed

Lines changed: 490 additions & 46 deletions

File tree

apps/sim/app/api/tools/mssql/insert/route.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3333
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
3434
)
3535

36+
/**
37+
* Built before connecting so a bad identifier costs no TLS+login round trip
38+
* and answers 400 like the update, delete, query, and execute routes, rather
39+
* than falling through to the catch-all as a 500.
40+
*/
41+
let built: { query: string; values: unknown[] }
42+
try {
43+
built = buildInsertQuery(params.table, params.data)
44+
} catch (error) {
45+
const message = getErrorMessage(error, 'Invalid statement')
46+
logger.warn(`[${requestId}] Insert statement rejected: ${message}`)
47+
return NextResponse.json(
48+
{ error: `Microsoft SQL Server insert failed: ${message}` },
49+
{ status: 400 }
50+
)
51+
}
52+
3653
const pool = await createMSSQLConnection(params)
3754

3855
try {
39-
const { query, values } = buildInsertQuery(params.table, params.data)
40-
const result = await executeQuery(pool, query, values)
56+
const result = await executeQuery(pool, built.query, built.values)
4157

4258
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
4359

apps/sim/app/api/tools/mssql/route-guards.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*
4-
* The update and delete routes build their statement before opening a
4+
* The insert, update, and delete routes build their statement before opening a
55
* connection, so a rejected WHERE clause or a bad identifier costs no TLS+login
66
* round trip and answers 400 like the query and execute routes do.
77
*/
@@ -29,6 +29,7 @@ vi.mock('@sim/security/dns', () => ({
2929
}))
3030

3131
import { POST as DELETE_POST } from '@/app/api/tools/mssql/delete/route'
32+
import { POST as INSERT_POST } from '@/app/api/tools/mssql/insert/route'
3233
import { POST as UPDATE_POST } from '@/app/api/tools/mssql/update/route'
3334

3435
const connection = {
@@ -42,7 +43,7 @@ const connection = {
4243
connectionTimeout: 15000,
4344
}
4445

45-
describe('MSSQL update and delete guards run before connecting', () => {
46+
describe('MSSQL insert, update, and delete guards run before connecting', () => {
4647
beforeEach(() => {
4748
vi.clearAllMocks()
4849
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
@@ -68,6 +69,8 @@ describe('MSSQL update and delete guards run before connecting', () => {
6869
)
6970

7071
it.each([
72+
['insert', INSERT_POST, { table: 'users-table', data: { a: 1 } }],
73+
['insert column', INSERT_POST, { table: 'users', data: { 'bad-col': 1 } }],
7174
['update', UPDATE_POST, { table: 'users-table', data: { a: 1 }, where: 'id = 1' }],
7275
['delete', DELETE_POST, { table: 'users-table', where: 'id = 1' }],
7376
])('answers 400 for a bad identifier on %s without connecting', async (_op, handler, body) => {
@@ -78,6 +81,7 @@ describe('MSSQL update and delete guards run before connecting', () => {
7881
})
7982

8083
it.each([
84+
['insert', INSERT_POST, { table: 'users', data: { a: 1 } }],
8185
['update', UPDATE_POST, { table: 'users', data: { a: 1 }, where: 'id = 1' }],
8286
['delete', DELETE_POST, { table: 'users', where: 'id = 1' }],
8387
])('still runs an accepted %s statement', async (_op, handler, body) => {

apps/sim/app/api/tools/mssql/utils.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,64 @@ describe('read-only screens cover the rest of the session and transaction family
445445
})
446446
})
447447

448+
describe('read-only screens cover RENAME and the Service Broker statement family', () => {
449+
/**
450+
* `RENAME` is documented T-SQL DDL for Azure Synapse dedicated SQL pools and
451+
* Analytics Platform System, both reachable over TDS with the connection
452+
* fields this block exposes — so a schema change was passing an operation
453+
* advertised as read-only.
454+
*/
455+
it.each([
456+
['RENAME OBJECT', 'SELECT 1 RENAME OBJECT dbo.Customer TO Customer1'],
457+
['RENAME OBJECT COLUMN', 'SELECT 1 RENAME OBJECT dbo.t COLUMN c1 TO c2'],
458+
['RENAME DATABASE', 'SELECT 1 RENAME DATABASE db1 TO db2'],
459+
['RECEIVE', 'SELECT 1 RECEIVE TOP(1) * FROM dbo.MyQueue'],
460+
['END CONVERSATION', "SELECT 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"],
461+
[
462+
'MOVE CONVERSATION',
463+
"SELECT 1 MOVE CONVERSATION '00000000-0000-0000-0000-000000000000' TO '00000000-0000-0000-0000-000000000001'",
464+
],
465+
['GET CONVERSATION GROUP', 'SELECT 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'],
466+
[
467+
'SEND ON CONVERSATION',
468+
"SELECT 1 SEND ON CONVERSATION '00000000-0000-0000-0000-000000000000' MESSAGE TYPE [t] ('x')",
469+
],
470+
])('rejects %s in the Query operation', (_label, query) => {
471+
expect(validateReadOnlyQuery(query).isValid).toBe(false)
472+
})
473+
474+
it.each([
475+
['RENAME OBJECT', 'id = 1 RENAME OBJECT dbo.t TO t2'],
476+
['RECEIVE', 'id = 1 RECEIVE TOP(1) * FROM dbo.MyQueue'],
477+
['END CONVERSATION', "id = 1 END CONVERSATION '00000000-0000-0000-0000-000000000000'"],
478+
['GET CONVERSATION GROUP', 'id = 1 GET CONVERSATION GROUP @g FROM dbo.MyQueue'],
479+
])('rejects %s in an update or delete WHERE clause', (_label, where) => {
480+
expect(() => buildUpdateQuery('t', { a: 1 }, where)).toThrow()
481+
expect(() => buildDeleteQuery('t', where)).toThrow()
482+
})
483+
484+
/**
485+
* The over-screening guard. `END` closes every `CASE`, and `rename`/`receive`
486+
* are the stems of ordinary column names, so neither addition may cost the
487+
* plain SELECTs this operation exists to run.
488+
*/
489+
it('still accepts CASE … END and ordinary identifiers built on the new words', () => {
490+
const allowed = [
491+
"SELECT CASE WHEN status = 1 THEN 'on' ELSE 'off' END FROM dbo.jobs",
492+
"SELECT CASE WHEN a = 1 THEN 'x' END AS conversation_state FROM dbo.t",
493+
'SELECT renamed_at, rename_log, received_at, receive_queue FROM dbo.audit',
494+
'SELECT conversation_id, get_flag, move_order, send_at, end_date FROM dbo.t',
495+
]
496+
497+
for (const query of allowed) {
498+
expect(validateReadOnlyQuery(query)).toEqual({ isValid: true })
499+
}
500+
501+
expect(() => buildUpdateQuery('audit', { a: 1 }, 'renamed_at > 0')).not.toThrow()
502+
expect(() => buildDeleteQuery('audit', 'received_at > 0 AND conversation_id = 3')).not.toThrow()
503+
})
504+
})
505+
448506
describe('executeQuery result caps', () => {
449507
function makeCapPool(recordset: unknown[]) {
450508
return {

apps/sim/app/api/tools/mssql/utils.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,28 @@ export function toRowsResponseBody(result: MSSQLQueryResult, message: string) {
276276
* but it introduces a second statement in exactly the same semicolon-less way,
277277
* which is what this list exists to reject.
278278
*
279+
* `RENAME` is documented T-SQL DDL — it applies to Azure Synapse Analytics
280+
* dedicated SQL pools and Analytics Platform System, both of which speak TDS on
281+
* port 1433 and are reachable with exactly the connection fields this block
282+
* exposes. `SELECT 1 RENAME OBJECT dbo.Customer TO Customer1` is a valid
283+
* semicolon-less batch that changes schema through an operation advertised as
284+
* read-only, and `RENAME DATABASE` and `RENAME OBJECT … COLUMN … TO …` reach it
285+
* the same way.
286+
*
287+
* `RECEIVE` is the Service Broker read that *removes* the messages it returns,
288+
* so it is a write in everything but name. Its siblings — `END`/`MOVE`/`GET`
289+
* `CONVERSATION` and `SEND ON CONVERSATION` — open with words that are ordinary
290+
* identifiers (`END` closes every `CASE`), so they are screened as phrases in
291+
* {@link MSSQL_STATEMENT_PHRASES} instead.
292+
*
279293
* `FETCH` is deliberately **absent**: `OFFSET … FETCH NEXT` is the standard
280294
* T-SQL paging clause, so screening it would reject the ordinary paged SELECT
281295
* this operation exists to run. Word boundaries keep the additions off ordinary
282296
* identifiers — `settled`, `offset_value`, and `begin_date` all match nothing.
283297
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements
284298
*/
285299
const MSSQL_STATEMENT_KEYWORDS =
286-
/\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into|deallocate)\b/i
300+
/\b(?:insert|update|updatetext|writetext|readtext|delete|merge|drop|create|alter|truncate|rename|receive|disable|enable|set|begin|commit|rollback|grant|revoke|deny|exec|execute|backup|restore|shutdown|reconfigure|dbcc|kill|checkpoint|use|bulk|revert|setuser|openrowset|opendatasource|openquery|openxml|waitfor|into|deallocate)\b/i
287301

288302
/**
289303
* The remaining session, transaction, cursor, and key-management statements,
@@ -297,10 +311,16 @@ const MSSQL_STATEMENT_KEYWORDS =
297311
* run. `DEALLOCATE` is the one exception and lives in the word list above — it
298312
* has no ordinary-identifier reading.
299313
*
300-
* None of these writes table data or schema, which is why they were missed; they
301-
* are screened because the file's stated rule is that a second statement is
302-
* rejected structurally, not by what it happens to do. `RAISERROR ... WITH LOG`
303-
* writes to the error log and the Windows application log, so it is not inert.
314+
* Most of these write neither table data nor schema, which is why they were
315+
* missed; they are screened because the file's stated rule is that a second
316+
* statement is rejected structurally, not by what it happens to do.
317+
* `RAISERROR ... WITH LOG` writes to the error log and the Windows application
318+
* log, so it is not inert. The Service Broker conversation statements are not
319+
* inert either: `END CONVERSATION ... WITH CLEANUP` drops every message in a
320+
* conversation, `MOVE CONVERSATION` reassigns it, and `SEND ON CONVERSATION`
321+
* enqueues a message — and the handles they need are enumerable through this
322+
* same path, because the catalog screen applies only to WHERE clauses.
323+
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/end-conversation-transact-sql
304324
* @see https://learn.microsoft.com/en-us/sql/t-sql/statements/statements
305325
*/
306326
const MSSQL_STATEMENT_PHRASES: readonly RegExp[] = [
@@ -309,6 +329,8 @@ const MSSQL_STATEMENT_PHRASES: readonly RegExp[] = [
309329
/\bclose\s+(?:all\s+symmetric\s+keys|master\s+key|symmetric\s+key)\b/i,
310330
/\badd\s+signature\b/i,
311331
/\braiserror[\s\S]*?\bwith\s+log\b/i,
332+
/\b(?:end|move|get)\s+conversation\b/i,
333+
/\bsend\s+on\s+conversation\b/i,
312334
]
313335

314336
/** Matches the first screened statement phrase, or `null`. */

apps/sim/blocks/blocks/microsoft_ad.test.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { SCOPE_DESCRIPTIONS } from '@/lib/oauth/utils'
56
import { MicrosoftAdBlock } from '@/blocks/blocks/microsoft_ad'
7+
import * as microsoftAdTools from '@/tools/microsoft_ad'
68

79
/**
810
* The tri-state assertions run against `{ ...inputs, ...buildParams(inputs) }`, the shape the
@@ -121,9 +123,22 @@ describe('MicrosoftAdBlock', () => {
121123
/**
122124
* `User.ReadWrite.All` is listed on every `/users` read this block performs — list, get,
123125
* licenseDetails, registeredDevices, and ownedDevices — so `User.Read.All` was pure consent
124-
* noise. `Directory.Read.All` and `GroupMember.ReadWrite.All` are deliberately retained:
125-
* `GET /subscribedSkus` names neither `LicenseAssignment.ReadWrite.All` nor any scope left in
126-
* this list, and `POST /groups/{id}/members/$ref` accepts `GroupMember.ReadWrite.All` only.
126+
* noise.
127+
*
128+
* `Directory.Read.All` was required by exactly one call, `GET /subscribedSkus`, whose
129+
* permission table names `LicenseAssignment.Read.All` as least privileged and
130+
* `Directory.Read.All` only as a higher-privileged alternative — and does **not** list
131+
* `LicenseAssignment.ReadWrite.All`, so the write scope this block already holds does not
132+
* cover the read. Every other call is covered by a narrower scope in the list:
133+
* directory roles by `RoleManagement.ReadWrite.Directory`, group members by
134+
* `Group.ReadWrite.All`/`GroupMember.ReadWrite.All`, devices by `Device.Read.All`, audits by
135+
* `AuditLog.Read.All`, service principals by `Application.Read.All`, user app-role
136+
* assignments by `AppRoleAssignment.ReadWrite.All`, and CA policies by `Policy.Read.All`.
137+
*
138+
* `GroupMember.ReadWrite.All` is deliberately retained: `POST /groups/{id}/members/$ref`
139+
* accepts it and nothing else in this list for a user member.
140+
* @see https://learn.microsoft.com/en-us/graph/api/subscribedsku-list
141+
* @see https://learn.microsoft.com/en-us/graph/api/group-post-members
127142
*/
128143
describe('requested OAuth scopes', () => {
129144
const requiredScopes =
@@ -135,10 +150,39 @@ describe('MicrosoftAdBlock', () => {
135150
expect(requiredScopes).not.toContain('User.Read.All')
136151
})
137152

153+
it('requests the least-privileged scope for subscribedSkus, not Directory.Read.All', () => {
154+
expect(requiredScopes).toContain('LicenseAssignment.Read.All')
155+
expect(requiredScopes).not.toContain('Directory.Read.All')
156+
})
157+
138158
it('keeps the scopes no retained scope covers', () => {
139-
expect(requiredScopes).toEqual(
140-
expect.arrayContaining(['Directory.Read.All', 'GroupMember.ReadWrite.All'])
141-
)
159+
expect(requiredScopes).toEqual(expect.arrayContaining(['GroupMember.ReadWrite.All']))
160+
})
161+
162+
it('describes every scope it requests', () => {
163+
const undescribed = requiredScopes.filter((scope) => !SCOPE_DESCRIPTIONS[scope])
164+
expect(undescribed).toEqual([])
165+
})
166+
})
167+
168+
/**
169+
* `getBlockOutputs` derives the referenceable schema from `blockConfig.outputs`, so a single
170+
* `response` entry made the tag dropdown offer `<microsoft_ad.response>` — which corresponds to
171+
* nothing, since every tool puts its fields at the top level of `output` — and left the real
172+
* outputs unreferenceable downstream.
173+
*/
174+
describe('declared outputs', () => {
175+
const toolOutputKeys = new Set(
176+
Object.values(microsoftAdTools).flatMap((tool) => Object.keys(tool.outputs ?? {}))
177+
)
178+
const blockOutputKeys = new Set(Object.keys(MicrosoftAdBlock.outputs))
179+
180+
it('declares every key its tools emit', () => {
181+
expect([...toolOutputKeys].filter((key) => !blockOutputKeys.has(key)).sort()).toEqual([])
182+
})
183+
184+
it('declares nothing no tool emits', () => {
185+
expect([...blockOutputKeys].filter((key) => !toolOutputKeys.has(key)).sort()).toEqual([])
142186
})
143187
})
144188
})

0 commit comments

Comments
 (0)