TCIS.Pluggable.Persistence.PostgreSql
1.0.0-rc.33
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
<PackageReference Include="TCIS.Pluggable.Persistence.PostgreSql" Version="1.0.0-rc.33" />
<PackageVersion Include="TCIS.Pluggable.Persistence.PostgreSql" Version="1.0.0-rc.33" />
<PackageReference Include="TCIS.Pluggable.Persistence.PostgreSql" />
paket add TCIS.Pluggable.Persistence.PostgreSql --version 1.0.0-rc.33
#r "nuget: TCIS.Pluggable.Persistence.PostgreSql, 1.0.0-rc.33"
#:package TCIS.Pluggable.Persistence.PostgreSql@1.0.0-rc.33
#addin nuget:?package=TCIS.Pluggable.Persistence.PostgreSql&version=1.0.0-rc.33&prerelease
#tool nuget:?package=TCIS.Pluggable.Persistence.PostgreSql&version=1.0.0-rc.33&prerelease
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, notTenantInfo.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}$— soTCIandCTLwork,cat-laidoes 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_pathis 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 | Versions 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. |
-
net8.0
- Dapper (>= 2.1.66)
- Microsoft.EntityFrameworkCore (>= 8.0.13)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.13)
- Microsoft.Extensions.Caching.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.Configuration (>= 9.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection (>= 9.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Http (>= 9.0.0)
- Microsoft.Extensions.Logging (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 9.0.0)
- Microsoft.Extensions.Options.DataAnnotations (>= 8.0.0)
- Npgsql (>= 8.0.6)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 8.0.0)
- TCIS.Core (>= 1.0.0-rc.33)
- TCIS.MultiTenancy (>= 1.0.0-rc.33)
- TCIS.Pluggable.Persistence.EntityFrameworkCore (>= 1.0.0-rc.33)
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 |