Skip to content

feat: set SESSION_CONTEXT per request for Row-Level Security - #6

Open
lbertrand wants to merge 1 commit into
bilims:mainfrom
lbertrand:feat/session-context
Open

feat: set SESSION_CONTEXT per request for Row-Level Security#6
lbertrand wants to merge 1 commit into
bilims:mainfrom
lbertrand:feat/session-context

Conversation

@lbertrand

Copy link
Copy Markdown

Closes #5.

Tables guarded by Row-Level Security usually scope rows through SESSION_CONTEXT() rather than by login, because the server connects with a single shared account. QueryValidator blocks EXEC, EXECUTE and SP_, so the context could never be established and every RLS-protected query returned an empty recordset with nothing to indicate why. #5 has the full reasoning; this is the implementation.

Approach

SESSION_CONTEXT is connection-scoped and queries run through a pool of up to 10, so the context is emitted in the same batch as the query rather than in a separate round trip that would land on whichever connection the pool handed out. applySessionContext() builds the preamble and binds its parameters; query() prepends it.

For SQLSERVER_SESSION_CONTEXT='{"TenantId":42,"UserRole":"auditor"}' the batch is:

IF SESSION_CONTEXT(@__sc_key_0) IS NULL EXEC sys.sp_set_session_context @key = @__sc_key_0, @value = @__sc_val_0, @read_only = 1;
IF SESSION_CONTEXT(@__sc_key_1) IS NULL EXEC sys.sp_set_session_context @key = @__sc_key_1, @value = @__sc_val_1, @read_only = 1;
SELECT TOP 1000 * FROM dbo.Invoices

Notes for review

  • security.ts is untouched. The preamble is built from process configuration and never from the query parameter, so the read-only guarantee on model-supplied SQL is unchanged and the validator needs no exception. This should not conflict with the validator work in Improved Forbidden Keywords #1 or Security fixes #3.
  • Ordering matters. The preamble is prepended in query(), after addRowLimit() has run — its /^(\s*SELECT\s+)/i anchor would stop matching otherwise.
  • @read_only = 1 is the default. A query then cannot widen its own scope even if it reached the server with context already set. Each EXEC is guarded on SESSION_CONTEXT(@key) IS NULL to stay idempotent across reused pooled connections, since re-setting a locked key raises error 15664.
  • resetSessionContext() recycles the pool for the one case the guard cannot cover: changing a value that was locked read-only. Read-only keys are released only on connection reset; tedious exposes Connection.reset, but mssql never calls it and offers no per-checkout hook, so pool recycling is the only route through the public API. SQLSERVER_SESSION_CONTEXT_READONLY=false opts out.
  • Values bind by type. Numbers bind as int so predicates comparing against integer columns work without a CONVERT; strings bind as nvarchar.

Compatibility

When SQLSERVER_SESSION_CONTEXT is unset, the preamble is empty and the emitted SQL is byte-identical to today's. Both new settings are optional.

Verification

npx tsc --noEmit is clean. Behaviour was exercised against a stubbed pool capturing the emitted batch and bound parameters:

  • read-only default, mixed value types → guarded EXEC per key, 42 bound as Int, "auditor" as NVarChar
  • SQLSERVER_SESSION_CONTEXT_READONLY=false → unguarded EXEC, no @read_only
  • unconfigured → SELECT 1 emitted verbatim, no preamble
  • malformed env values (not json, [1,2], nested objects) → rejected at startup with a message naming the expected shape

I did not add a test file: npm test runs jest but there is no jest config or src/test/ on main (#3 adds both). Glad to add coverage in whichever form you'd prefer, or to rebase on #3 once it lands.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support RLS via SESSION_CONTEXT set per-request server-side

1 participant