A tenant ID in a route identifies the tenant the caller requested. Before you run SQL, check the authenticated caller against your membership store.
I maintain LakeWright.NET, an independent open-source project. This example uses its published 3.0.0 Core and Databricks packages in a .NET 8 console app. It checks membership, creates a tenant context for an allowed request, and runs a parameterized query. You can keep your own identity provider and membership store. The example runs without Signalboard or 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.
This is a console fixture. It does not host an ASP.NET endpoint or capture an HTTP 404.
Watch the LakeWright demo on YouTube
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 sets the default catalog and schema for the statement. SQL can still name another catalog or schema explicitly. Use Databricks grants to enforce the storage boundary, and keep caller-supplied or Genie-generated SQL out of this execution 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.
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.

