A tenant ID in a route tells your application which tenant the caller requested. Your
membership store still has to decide whether that caller belongs to it.
I maintain LakeWright.NET as an independent open-source project. Its Core and Databricks
packages let an existing .NET application resolve that membership, create a tenant context,
and execute a parameterized statement. You can keep your identity provider and membership store.
This example uses published 3.0.0 packages in a standalone .NET 8 console application.
It needs neither Signalboard nor PostgreSQL.
Run an allowed query and a denied request
Install the .NET 8 SDK .
Download the companion project and extract it. The
source directory
is pinned to the tested revision. Run from the extracted project directory:
dotnet restore --configfile NuGet.Config
dotnet run -c Release -- --offline
The output is:
ALLOW: member-alice -> Acme; tenant-scoped query returned 1.
DENY: member-alice -> Globex; membership denied; no statement sent; transport requests remain 1.
OFFLINE_OK: published 3.0.0; no workspace credentials or external network calls.
Restore downloads packages. The executable then talks only to its own loopback HTTP fixture.
It checks the allowed request's catalog, schema, SQL and bound value. Alice's request for
Globex returns no context, so the example stops before constructing a statement. The
transport count stays at one.
Watch the walkthrough
Read the video transcript · English captions · Download the example
This is a console fixture. It does not host an ASP.NET endpoint or capture an HTTP 404.
Resolve membership before constructing the statement
The relevant part of the compiled example is:
var tenant = await resolver.ResolveAsync(MembershipResolver.Acme, PrincipalId, ct)
?? throw new InvalidOperationException("The fixture member was not resolved.");
var result = await executor.ExecuteAsync(
TenantScopedStatement.Create(tenant, "SELECT :value AS value",
StatementParameter.Int("value", 1)), ct);
resolver and executor come from dependency injection. MembershipResolver.Acme and
PrincipalId are demonstration identities defined in the project. The resolver checks a
server-owned (tenant, principal) mapping before calling ITenantContextFactory.
In your application, replace that mapping with your trusted membership lookup. Obtain the
principal from the authenticated server identity. Load the catalog and schema from stored
tenant configuration. A caller-supplied tenant ID or principal string cannot establish membership.
Register your resolver with AddLakeWrightTenancy<YourMembershipResolver>(). Keep the context
factory inside that trusted application layer. An endpoint should ask the resolver for a
context and stop if it receives null. The guide uses a 404 policy for that case.
Keep the SQL in trusted application code
StatementParameter.Int("value", 1) binds a value. The SQL text stays constant.
Use parameters for values rather than building SQL by concatenation.
A tenant context supplies the statement's catalog and schema. Those are default namespaces ,
not a SQL sandbox. Trusted code can still name another catalog or schema explicitly. Databricks
grants must enforce the storage boundary your application requires. Do not pass arbitrary
caller-supplied or Genie-generated SQL into this path.
The fixture tests the application's membership boundary and one serialized query. Separate
server-side tenant schemas, exports and stored statement-ID ownership need their own checks.
Connect to a workspace after the local check
The existing-application guide
lists warehouse access, catalog/schema permissions and credential choices. The provided
executable has no live switch. Adapting it for your application means replacing the fixture
transport and membership data after those prerequisites are available.
On 7 November 2026, a separate installed 3.0.0 .NET 8 consumer passed a bounded check in a
nonproduction AWS workspace. A custom TokenCredential adapter forwarded the selected
Databricks CLI user's OAuth token. The bound query returned 1. Wrong-tenant membership
denial and default-statement rejection added zero SQL calls.
That check did not exercise Azure managed identity, business tables, billing or jobs. Its
sample catalog/schema did not establish isolation between separate server-side tenant schemas.
The compatibility record
records the tested path. The video above shows the offline example.
When setup fails
Symptom
Check
The application asks for --offline
This companion deliberately has no live mode. Use the guide when adapting it.
The allowed fixture request fails
Run from the complete extracted project, including its local build and NuGet configuration.
A real caller cannot resolve membership
Check the authenticated principal and trusted membership mapping before changing SQL or grants.
Retargeting to .NET 10 produces NU1605
Align the direct Configuration and DependencyInjection package pins with the tested .NET 10 adaptation.
Both .NET 8 and .NET 10 consumers passed the offline allow/deny fixture. The companion README
includes the .NET 10 adaptation; it keeps LakeWright at 3.0.0 and aligns the direct
Microsoft.Extensions dependencies.
If you also use LakeWright's durable-operation modules, read
ADR 0033
before upgrading to 3.0.0. Admission rejects caller-owned RepeatableRead and Serializable
transactions. Invoke ClaimNextAsync outside that caller transaction, or use ReadCommitted.
Commit a returned claim before submitting remote work. After the deletion grace period,
purge may discard unclaimed Pending entries that were never submitted. Other incomplete work
still blocks purge. The Core/Databricks console example does not exercise those optional flows.