@zerotal/tenancy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +52 -0
- package/src/EnsureTenancyMiddleware.ts +32 -0
- package/src/Tenancy.ts +256 -0
- package/src/TenancyMiddleware.ts +136 -0
- package/src/TenantContext.ts +56 -0
- package/src/TenantManager.ts +93 -0
- package/src/TenantModel.ts +25 -0
- package/src/Tenantable.ts +138 -0
- package/src/config.ts +38 -0
- package/src/errors.ts +86 -0
- package/src/facades/Tenant.ts +32 -0
- package/src/index.ts +55 -0
- package/src/provider/TenancyProvider.ts +136 -0
- package/src/resolvers/AuthResolver.ts +56 -0
- package/src/resolvers/HeaderResolver.ts +24 -0
- package/src/resolvers/PathResolver.ts +52 -0
- package/src/resolvers/RouteParamResolver.ts +40 -0
- package/src/resolvers/SubdomainResolver.ts +34 -0
- package/src/tenantSchemaConcern.ts +41 -0
- package/src/tenantStorage.ts +179 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenancyMiddleware — resolves the active tenant and opens a TenantContext boundary.
|
|
3
|
+
*
|
|
4
|
+
* You do not register this yourself: `TenancyProvider` adds it to the **global**
|
|
5
|
+
* pipeline automatically (and it is not exported from the package). It runs on every
|
|
6
|
+
* request, so once a tenant is resolved every downstream service — `Auth.user()`, model
|
|
7
|
+
* queries, cache, storage — is transparently scoped to that tenant.
|
|
8
|
+
*
|
|
9
|
+
* It is deliberately **lenient about absence**: a request that resolves no tenant simply
|
|
10
|
+
* continues with no boundary, which is what lets tenant-less routes (login, signup,
|
|
11
|
+
* marketing) coexist with tenant-scoped ones. That is safe because `Tenantable`'s scope
|
|
12
|
+
* matches nothing outside a boundary — no tenant means no rows, not everyone's rows. Apply
|
|
13
|
+
* {@link EnsureTenancyMiddleware} to routes that must *have* a tenant.
|
|
14
|
+
*
|
|
15
|
+
* It is **strict about identity**: every resolver except {@link AuthResolver} takes its
|
|
16
|
+
* identifier from the request (a subdomain, a header, a path segment), so an authenticated
|
|
17
|
+
* requester who names a tenant must be a member of it. See {@link TenantResolver.trusted}.
|
|
18
|
+
*
|
|
19
|
+
* Outcomes:
|
|
20
|
+
* - no resolver matches → continue, no boundary (tenant-scoped models return nothing)
|
|
21
|
+
* - resolver names a tenant that does not exist → continue, no boundary
|
|
22
|
+
* - tenant exists but `isActive` is false → 403
|
|
23
|
+
* - signed-in requester named a tenant they do not belong to → 403
|
|
24
|
+
* - tenant exists and is active → open the boundary
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { BaseMiddleware, type NextFn, type HttpContext } from "@zerotal/core";
|
|
28
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
29
|
+
import { TenantModel } from "./TenantModel.ts";
|
|
30
|
+
import { DB } from "@zerotal/orm";
|
|
31
|
+
import { MEMBERS_TABLE } from "./Tenancy.ts";
|
|
32
|
+
import { TenantInactiveError, TenancyNotConfiguredError, TenantForbiddenError } from "./errors.ts";
|
|
33
|
+
import type { Tenant, TenancyConfigShape } from "./types.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The authenticated user's id, or `null` for an anonymous request.
|
|
37
|
+
*
|
|
38
|
+
* `AuthenticatedUser` is an empty interface until an app augments it, so reaching `id`
|
|
39
|
+
* needs one structural view. Kept here so the membership check has exactly one.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
function _authUserId(http: HttpContext): number | null {
|
|
43
|
+
const user = (http as { user?: { id?: unknown } | null }).user;
|
|
44
|
+
return typeof user?.id === "number" ? user.id : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class TenancyMiddleware extends BaseMiddleware {
|
|
48
|
+
protected options: {} = {};
|
|
49
|
+
|
|
50
|
+
private static _config?: TenancyConfigShape;
|
|
51
|
+
|
|
52
|
+
/** Called by TenancyProvider during boot. */
|
|
53
|
+
static configure(config: TenancyConfigShape): void {
|
|
54
|
+
TenancyMiddleware._config = config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
58
|
+
const config = TenancyMiddleware._config;
|
|
59
|
+
if (!config)
|
|
60
|
+
throw new TenancyNotConfiguredError(
|
|
61
|
+
"TenancyMiddleware used before TenancyProvider was booted.",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Try each resolver in order until one identifies a tenant. Each resolver reads
|
|
65
|
+
// whatever it needs off the HttpContext (route params, the auth user, headers, …).
|
|
66
|
+
let identifier: string | number | null = null;
|
|
67
|
+
let by: "slug" | "id" = "slug";
|
|
68
|
+
let trusted = false;
|
|
69
|
+
for (const resolver of config.resolvers) {
|
|
70
|
+
const result = resolver.resolve(http);
|
|
71
|
+
if (result != null && result.identifier !== "" && result.identifier != null) {
|
|
72
|
+
identifier = result.identifier;
|
|
73
|
+
by = result.by ?? "slug";
|
|
74
|
+
trusted = resolver.trusted === true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No tenant could be identified — run tenant-less against the default database.
|
|
80
|
+
// Routes that must have a tenant reject this later via EnsureTenancyMiddleware.
|
|
81
|
+
if (identifier == null) return next();
|
|
82
|
+
|
|
83
|
+
// Load the tenant from the internal registry (owned by @zerotal/tenancy), matching
|
|
84
|
+
// on slug or primary key depending on how the resolver identified it.
|
|
85
|
+
const column = by === "id" ? "id" : "slug";
|
|
86
|
+
const tenant = (await TenantModel.query().where(column, identifier).first()) as Tenant | null;
|
|
87
|
+
|
|
88
|
+
// An identifier that maps to no tenant is treated leniently (no boundary) — the
|
|
89
|
+
// EnsureTenancyMiddleware gate turns this into a 404 only where a tenant is required.
|
|
90
|
+
if (!tenant) return next();
|
|
91
|
+
|
|
92
|
+
// A known-but-disabled tenant is a definitive rejection, surfaced everywhere.
|
|
93
|
+
if (!tenant.isActive) throw new TenantInactiveError();
|
|
94
|
+
|
|
95
|
+
// An authenticated requester who *names* a tenant must belong to it. Subdomains,
|
|
96
|
+
// headers, path segments and route params are all attacker-chosen, so without this a
|
|
97
|
+
// user of tenant A reaches tenant B by editing one header — the tenant boundary would
|
|
98
|
+
// be decided entirely by the client. Anonymous requests are left alone: a public
|
|
99
|
+
// tenant surface (marketing page, login form) has no membership to check, and nothing
|
|
100
|
+
// downstream is authorised on identity yet.
|
|
101
|
+
if (!trusted) {
|
|
102
|
+
await this._assertMembership(http, tenant);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Attach tenant to http for convenient access in controllers.
|
|
106
|
+
(http as unknown as Record<string, unknown>)["tenant"] = tenant;
|
|
107
|
+
|
|
108
|
+
// Establish the ALS boundary so all downstream code can call TenantContext.get().
|
|
109
|
+
return new Promise<Response | void>((resolve, reject) => {
|
|
110
|
+
TenantContext.run(tenant, () => {
|
|
111
|
+
next().then(resolve, reject);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reject an authenticated requester who named a tenant they do not belong to.
|
|
118
|
+
*
|
|
119
|
+
* Queries the membership pivot directly rather than going through the `Tenant` facade:
|
|
120
|
+
* this check runs on every request and must not depend on the container being resolvable,
|
|
121
|
+
* and `Tenancy.isMember()` reads the *current* tenant id — which is precisely the tenant
|
|
122
|
+
* we have not entered yet.
|
|
123
|
+
*
|
|
124
|
+
* @throws {@link TenantForbiddenError} When a signed-in user is not a member.
|
|
125
|
+
*/
|
|
126
|
+
private async _assertMembership(http: HttpContext, tenant: Tenant): Promise<void> {
|
|
127
|
+
const userId = _authUserId(http);
|
|
128
|
+
if (userId === null) return; // anonymous — nothing to check against
|
|
129
|
+
|
|
130
|
+
const row = await DB.table(MEMBERS_TABLE)
|
|
131
|
+
.where("tenant_id", tenant.id)
|
|
132
|
+
.where("user_id", userId)
|
|
133
|
+
.first();
|
|
134
|
+
if (!row) throw new TenantForbiddenError();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantContext — AsyncLocalStorage-based tenant store.
|
|
3
|
+
*
|
|
4
|
+
* Once TenancyMiddleware resolves a tenant and calls `TenantContext.run()`,
|
|
5
|
+
* every async operation downstream (including ORM queries, mail jobs, queue
|
|
6
|
+
* workers dispatched from within the same request) can call
|
|
7
|
+
* `TenantContext.get()` to retrieve the active tenant without threading it
|
|
8
|
+
* manually through function arguments.
|
|
9
|
+
*
|
|
10
|
+
* This parallels how RequestContext works in @zerotal/core.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import { NoActiveTenantError } from "./errors.ts";
|
|
15
|
+
import type { Tenant } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
const _storage = new AsyncLocalStorage<Tenant>();
|
|
18
|
+
|
|
19
|
+
export class TenantContext {
|
|
20
|
+
/**
|
|
21
|
+
* Execute `callback` inside a tenant boundary.
|
|
22
|
+
* Called by TenancyMiddleware before forwarding to the next handler.
|
|
23
|
+
*/
|
|
24
|
+
static run<T>(tenant: Tenant, callback: () => T): T {
|
|
25
|
+
return _storage.run(tenant, callback);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the active tenant.
|
|
30
|
+
* Throws if called outside a tenant context (i.e. without TenancyMiddleware).
|
|
31
|
+
*/
|
|
32
|
+
static get(): Tenant {
|
|
33
|
+
const t = _storage.getStore();
|
|
34
|
+
if (!t) throw new NoActiveTenantError();
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return the active tenant, or undefined if called outside a tenant context.
|
|
40
|
+
* Useful in shared code that runs in both tenant and non-tenant contexts
|
|
41
|
+
* (CLI commands, background jobs that process all tenants).
|
|
42
|
+
*/
|
|
43
|
+
static tryGet(): Tenant | undefined {
|
|
44
|
+
return _storage.getStore();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convenience: return the current tenant's id, or null outside a context. */
|
|
48
|
+
static id(): number | null {
|
|
49
|
+
return _storage.getStore()?.id ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Convenience: return the current tenant's slug, or null outside a context. */
|
|
53
|
+
static slug(): string | null {
|
|
54
|
+
return _storage.getStore()?.slug ?? null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantManager — the per-tenant database connection pool for the multi-database
|
|
3
|
+
* strategy. You normally never touch it directly: when you give `TenancyConfig` a
|
|
4
|
+
* `connect` factory, `TenancyProvider` builds a TenantManager from it and registers
|
|
5
|
+
* an ORM connection resolver, so every model query inside a tenant boundary is routed
|
|
6
|
+
* to the right database automatically.
|
|
7
|
+
*
|
|
8
|
+
* It is exported for the rare case where you need the raw connection for a tenant
|
|
9
|
+
* outside the ORM (a manual migration, a bulk import). In the single-database strategy
|
|
10
|
+
* you don't need this class at all — `Tenantable` isolates via `WHERE tenant_id = ?`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Pre-warm connections for all known tenants at startup:
|
|
14
|
+
* const tenants = await Tenant.all();
|
|
15
|
+
* for (const t of tenants) manager.warmUp(t);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SQLInstance } from "@zerotal/orm";
|
|
19
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
20
|
+
import type { MultiDbTenant, Tenant } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
export interface TenantManagerOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Factory that opens a new database connection for the given tenant.
|
|
25
|
+
* Called once per tenant on first use; the result is cached.
|
|
26
|
+
*/
|
|
27
|
+
connect(tenant: MultiDbTenant): SQLInstance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TenantManager {
|
|
31
|
+
private readonly _pool = new Map<number, SQLInstance>();
|
|
32
|
+
private readonly _opts: TenantManagerOptions;
|
|
33
|
+
|
|
34
|
+
constructor(opts: TenantManagerOptions) {
|
|
35
|
+
this._opts = opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return the database connection for the currently active tenant.
|
|
40
|
+
* Throws if called outside a TenantContext boundary.
|
|
41
|
+
*/
|
|
42
|
+
connection(): SQLInstance {
|
|
43
|
+
const tenant = TenantContext.get() as MultiDbTenant;
|
|
44
|
+
return this._forTenant(tenant);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return the database connection for a specific tenant without requiring
|
|
49
|
+
* an active TenantContext. Useful in background jobs that iterate tenants.
|
|
50
|
+
*/
|
|
51
|
+
connectionFor(tenant: MultiDbTenant): SQLInstance {
|
|
52
|
+
return this._forTenant(tenant);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pre-open the connection for a tenant so the first request is instant.
|
|
57
|
+
* Safe to call at boot time before any requests arrive.
|
|
58
|
+
*/
|
|
59
|
+
warmUp(tenant: MultiDbTenant): void {
|
|
60
|
+
this._forTenant(tenant);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Close and evict a tenant's connection (e.g. after a tenant is deleted). */
|
|
64
|
+
evict(tenant: Tenant): void {
|
|
65
|
+
const conn = this._pool.get(tenant.id);
|
|
66
|
+
if (conn) _closeConnection(conn);
|
|
67
|
+
this._pool.delete(tenant.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Close all open connections. */
|
|
71
|
+
closeAll(): void {
|
|
72
|
+
for (const [id, conn] of this._pool) {
|
|
73
|
+
_closeConnection(conn);
|
|
74
|
+
this._pool.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private _forTenant(tenant: MultiDbTenant): SQLInstance {
|
|
79
|
+
let conn = this._pool.get(tenant.id);
|
|
80
|
+
if (!conn) {
|
|
81
|
+
conn = this._opts.connect(tenant);
|
|
82
|
+
this._pool.set(tenant.id, conn);
|
|
83
|
+
}
|
|
84
|
+
return conn;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Close a connection via whichever teardown method it exposes (`end`/`close`). */
|
|
89
|
+
function _closeConnection(conn: SQLInstance): void {
|
|
90
|
+
const c = conn as unknown as { end?: () => unknown; close?: () => unknown };
|
|
91
|
+
if (typeof c.end === "function") c.end();
|
|
92
|
+
else if (typeof c.close === "function") c.close();
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BaseModel, column, table } from "@zerotal/orm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The internal tenant record — owned by `@zerotal/tenancy` (like `AuditLog` is owned
|
|
5
|
+
* by `@zerotal/audit`). Its table (`tenants`) and the `tenant_members` pivot are
|
|
6
|
+
* provisioned on boot by `tenantSchemaConcern`, so apps don't define a tenant model
|
|
7
|
+
* or write migrations for it. Reach tenants through the `Tenant` facade, not this
|
|
8
|
+
* class directly.
|
|
9
|
+
*
|
|
10
|
+
* `TenantModel` is deliberately NOT `Tenantable` — it lives in the central/platform
|
|
11
|
+
* database and is what bootstraps a tenant context, not something scoped within one.
|
|
12
|
+
*/
|
|
13
|
+
@(table("tenants").withTimestamps())
|
|
14
|
+
export class TenantModel extends BaseModel {
|
|
15
|
+
@column() slug!: string;
|
|
16
|
+
@column() name!: string;
|
|
17
|
+
@column("boolean") isActive!: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Connection target for the multi-database strategy; null in single-database.
|
|
20
|
+
* Nullable so a single-database `Tenant.create()` (no `database`) doesn't violate a
|
|
21
|
+
* NOT NULL column — this must match `tenantSchemaConcern`, and it is what the ORM's
|
|
22
|
+
* model auto-synchronize uses when it provisions the `tenants` table.
|
|
23
|
+
*/
|
|
24
|
+
@column({ nullable: true }) database?: string | null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ── Tenantable ──────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Multi-tenant model mixin. Compose it via `BaseModelWith`, like every other mixin:
|
|
4
|
+
//
|
|
5
|
+
// import { BaseModelWith, column, table } from "@zerotal/orm";
|
|
6
|
+
// import { Tenantable } from "@zerotal/tenancy";
|
|
7
|
+
//
|
|
8
|
+
// @table("projects").withTimestamps()
|
|
9
|
+
// export class Project extends BaseModelWith(Tenantable) {
|
|
10
|
+
// @column() name!: string;
|
|
11
|
+
// @column() tenantId!: number;
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// Configure the tenant FK column by overriding the static field (default
|
|
15
|
+
// `tenant_id`):
|
|
16
|
+
//
|
|
17
|
+
// protected static tenantColumn = "org_id";
|
|
18
|
+
//
|
|
19
|
+
// It registers two behaviours against the model:
|
|
20
|
+
// 1. Query scoping — every SELECT/UPDATE/DELETE gets `WHERE <col> = <tenant id>`
|
|
21
|
+
// via the ORM global-scope system. Outside a tenant context the scope matches
|
|
22
|
+
// *nothing*, so a missing boundary is an empty result, never every tenant's rows.
|
|
23
|
+
// 2. Create injection — inside a boundary the tenant column is set from the context and
|
|
24
|
+
// is authoritative, so a client-supplied `tenant_id` cannot redirect the write.
|
|
25
|
+
//
|
|
26
|
+
// const projects = await Project.all(); // … WHERE tenant_id = 7
|
|
27
|
+
// const all = await Project.query().withoutTenancy().get(); // bypass for admin
|
|
28
|
+
//
|
|
29
|
+
// Registration is deferred: the mixin enqueues the anonymous class at module-load
|
|
30
|
+
// (before the ORM context is live) and TenancyProvider.onBooted() drains the queue
|
|
31
|
+
// and wires each class to the live ORM context. This matches the Auditable pattern
|
|
32
|
+
// and avoids hooks/scopes being lost when registerAppScope resets the ORM context.
|
|
33
|
+
|
|
34
|
+
import { TenantContext } from "./TenantContext.ts";
|
|
35
|
+
import { _globalScopeRegistry, ModelQueryBuilder as MQB, HookRegistry } from "@zerotal/orm";
|
|
36
|
+
import type { ModelQueryBuilder } from "@zerotal/orm";
|
|
37
|
+
import type { BaseModel } from "@zerotal/orm";
|
|
38
|
+
|
|
39
|
+
const SCOPE_NAME = "__tenant__";
|
|
40
|
+
const DEFAULT_COLUMN = "tenant_id";
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
|
|
43
|
+
type Constructor<T = object> = new (...args: any[]) => T;
|
|
44
|
+
|
|
45
|
+
/** Resolve the tenant column for a concrete model class (honours static override). */
|
|
46
|
+
function _tenantColumn(ctor: unknown): string {
|
|
47
|
+
return (ctor as { tenantColumn?: string })?.tenantColumn ?? DEFAULT_COLUMN;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classes awaiting tenant-scope registration. `Tenantable` adds to this set at
|
|
52
|
+
* module-load; `TenancyProvider.onBooted()` drains it once the ORM context is live.
|
|
53
|
+
* After boot, new classes are registered immediately via `_markTenantProviderBooted()`.
|
|
54
|
+
*/
|
|
55
|
+
export const _pendingTenantable = new Set<Function>();
|
|
56
|
+
let _tenantProviderBooted = false;
|
|
57
|
+
export function _markTenantProviderBooted(): void {
|
|
58
|
+
_tenantProviderBooted = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wire the global query scope and beforeCreate hook onto a model class.
|
|
63
|
+
* Called by `TenancyProvider.onBooted()` for each `Tenantable`-composed class.
|
|
64
|
+
*/
|
|
65
|
+
export function registerTenantScoping(cls: Function): void {
|
|
66
|
+
// ── 1. Global query scope ────────────────────────────────────────────────
|
|
67
|
+
const registry = _globalScopeRegistry();
|
|
68
|
+
let scopes = registry.get(cls as unknown as typeof BaseModel);
|
|
69
|
+
if (!scopes) {
|
|
70
|
+
scopes = new Map();
|
|
71
|
+
registry.set(cls as unknown as typeof BaseModel, scopes);
|
|
72
|
+
}
|
|
73
|
+
scopes.set(SCOPE_NAME, (qb: ModelQueryBuilder<BaseModel>) => {
|
|
74
|
+
const column = _tenantColumn((qb as unknown as { _ModelClass?: unknown })._ModelClass);
|
|
75
|
+
const tenantId = TenantContext.id();
|
|
76
|
+
if (tenantId !== null) {
|
|
77
|
+
qb.where(column, tenantId);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// No tenant context: match nothing rather than everything. A tenant-scoped model
|
|
81
|
+
// queried outside a boundary — the apex domain, a queue worker, a resolver that found
|
|
82
|
+
// no tenant — otherwise returned every tenant's rows, and did it silently. Failing
|
|
83
|
+
// closed turns that into an obviously-empty result, and code that genuinely wants
|
|
84
|
+
// cross-tenant reach says so with `withoutTenancy()`.
|
|
85
|
+
qb.whereRaw("1 = 0");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── 2. Set the tenant column on create ───────────────────────────────────
|
|
89
|
+
HookRegistry.register(cls as unknown as typeof BaseModel, "beforeCreate", (model: BaseModel) => {
|
|
90
|
+
const column = _tenantColumn(model.constructor);
|
|
91
|
+
const rec = model as unknown as Record<string, unknown>;
|
|
92
|
+
const tenantId = TenantContext.id();
|
|
93
|
+
// Inside a boundary the context is authoritative and overwrites whatever is there.
|
|
94
|
+
// Fill-if-absent made the column mass-assignable: `Project.create(request.all())` with
|
|
95
|
+
// an attacker-supplied `tenant_id` wrote straight into another tenant, with the
|
|
96
|
+
// mass-assignment guard as the only thing in the way. Cross-tenant writes are done by
|
|
97
|
+
// running inside that tenant's context, not by naming it in the payload.
|
|
98
|
+
if (tenantId !== null) rec[column] = tenantId;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function Tenantable<TBase extends Constructor>(Base: TBase) {
|
|
103
|
+
const cls = class extends Base {
|
|
104
|
+
/** Tenant foreign-key column. Override per model to change it. */
|
|
105
|
+
static tenantColumn = DEFAULT_COLUMN;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
_pendingTenantable.add(cls);
|
|
109
|
+
if (_tenantProviderBooted) {
|
|
110
|
+
// Provider already booted — register into the live context now.
|
|
111
|
+
// The class stays in _pendingTenantable so future onBooted() calls
|
|
112
|
+
// (new Application instances) re-register it into the new context.
|
|
113
|
+
registerTenantScoping(cls);
|
|
114
|
+
}
|
|
115
|
+
return cls;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Augment ModelQueryBuilder with withoutTenancy() ──────────────────────────
|
|
119
|
+
|
|
120
|
+
declare module "@zerotal/orm" {
|
|
121
|
+
interface ModelQueryBuilder<M extends BaseModel> {
|
|
122
|
+
/**
|
|
123
|
+
* Remove the tenant global scope for this query only.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* // Admin route — load all projects across tenants:
|
|
127
|
+
* const all = await Project.query().withoutTenancy().get();
|
|
128
|
+
*/
|
|
129
|
+
withoutTenancy(): this;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Patch the prototype once at module-load time.
|
|
134
|
+
(MQB.prototype as unknown as Record<string, unknown>)["withoutTenancy"] = function (
|
|
135
|
+
this: ModelQueryBuilder<BaseModel>,
|
|
136
|
+
): ModelQueryBuilder<BaseModel> {
|
|
137
|
+
return this.withoutGlobalScope(SCOPE_NAME);
|
|
138
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy config factory.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* // config/tenancy.ts
|
|
6
|
+
* import { TenancyConfig, SubdomainResolver } from "@zerotal/tenancy";
|
|
7
|
+
*
|
|
8
|
+
* export default TenancyConfig({
|
|
9
|
+
* strategy: "single-database",
|
|
10
|
+
* resolvers: [new SubdomainResolver("myapp.com")],
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* The tenant registry (`tenants` table) is owned by the package — no `findTenant`
|
|
14
|
+
* callback or app tenant model needed. Reach tenants via the `Tenant` facade.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { deepMerge } from "@zerotal/core";
|
|
18
|
+
import type { TenancyConfigShape } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
// `resolvers` has no default — it is required from the caller. deepMerge passes the
|
|
21
|
+
// resolver instances through by reference (it only deep-clones plain objects/arrays).
|
|
22
|
+
const defaults: Partial<TenancyConfigShape> = {
|
|
23
|
+
strategy: "single-database",
|
|
24
|
+
tenantColumn: "tenant_id",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function TenancyConfig(
|
|
28
|
+
config: Partial<TenancyConfigShape> & Pick<TenancyConfigShape, "resolvers">,
|
|
29
|
+
): TenancyConfigShape {
|
|
30
|
+
return deepMerge(defaults as TenancyConfigShape, config);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
34
|
+
declare module "@zerotal/core" {
|
|
35
|
+
interface ConfigRegistry {
|
|
36
|
+
tenancy: TenancyConfigShape;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { HttpError, ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* No tenant could be resolved from the request, or the resolved identifier
|
|
5
|
+
* doesn't match any tenant. Renders as a 404 (content-negotiated by the
|
|
6
|
+
* framework's exception handler).
|
|
7
|
+
*/
|
|
8
|
+
export class TenantNotFoundError extends HttpError {
|
|
9
|
+
constructor(message = "This account doesn’t exist.") {
|
|
10
|
+
super(message, 404, "E_TENANT_NOT_FOUND");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The tenant exists but is not active (`is_active === false`). Renders as a 403.
|
|
16
|
+
*/
|
|
17
|
+
export class TenantInactiveError extends HttpError {
|
|
18
|
+
constructor(message = "This account has been disabled.") {
|
|
19
|
+
super(message, 403, "E_TENANT_INACTIVE");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tenancy was used before it was configured — `TenancyProvider` isn't registered
|
|
25
|
+
* or `TenancyMiddleware` ran before boot. A misconfiguration (500).
|
|
26
|
+
*/
|
|
27
|
+
export class TenancyNotConfiguredError extends HttpError {
|
|
28
|
+
constructor(
|
|
29
|
+
message = "Tenancy is not configured. Register TenancyProvider and apply TenancyMiddleware.",
|
|
30
|
+
) {
|
|
31
|
+
super(message, 500, "E_TENANCY_NOT_CONFIGURED");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The current user tried an admin-only tenant action (`update`/`delete`) without
|
|
37
|
+
* being an admin member of the tenant. Renders as a 403. Use the `force*` variants
|
|
38
|
+
* to bypass the check from trusted server code.
|
|
39
|
+
*/
|
|
40
|
+
export class TenantForbiddenError extends HttpError {
|
|
41
|
+
constructor(message = "You don’t have permission to manage this account.") {
|
|
42
|
+
super(message, 403, "E_TENANT_FORBIDDEN");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A required companion package or configuration for a tenancy feature is missing
|
|
48
|
+
* (e.g. `tenantDisk()` without a configured disk). A misconfiguration (500).
|
|
49
|
+
*/
|
|
50
|
+
export class TenancyConfigError extends ZerotalError {
|
|
51
|
+
constructor(message: string) {
|
|
52
|
+
super(message, "E_TENANCY_CONFIG", 500);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `TenantContext.get()` (or `Tenant.current()`) was called outside an active
|
|
58
|
+
* tenant boundary. A programming error — wrap the code in TenancyMiddleware, or
|
|
59
|
+
* use `TenantContext.getOrNull()` in shared (tenant + non-tenant) code.
|
|
60
|
+
*/
|
|
61
|
+
export class NoActiveTenantError extends ZerotalError {
|
|
62
|
+
constructor(
|
|
63
|
+
message = "No active tenant. This code ran outside a tenant boundary — is TenancyMiddleware applied? (use TenantContext.getOrNull() in shared code).",
|
|
64
|
+
) {
|
|
65
|
+
super(message, "E_NO_ACTIVE_TENANT", 500);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Raised when a caller-supplied storage key would escape the active tenant's directory.
|
|
71
|
+
*
|
|
72
|
+
* `tenantDisk()` prefixes keys with `tenants/<slug>/`, but the underlying LocalDriver confines
|
|
73
|
+
* paths to the *disk root*, not the tenant directory — so a key containing `..` resolved to a
|
|
74
|
+
* sibling tenant's folder and passed the driver's own check. Traversal is rejected at the
|
|
75
|
+
* prefixing layer instead.
|
|
76
|
+
*/
|
|
77
|
+
export class TenantStoragePathError extends ZerotalError {
|
|
78
|
+
constructor(path: string, slug: string) {
|
|
79
|
+
super(
|
|
80
|
+
`Storage path "${path}" is not allowed for tenant "${slug}": it would resolve outside ` +
|
|
81
|
+
`the tenant's directory. Use a relative key with no ".." segments and no leading "/".`,
|
|
82
|
+
"E_TENANT_STORAGE_PATH",
|
|
83
|
+
400,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createFacade } from "@zerotal/core";
|
|
2
|
+
import type { Tenancy } from "../Tenancy.ts";
|
|
3
|
+
import type { Tenant as TenantRecord } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
declare module "@zerotal/core" {
|
|
6
|
+
interface ContainerBindings {
|
|
7
|
+
tenancy: Tenancy;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tenant facade — the primary entry point to tenancy.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { Tenant } from "@zerotal/tenancy";
|
|
16
|
+
*
|
|
17
|
+
* Tenant.current(); // the active tenant (or null)
|
|
18
|
+
* Tenant.check(); // is there an active, enabled tenant?
|
|
19
|
+
* await Tenant.members(); // hydrated User models
|
|
20
|
+
* await Tenant.update({ name }); // admin-gated
|
|
21
|
+
*
|
|
22
|
+
* (The `Tenant` *type* — the tenant record shape — is exported separately and
|
|
23
|
+
* coexists with this value, so `Tenant.current(): Tenant` reads naturally.)
|
|
24
|
+
*/
|
|
25
|
+
export const Tenant = createFacade("tenancy");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The tenant **record** shape (`{ id, slug, name, isActive, … }`). Shares the `Tenant`
|
|
29
|
+
* name with the facade value above — value and type namespaces coexist — so both
|
|
30
|
+
* `Tenant.current()` (value) and `: Tenant` (type) read naturally from one import.
|
|
31
|
+
*/
|
|
32
|
+
export type Tenant = TenantRecord;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @zerotal/tenancy — public API
|
|
2
|
+
|
|
3
|
+
// Facade + service (the primary entry point). The `Tenant` export is both the facade
|
|
4
|
+
// *value* and the tenant-record *type* (they share the name from one module).
|
|
5
|
+
export { Tenant } from "./facades/Tenant.ts";
|
|
6
|
+
export { Tenancy } from "./Tenancy.ts";
|
|
7
|
+
export type { TenantDeletedHook } from "./Tenancy.ts";
|
|
8
|
+
export { TenantModel } from "./TenantModel.ts";
|
|
9
|
+
|
|
10
|
+
// Core
|
|
11
|
+
export { TenantContext } from "./TenantContext.ts";
|
|
12
|
+
// TenancyMiddleware is intentionally NOT exported — TenancyProvider registers it globally.
|
|
13
|
+
// Apps gate tenant-required routes with EnsureTenancyMiddleware instead.
|
|
14
|
+
export { EnsureTenancyMiddleware } from "./EnsureTenancyMiddleware.ts";
|
|
15
|
+
export { TenancyProvider } from "./provider/TenancyProvider.ts";
|
|
16
|
+
export { TenantManager } from "./TenantManager.ts";
|
|
17
|
+
export type { TenantManagerOptions } from "./TenantManager.ts";
|
|
18
|
+
|
|
19
|
+
// Resolvers
|
|
20
|
+
export { SubdomainResolver } from "./resolvers/SubdomainResolver.ts";
|
|
21
|
+
export { HeaderResolver } from "./resolvers/HeaderResolver.ts";
|
|
22
|
+
export { PathResolver } from "./resolvers/PathResolver.ts";
|
|
23
|
+
export { RouteParamResolver } from "./resolvers/RouteParamResolver.ts";
|
|
24
|
+
export { AuthResolver } from "./resolvers/AuthResolver.ts";
|
|
25
|
+
|
|
26
|
+
// Model mixin
|
|
27
|
+
export { Tenantable } from "./Tenantable.ts";
|
|
28
|
+
|
|
29
|
+
// Storage / Cache helpers
|
|
30
|
+
export { tenantDisk, tenantCache } from "./tenantStorage.ts";
|
|
31
|
+
|
|
32
|
+
// Config
|
|
33
|
+
export { TenancyConfig } from "./config.ts";
|
|
34
|
+
|
|
35
|
+
// Errors
|
|
36
|
+
export {
|
|
37
|
+
TenantNotFoundError,
|
|
38
|
+
TenantInactiveError,
|
|
39
|
+
TenantForbiddenError,
|
|
40
|
+
TenancyNotConfiguredError,
|
|
41
|
+
TenancyConfigError,
|
|
42
|
+
TenantStoragePathError,
|
|
43
|
+
NoActiveTenantError,
|
|
44
|
+
} from "./errors.ts";
|
|
45
|
+
|
|
46
|
+
// Types
|
|
47
|
+
// Note: the `Tenant` record type is exported from ./facades/Tenant.ts (alongside the
|
|
48
|
+
// facade value), so it is intentionally absent here.
|
|
49
|
+
export type {
|
|
50
|
+
MultiDbTenant,
|
|
51
|
+
TenantResolver,
|
|
52
|
+
TenantResolverResult,
|
|
53
|
+
TenancyStrategy,
|
|
54
|
+
TenancyConfigShape,
|
|
55
|
+
} from "./types.ts";
|