TCIS.Pluggable.Persistence.PostgreSql 1.0.0-rc.33

This is a prerelease version of TCIS.Pluggable.Persistence.PostgreSql.
dotnet add package TCIS.Pluggable.Persistence.PostgreSql --version 1.0.0-rc.33
                    
NuGet\Install-Package TCIS.Pluggable.Persistence.PostgreSql -Version 1.0.0-rc.33
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TCIS.Pluggable.Persistence.PostgreSql" Version="1.0.0-rc.33" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.Pluggable.Persistence.PostgreSql" Version="1.0.0-rc.33" />
                    
Directory.Packages.props
<PackageReference Include="TCIS.Pluggable.Persistence.PostgreSql" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TCIS.Pluggable.Persistence.PostgreSql --version 1.0.0-rc.33
                    
#r "nuget: TCIS.Pluggable.Persistence.PostgreSql, 1.0.0-rc.33"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TCIS.Pluggable.Persistence.PostgreSql@1.0.0-rc.33
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TCIS.Pluggable.Persistence.PostgreSql&version=1.0.0-rc.33&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.Pluggable.Persistence.PostgreSql&version=1.0.0-rc.33&prerelease
                    
Install as a Cake Tool

TCIS.Pluggable.Persistence.PostgreSql

PostgreSQL provider for the Pluggable multi-tenant data layer. Supports all three tiers — Row-Level Security, schema per tenant, and database per tenant.

Shared behaviour — model composition, query filters, audit stamping — lives in TCIS.Pluggable.Persistence.EntityFrameworkCore.


1. Registration

dotnet add package TCIS.Pluggable.Persistence.PostgreSql
builder.Services.AddPluggablePostgreSql<TosDbContext>(commandTimeoutSeconds: 30);

Do not pass a connection string. It is resolved per request from the Tenant Store through IMultiTenantContextAccessor<TenantInfo>.


2. Tier support

Tier Mechanism Identifier used
Tier 1 — Row-Level Security set_config('app.tenant_id', …, false) TenantInfo.Id (GUID)
Tier 2 — Schema per tenant SET search_path TenantInfo.Identifier (site code)
Tier 3 — Database per tenant no-op initializer

Tier 2 takes the schema name from TenantInfo.Identifier, not TenantInfo.Id. A GUID contains -, which the identifier whitelist always rejects. The identifier must be a valid SQL identifier: ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$ — so TCI and CTL work, cat-lai does not.


3. Tier 2 — search_path has no fallback schema by default

PostgreSqlSchemaConnectionInitializer sets only the tenant's own schema:

SET search_path TO "tci"

No fallback. A tenant missing a table — usually because a migration has not run — gets relation does not exist immediately, instead of quietly reading data from another schema.

Before 07/08/2026 the initializer always appended public to search_path. A tenant missing a table therefore silently read from public — and if public happened to hold business tables (a migration run against the wrong schema, say), every tenant could read them, with no signal at all.

When you genuinely need a fallback

// Extensions (pgcrypto, uuid-ossp…) installed in public, called without schema qualification
new PostgreSqlSchemaConnectionInitializer("public")
// -> SET search_path TO "tci", public

// Shared reference data living in its own schema
new PostgreSqlSchemaConnectionInitializer("shared")
// -> SET search_path TO "tci", shared

Every schema you list becomes a place a query can land in. Never list a schema that holds business data.

Fallback names go straight into the SQL statement as well, so they pass the same whitelist — an invalid name throws at construction time, not when a connection is first opened.

A tenant's own tables always win over a fallback, because the tenant schema comes first in search_path.

Fail-closed on a blank identifier

A blank tenant identifier throws rather than being skipped. Skipping would leave the default search_path in place, so the session would read and write in the login user's schema — a cross-tenant leak.

This differs from Tier 1, where clearing the session context is enough for RLS to block everything. Tier 2 has no "empty schema" with equivalent semantics.


4. Tier 1 — Row-Level Security

set_config('app.tenant_id', @TenantId, false) runs on Open(); the third argument false makes it session-scoped rather than transaction-scoped, so it survives across the connection's lifetime.

Your policy reads it back with current_setting('app.tenant_id', true).

The application role must not hold BYPASSRLS, and the tables must have RLS enabled. Note that a table owner bypasses RLS unless you also set FORCE ROW LEVEL SECURITY — that keyword is PostgreSQL-specific and has no SQL Server equivalent.


5. Verified against a real database

Tier 2 is covered by integration tests against a real PostgreSQL container, including the two cases that only appear under contention:

  • Alternating tenants across pooled connections — a connection returning to the pool must not carry the previous tenant's search_path.
  • 40 concurrent queries interleaving two tenants — isolation holds under load. search_path is bound to a physical connection, so a connection reused by another thread is the most likely leak path, and it only surfaces when threads compete.

6. Pitfalls

# Pitfall Consequence
1 Using a GUID as the Tier 2 identifier Rejected by the whitelist — use the site code
2 Adding a schema with business tables to the fallback list Queries can silently land in another tenant's data
3 Expecting public to be searched by default It is not, on purpose — qualify extensions or declare the fallback
4 A tenant identifier containing - Whitelist rejection at construction time
5 Granting the application role BYPASSRLS on Tier 1 The last line of defence is gone
6 Forgetting FORCE ROW LEVEL SECURITY when the app owns the tables The owner bypasses RLS
7 Relying on commandTimeoutSeconds for Dapper It applies to EF Core only
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-rc.33 34 8/21/2026
1.0.0-rc.32 36 8/21/2026
1.0.0-rc.31 39 8/21/2026
1.0.0-rc.30 43 8/21/2026
1.0.0-rc.29 41 8/21/2026
1.0.0-rc.28 39 8/21/2026
1.0.0-rc.27 39 8/21/2026
1.0.0-rc.26 39 8/20/2026
1.0.0-rc.25 37 8/20/2026
1.0.0-rc.24 41 8/20/2026
1.0.0-rc.23 44 8/20/2026
1.0.0-rc.22 45 8/19/2026
1.0.0-rc.21 40 8/19/2026
1.0.0-rc.20 53 8/18/2026
1.0.0-rc.19 64 8/13/2026
1.0.0-rc.18 50 8/13/2026
1.0.0-rc.17 51 8/13/2026
1.0.0-rc.16 56 8/13/2026
1.0.0-rc.15 53 8/12/2026
1.0.0-rc.14 61 8/12/2026
Loading failed