From 82b8aa861be9e69435e7f085124e58b085aaf105 Mon Sep 17 00:00:00 2001 From: Laurent Bertrand Date: Fri, 28 Aug 2026 12:40:35 +0100 Subject: [PATCH] feat: set SESSION_CONTEXT per request for Row-Level Security Tables guarded by Row-Level Security usually scope rows via SESSION_CONTEXT() rather than by login, since the server connects with one shared account. The query validator blocks EXEC/SP_, so the context could never be set and every RLS-protected query came back empty with no indication why. SESSION_CONTEXT is connection-scoped and queries run through a pool, so the context is emitted in the same batch as the query rather than in a separate round trip that would land on an arbitrary connection. The preamble is built from process config and bound via request.input(), so no part of it derives from model-supplied input and security.ts is unchanged. Keys are locked with @read_only = 1 and each EXEC is guarded on SESSION_CONTEXT() IS NULL, which keeps it idempotent across reused pooled connections (re-setting a locked key raises 15664). resetSessionContext() recycles the pool for the case where a locked value must change. When SQLSERVER_SESSION_CONTEXT is unset the emitted SQL is unchanged. --- .env.example | 5 ++++ README.md | 27 +++++++++++++++++ src/cli.ts | 2 ++ src/connection.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 17 ++++++++++- src/types.ts | 36 +++++++++++++++++++++++ 6 files changed, 159 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 5d9a31c..9b5cccb 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ SQLSERVER_CONNECTION_TIMEOUT=30000 SQLSERVER_REQUEST_TIMEOUT=60000 SQLSERVER_MAX_ROWS=1000 +# Row-Level Security - applied to every query via sp_set_session_context. +# Values may be strings or numbers; numbers are bound as int. +# SQLSERVER_SESSION_CONTEXT={"TenantId":42,"UserRole":"auditor"} +# SQLSERVER_SESSION_CONTEXT_READONLY=true + # Example configurations for different environments: # Azure SQL Database diff --git a/README.md b/README.md index 57b11c3..bb3027b 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,33 @@ The server is configured using environment variables: - `SQLSERVER_CONNECTION_TIMEOUT` - Connection timeout in ms (default: 30000) - `SQLSERVER_REQUEST_TIMEOUT` - Request timeout in ms (default: 60000) - `SQLSERVER_MAX_ROWS` - Maximum rows per query (default: 1000) +- `SQLSERVER_SESSION_CONTEXT` - JSON object applied to every query as `SESSION_CONTEXT` (see [Row-Level Security](#row-level-security)) +- `SQLSERVER_SESSION_CONTEXT_READONLY` - Lock session context keys against modification (default: true) + +### Row-Level Security + +Databases that use Row-Level Security often scope rows by tenant or user through +`SESSION_CONTEXT()` rather than by login, because the application connects with a +single shared account. Set `SQLSERVER_SESSION_CONTEXT` to a JSON object and every +query runs with that context applied: + +```bash +export SQLSERVER_SESSION_CONTEXT='{"TenantId":42,"UserRole":"auditor"}' +``` + +Values may be strings or numbers; numbers are bound as `int` so predicates +comparing against integer columns work without a `CONVERT`. + +The context is set in the same batch as each query. `SESSION_CONTEXT` is +connection-scoped and queries run through a pool, so setting it in a separate +round trip would apply it to an arbitrary connection. + +Keys are locked with `@read_only = 1` by default, so a query cannot widen its own +scope even if it were to reach the server with the context already established. +Because read-only keys are released only when a connection resets, changing a +value at runtime requires `SqlServerConnection.resetSessionContext()`, which +recycles the pool. Set `SQLSERVER_SESSION_CONTEXT_READONLY=false` to allow keys +to be overwritten in place instead. ## Usage diff --git a/src/cli.ts b/src/cli.ts index d1aa0dc..712ce33 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,8 @@ ENVIRONMENT VARIABLES: SQLSERVER_PORT Port number (optional, default: 1433) SQLSERVER_ENCRYPT Enable encryption (optional, default: true) SQLSERVER_TRUST_CERT Trust server certificate (optional, default: true) + SQLSERVER_SESSION_CONTEXT JSON object set as SESSION_CONTEXT on every + query, for Row-Level Security (optional) EXAMPLES: # Set environment variables and run diff --git a/src/connection.ts b/src/connection.ts index 5f4f112..0f095c7 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -44,13 +44,85 @@ export class SqlServerConnection { } } + /** + * Builds the SESSION_CONTEXT preamble for a request. + * + * SESSION_CONTEXT is connection-scoped, and this class runs queries through a + * pool, so the context has to be established in the same batch as the query + * that depends on it -- setting it in a separate round trip would apply it to + * whichever connection the pool happened to hand out at the time. + * + * Keys and values are bound as parameters rather than interpolated, so the + * preamble carries no injection surface. It is built entirely from server + * configuration; no part of it comes from the caller's query. + * + * When read-only keys are in use the EXEC is guarded on SESSION_CONTEXT() + * being unset, because pooled connections are reused: re-running the EXEC + * against an already-locked key raises error 15664. + */ + private applySessionContext(request: sql.Request): string { + const entries = Object.entries(this.config.sessionContext ?? {}); + if (entries.length === 0) { + return ''; + } + + const readOnly = this.config.sessionContextReadOnly !== false; + let preamble = ''; + + entries.forEach(([key, value], i) => { + const keyParam = `__sc_key_${i}`; + const valueParam = `__sc_val_${i}`; + + request.input(keyParam, sql.NVarChar(128), key); + if (typeof value === 'number') { + request.input(valueParam, sql.Int, value); + } else { + request.input(valueParam, sql.NVarChar(sql.MAX), value); + } + + const exec = + `EXEC sys.sp_set_session_context @key = @${keyParam}, ` + + `@value = @${valueParam}` + + (readOnly ? ', @read_only = 1' : '') + + ';'; + + preamble += readOnly + ? `IF SESSION_CONTEXT(@${keyParam}) IS NULL ${exec}\n` + : `${exec}\n`; + }); + + return preamble; + } + async query(queryText: string): Promise> { if (!this.pool) { throw new Error('Database connection not established'); } const request = this.pool.request(); - return await request.query(queryText); + const preamble = this.applySessionContext(request); + return await request.query(preamble + queryText); + } + + /** + * Tears down and reopens the pool, clearing SESSION_CONTEXT on every + * connection. + * + * Read-only keys cannot be cleared from T-SQL -- they are released only when + * the connection resets. tedious can reset an individual connection + * (Connection.reset), but mssql neither calls it nor exposes a per-checkout + * hook, so recycling the pool is the only route through the public API. + * Callers need this to change a session context value that was set read-only. + */ + async resetSessionContext( + sessionContext?: ConnectionConfig['sessionContext'] + ): Promise { + if (arguments.length > 0) { + this.config = { ...this.config, sessionContext }; + } + + await this.disconnect(); + await this.connect(); } async testConnection(): Promise { diff --git a/src/index.ts b/src/index.ts index 2d4395c..bc94292 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ async function runServer() { ListToolsRequestSchema, } = await import('@modelcontextprotocol/sdk/types.js'); const { SqlServerConnection } = await import('./connection.js'); - const { ConnectionConfigSchema } = await import('./types.js'); + const { ConnectionConfigSchema, parseSessionContext } = await import('./types.js'); const { ListDatabasesTool, ListTablesTool, @@ -169,6 +169,18 @@ async function runServer() { return; } + // Parsed ahead of the config object so a malformed value reports itself + // rather than surfacing as an opaque startup failure. + let sessionContext; + try { + sessionContext = parseSessionContext(process.env.SQLSERVER_SESSION_CONTEXT); + } catch (error) { + console.error( + `Invalid configuration: ${error instanceof Error ? error.message : error}` + ); + process.exit(1); + } + // Read configuration from environment variables const config = { server: process.env.SQLSERVER_HOST || 'localhost', @@ -181,6 +193,9 @@ async function runServer() { connectionTimeout: parseInt(process.env.SQLSERVER_CONNECTION_TIMEOUT || '30000'), requestTimeout: parseInt(process.env.SQLSERVER_REQUEST_TIMEOUT || '60000'), maxRows: parseInt(process.env.SQLSERVER_MAX_ROWS || '1000'), + sessionContext, + sessionContextReadOnly: + process.env.SQLSERVER_SESSION_CONTEXT_READONLY !== 'false', }; // Validate configuration diff --git a/src/types.ts b/src/types.ts index d571e1c..2d76e8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,10 +11,46 @@ export const ConnectionConfigSchema = z.object({ connectionTimeout: z.number().optional().default(30000), requestTimeout: z.number().optional().default(60000), maxRows: z.number().optional().default(1000), + sessionContext: z + .record(z.union([z.string(), z.number()])) + .optional(), + sessionContextReadOnly: z.boolean().optional().default(true), }); export type ConnectionConfig = z.infer; +/** + * Parses SQLSERVER_SESSION_CONTEXT, a JSON object of key/value pairs applied to + * every query via sp_set_session_context. Used by Row-Level Security predicates + * that scope rows by tenant or user rather than by login. + */ +export function parseSessionContext( + raw: string | undefined +): ConnectionConfig['sessionContext'] { + if (!raw || !raw.trim()) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + 'SQLSERVER_SESSION_CONTEXT must be a JSON object, e.g. {"TenantId":42}' + ); + } + + const result = ConnectionConfigSchema.shape.sessionContext.safeParse(parsed); + if (!result.success) { + throw new Error( + 'SQLSERVER_SESSION_CONTEXT must be a JSON object whose values are ' + + 'strings or numbers, e.g. {"TenantId":42,"UserRole":"auditor"}' + ); + } + + return result.data; +} + export interface TableInfo { table_catalog: string; table_schema: string;