Skip to content

A boundary the caller cannot reach.

What it is
A reference architecture and seven packages — not a framework you adopt whole.
Who for
.NET teams selling analytics to customers who are not themselves Databricks customers.
Status
Prerelease — 0.1.2-preview.1, Apache-2.0, .NET 10. Seven packages, published on NuGet.

Unity Catalog row filters resolve the caller with session_user(). One service principal for every customer means one predicate for every tenant. LakeWright.NET moves tenant isolation into the query layer — open source, Apache-2.0, for .NET teams on Databricks.

Three requests · one boundary · same code pathGET /organizations/{acme}/operations/{id}
200OK · 10 rows
200OK · read only
404Not Found

demo|bob is an Admin. His request still stops at the boundary — not refused, no path. Membership resolves before authorization runs, so the answer is 404. A 403 would confirm the work exists.

Nothing here is a mock-up. Every identity, status code and row comes from the Signalboard sample in the repository, and you can send the same requests yourself in two minutes.

Pick a conductor above. The response, the pipeline and the register all answer as that person — the request is printed in full below.

Sending as Acme Logistics member demo|alice.

The default breaks quietly. This is what it looks like.

Sent as demo|alice — the same two requests, whoever you pick

R-01Read one operation

ACME=0198f000-0000-7000-8000-00000000ac11OP=019fbd1a-9c30-7af8-b9fd-0843432ca47fcurl -i http://localhost:8080/organizations/$ACME/operations/$OP \ -H "X-Demo-User: demo|alice"
200OK

A member of Acme. The tenant resolved, so the store answers.

R-02Start work

ACME=0198f000-0000-7000-8000-00000000ac11curl -i -X POST http://localhost:8080/organizations/$ACME/operations \ -H "X-Demo-User: demo|alice" \ -H "Idempotency-Key: nightly-2026-08-01" \ -H "Content-Type: application/json" \ -d '{"kind":"analysis"}'
202Accepted

Accepted. Send it twice with the same key and you get this operation back, not a second Databricks run.

  1. Step 01AuthenticateThe demo scheme believes demo|alice.
  2. Step 02Resolve tenantMembership found. TenantContext issued for Acme.
  3. Step 03Authorize roleAdmin. A floor inside the tenant, not the boundary.
  4. Step 04Operation storeFilters on the resolved tenant. Ten rows.
The filter every tenant getssame predicate
-- Unity Catalog's own syntax. Evaluated as the-- connected principal, not as your end user.CREATE FUNCTION tenant_rows(tenant_id STRING)RETURN tenant_id = tenant_of(session_user());ALTER TABLE events SET ROW FILTER tenant_rows  ON (tenant_id);

One backend, one service principal, every request. The predicate is identical for all of them: every tenant sees everything, or every tenant sees nothing. Both look like a working product from outside, which is why this is found in an audit rather than in testing.

404Not a member of the tenant

Membership is read from the database on every request and resolves before authorization runs. The request stops at resolution, and the query layer has no overload that omits the tenant.

403A member, wrong role

Vera keeps every row and loses one control, and the API refuses her write too — a hidden button is not an authorization control. Roles are a floor inside a tenant, never the boundary.

Operation register — what Alice can seeAcme Logistics · 0198f000-0000-7000-8000-00000000ac11
Operations belonging to Acme Logistics
StartedKindStateByOperation
11:34:25analysisPendingAlice019fbd1a-9c30-7af8-b9fd-0843432ca47f
11:31:55analysisPendingAlice019fbd18-5197-73f6-b2ca-7f4057f50f08
11:16:23analysisPendingAlice019fbd0a-1927-78cf-a121-6259c768b649
11:13:30analysisPendingAlice019fbd07-750b-7668-9301-2ce475819a60
11:12:17analysisPendingAlice019fbd06-585f-7941-ba09-147576a14605
11:10:43analysisPendingAlice019fbd04-e855-7154-bce3-f00bde756fe1
11:06:19analysisRunningAlicee369391d-8e48-44ae-a112-69f78993f0ca
10:51:19exportFailedAlice46c22a0c-571f-45f1-b37c-62b0190bd006
10:37:19exportSucceededVera4ff8a9d2-e029-4c3e-9635-84cf0b68a806
10:24:19analysisSucceededAlice4aff9092-fad3-499d-8962-8f96cfbc1f16

Ten rows, because ten belong to Acme Logistics. The dashboard reads through the same resolver and the same store as the API.

A real interface, photographed, not drawn.

A browser smoke test drives the sample and takes these. Nothing is retouched, and every plate opens its full file.

A · Alice, Admin at Acme10 operations
Signalboard in its dark colour scheme, showing the Acme Logistics operations table
B · 390 px
The Signalboard dashboard at 390 pixels wide
C · Vera, Viewer at Acme403 on write
Vera sees every Acme operation, and the Start control is disabled with the reason stated beside it

Every row Acme has, and one control she cannot use — the reason printed beside it rather than the button hidden.

D · Bob, Admin at Globex404 cross-tenant
Bob's dashboard reads Globex Freight, and nothing yet

Same application, same code path, different membership. As far as Bob's session is concerned, Acme's work does not exist.

Hover a plate to straighten and lift it. C and D are printed over-size and cropped to the top-left, because a full-width thumbnail of a 1280-pixel screenshot is unreadable.

Seven packages, and nothing you must take.

Take 01–04 for tenancy and the query layer. 05–07 are brokered access, kept separate so a team who does not want them never compiles them. There is no meta-package.

Enforced by a test, not a convention

LakeWright.Multitenancy does not reference LakeWright.Databricks, so adopting the tenancy tier alone does not drag in the Databricks client. DependencyDirectionTests fails if that changes.

Does not compile

Passing an interpolated string where SQL is expected does not compile. Values go in as StatementParameter arguments; catalog and schema come from the tenant context and are validated as identifiers.

Secretless, with one named exception

You register a TokenCredential, not a string — Entra tokens expire within the hour and the credential is what knows how to get another one. One optional module is the exception: LakeWright.Embedding needs a service principal OAuth secret, because Databricks documents no other credential for the AI/BI external-embedding token exchange — so an application that embeds dashboards holds one long-lived secret.

Docker is the only requirement.

No .NET SDK, no Databricks workspace, no account — tenancy and authorization never talk to Databricks, so the isolation model runs without one. The sample seeds Alice, Vera and Bob, so the test above is the one you run locally.

Two minutes to a running sample
git clone https://github.com/ivanvyd/LakeWright.NETcd LakeWright.NET/samples/Signalboarddocker compose up# then open http://localhost:8080 and sign in# as Alice, Vera or Bob
Or wire it into your application
builder.Services.AddLakeWright(config);builder.Services.AddLakeWrightDatabricks(config);builder.Services.AddLakeWrightOperationWorker(config);app.UseAuthentication();app.UseLakeWrightTenancy();app.UseAuthorization();app.MapLakeWrightOperations();

Order matters. UseLakeWrightTenancy() sits between authentication and authorization, because it resolves what the policies read.

Working today

  • Tenant model and resolution, provisioning and deletion
  • Tenant-scoped Databricks query layer
  • Async operation worker — crash reconciliation, resumed polling
  • ASP.NET Core middleware, role policies, operations API
  • Client idempotency
  • Append-only audit trail
  • Declarative Automation Bundle
  • Telemetry instruments — no tenant id on any metric
  • The Signalboard sample

Still open

  • Cost attribution in currency, and per-tenant token metering
  • An observability export
  • A reference deployment

Deliberately not this

  • Not a Databricks SDK — it depends on Microsoft.Azure.Databricks.Client
  • Not a dashboard embedding library — AI/BI external embedding ships today; use it
  • Not a generic ASP.NET Core SaaS starter. If you are not on Databricks, nothing here is for you
  • Not an admin portal, a workspace provisioner, or a notebook tutorial

Read the code. That is the point.

Every identity, status code and row on this page comes from the Signalboard sample in the repository. Clone it and send the same requests yourself.