@venturekit-pro/tenancy 0.0.1 → 0.0.2
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware/handoff-authorize.d.ts +61 -0
- package/dist/middleware/handoff-authorize.d.ts.map +1 -0
- package/dist/middleware/handoff-authorize.js +55 -0
- package/dist/middleware/handoff-authorize.js.map +1 -0
- package/dist/middleware/index.d.ts +6 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +3 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/tenant-roles-claim.d.ts +84 -0
- package/dist/middleware/tenant-roles-claim.d.ts.map +1 -0
- package/dist/middleware/tenant-roles-claim.js +120 -0
- package/dist/middleware/tenant-roles-claim.js.map +1 -0
- package/dist/middleware/tenant-user-scopes-middleware.d.ts +100 -0
- package/dist/middleware/tenant-user-scopes-middleware.d.ts.map +1 -0
- package/dist/middleware/tenant-user-scopes-middleware.js +172 -0
- package/dist/middleware/tenant-user-scopes-middleware.js.map +1 -0
- package/package.json +4 -4
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,YAAY,EACZ,iBAAiB,EAGjB,YAAY,IAAI,cAAc,GAC/B,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,cAAc,kBAAkB,CAAC;AAGjC,OAAO,EACL,aAAa,EACb,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,cAAc,uBAAuB,CAAC;AAGtC,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,YAAY,EACZ,iBAAiB,EAGjB,YAAY,IAAI,cAAc,GAC/B,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,sBAAsB,EACtB,cAAc,EACd,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,cAAc,EACd,aAAa,GACd,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,10 @@ export { createTenantContext, getCurrentTenant, resolveTenant, } from './context
|
|
|
15
15
|
export * from './middleware/index.js';
|
|
16
16
|
// Bare vk_tenants CRUD (column-agnostic create / read / update)
|
|
17
17
|
export { listTenants, getTenantById, getTenantBySlug, getTenantByPrimaryDomain, createTenant, updateTenant, setTenantStatus, isUniqueViolation as isTenantUniqueViolation, } from './crud/index.js';
|
|
18
|
+
// Role → scopes mapping lives in @venturekit/auth (vk_role_scopes +
|
|
19
|
+
// createRoleScopesResolver) — baseline authorization, useful without
|
|
20
|
+
// tenancy. The scopes middleware consumes it through the structural
|
|
21
|
+
// `RoleScopesLookup` type (re-exported via ./middleware above).
|
|
18
22
|
// Lifecycle (suspend / archive / restore / hard-delete + cascade walker)
|
|
19
23
|
export { suspendTenant, archiveTenant, restoreTenant, hardDeleteTenant, planCascade, executeCascade, } from './lifecycle/index.js';
|
|
20
24
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,gEAAgE;AAChE,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AASzB,yEAAyE;AACzE,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,QAAQ;AACR,cAAc,kBAAkB,CAAC;AAEjC,UAAU;AACV,OAAO,EAEL,mBAAmB,EACnB,gBAAgB,EAChB,aAAa,GACd,MAAM,oBAAoB,CAAC;AAE5B,aAAa;AACb,cAAc,uBAAuB,CAAC;AAEtC,gEAAgE;AAChE,OAAO,EACL,WAAW,EACX,aAAa,EACb,eAAe,EACf,wBAAwB,EACxB,YAAY,EACZ,YAAY,EACZ,eAAe,EACf,iBAAiB,IAAI,uBAAuB,GAC7C,MAAM,iBAAiB,CAAC;AASzB,oEAAoE;AACpE,qEAAqE;AACrE,oEAAoE;AACpE,gEAAgE;AAEhE,yEAAyE;AACzE,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,WAAW,EACX,cAAc,GACf,MAAM,sBAAsB,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy-side authorization gate for cross-domain session handoff.
|
|
3
|
+
*
|
|
4
|
+
* The handoff MECHANISM (single-use codes, refresh-token exchange,
|
|
5
|
+
* cookie minting) lives in `@venturekit/auth/server` — it is session
|
|
6
|
+
* machinery and knows nothing about tenants. The QUESTION it delegates
|
|
7
|
+
* — *"may this user get a session on that host?"* — is pure tenancy:
|
|
8
|
+
* resolve the host to a tenant, check the user is an active tenant
|
|
9
|
+
* user there. This factory packages that answer so apps don't
|
|
10
|
+
* hand-roll host normalization and the fail-closed rules:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // app code
|
|
14
|
+
* createSessionHandoffRoutes({
|
|
15
|
+
* store,
|
|
16
|
+
* authorize: createTenantHandoffAuthorize({
|
|
17
|
+
* resolveTenantByHost: (host) => loadByDomain(host),
|
|
18
|
+
* isActiveTenantUser: ({ tenantId, userSub }) =>
|
|
19
|
+
* hasApprovedMembership(tenantId, userSub),
|
|
20
|
+
* }),
|
|
21
|
+
* ...
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Fail-closed by construction: unknown host → false, resolver error →
|
|
26
|
+
* false is the CALLER's job to avoid (let errors propagate = request
|
|
27
|
+
* fails, which is also closed), missing tenant user → false.
|
|
28
|
+
*/
|
|
29
|
+
/** Host → tenant resolution, app-provided (usually the same lookup the
|
|
30
|
+
* tenant middleware uses). Receives a bare lowercase hostname — no
|
|
31
|
+
* port, no `www.` prefix. Return null for unknown hosts. */
|
|
32
|
+
export type HandoffTenantResolver = (host: string) => Promise<{
|
|
33
|
+
id: string;
|
|
34
|
+
} | null>;
|
|
35
|
+
export interface TenantHandoffAuthorizeOptions {
|
|
36
|
+
/** Resolve the (normalized) target host to a tenant. */
|
|
37
|
+
resolveTenantByHost: HandoffTenantResolver;
|
|
38
|
+
/**
|
|
39
|
+
* Is this user an ACTIVE tenant user of the resolved tenant?
|
|
40
|
+
* Apps define "active" (approved, not suspended, not deleted, …) —
|
|
41
|
+
* the same rule their scope middleware uses.
|
|
42
|
+
*/
|
|
43
|
+
isActiveTenantUser: (args: {
|
|
44
|
+
tenantId: string;
|
|
45
|
+
userSub: string;
|
|
46
|
+
}) => Promise<boolean>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a browser-facing host for tenant resolution: lowercase,
|
|
50
|
+
* strip the port and a leading `www.`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeHandoffHost(targetHost: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Build an `authorize` function for
|
|
55
|
+
* `@venturekit/auth/server`'s `createSessionHandoffRoutes`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createTenantHandoffAuthorize(options: TenantHandoffAuthorizeOptions): (args: {
|
|
58
|
+
userSub: string;
|
|
59
|
+
targetHost: string;
|
|
60
|
+
}) => Promise<boolean>;
|
|
61
|
+
//# sourceMappingURL=handoff-authorize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff-authorize.d.ts","sourceRoot":"","sources":["../../src/middleware/handoff-authorize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;6DAE6D;AAC7D,MAAM,MAAM,qBAAqB,GAAG,CAClC,IAAI,EAAE,MAAM,KACT,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAAC;AAEpC,MAAM,WAAW,6BAA6B;IAC5C,wDAAwD;IACxD,mBAAmB,EAAE,qBAAqB,CAAC;IAC3C;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,IAAI,EAAE;QACzB,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM/D;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,CAAC,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAUrE"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy-side authorization gate for cross-domain session handoff.
|
|
3
|
+
*
|
|
4
|
+
* The handoff MECHANISM (single-use codes, refresh-token exchange,
|
|
5
|
+
* cookie minting) lives in `@venturekit/auth/server` — it is session
|
|
6
|
+
* machinery and knows nothing about tenants. The QUESTION it delegates
|
|
7
|
+
* — *"may this user get a session on that host?"* — is pure tenancy:
|
|
8
|
+
* resolve the host to a tenant, check the user is an active tenant
|
|
9
|
+
* user there. This factory packages that answer so apps don't
|
|
10
|
+
* hand-roll host normalization and the fail-closed rules:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // app code
|
|
14
|
+
* createSessionHandoffRoutes({
|
|
15
|
+
* store,
|
|
16
|
+
* authorize: createTenantHandoffAuthorize({
|
|
17
|
+
* resolveTenantByHost: (host) => loadByDomain(host),
|
|
18
|
+
* isActiveTenantUser: ({ tenantId, userSub }) =>
|
|
19
|
+
* hasApprovedMembership(tenantId, userSub),
|
|
20
|
+
* }),
|
|
21
|
+
* ...
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Fail-closed by construction: unknown host → false, resolver error →
|
|
26
|
+
* false is the CALLER's job to avoid (let errors propagate = request
|
|
27
|
+
* fails, which is also closed), missing tenant user → false.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Normalize a browser-facing host for tenant resolution: lowercase,
|
|
31
|
+
* strip the port and a leading `www.`.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeHandoffHost(targetHost) {
|
|
34
|
+
return (targetHost || '')
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.split(':', 1)[0]
|
|
38
|
+
.replace(/^www\./, '');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build an `authorize` function for
|
|
42
|
+
* `@venturekit/auth/server`'s `createSessionHandoffRoutes`.
|
|
43
|
+
*/
|
|
44
|
+
export function createTenantHandoffAuthorize(options) {
|
|
45
|
+
return async ({ userSub, targetHost }) => {
|
|
46
|
+
const host = normalizeHandoffHost(targetHost);
|
|
47
|
+
if (!host || !userSub)
|
|
48
|
+
return false;
|
|
49
|
+
const tenant = await options.resolveTenantByHost(host);
|
|
50
|
+
if (!tenant)
|
|
51
|
+
return false;
|
|
52
|
+
return options.isActiveTenantUser({ tenantId: tenant.id, userSub });
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=handoff-authorize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff-authorize.js","sourceRoot":"","sources":["../../src/middleware/handoff-authorize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAuBH;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,OAAO,CAAC,UAAU,IAAI,EAAE,CAAC;SACtB,IAAI,EAAE;SACN,WAAW,EAAE;SACb,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAE;SACjB,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;AAC3B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,4BAA4B,CAC1C,OAAsC;IAEtC,OAAO,KAAK,EAAE,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE;QACvC,MAAM,IAAI,GAAG,oBAAoB,CAAC,UAAU,CAAC,CAAC;QAC9C,IAAI,CAAC,IAAI,IAAI,CAAC,OAAO;YAAE,OAAO,KAAK,CAAC;QAEpC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACvD,IAAI,CAAC,MAAM;YAAE,OAAO,KAAK,CAAC;QAE1B,OAAO,OAAO,CAAC,kBAAkB,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;IACtE,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -5,4 +5,10 @@ export { createTenantMiddleware, TenantNotFoundError, TenantSuspendedError, Tena
|
|
|
5
5
|
export { createQuotaMiddleware, QuotaExceededError, checkQuotas } from './quota-middleware.js';
|
|
6
6
|
export { createRuntimeTenantMiddleware, hostPrefixDomainResolver, } from './runtime-tenant-middleware.js';
|
|
7
7
|
export type { RuntimeTenantResolver, RuntimeTenantMiddlewareOptions, HostPrefixDomainResolverOptions, } from './runtime-tenant-middleware.js';
|
|
8
|
+
export { createTenantUserScopesMiddleware, getTenantUser, requireTenantUser, } from './tenant-user-scopes-middleware.js';
|
|
9
|
+
export type { TenantUserWithRole, TenantUserResolver, TenantUserScopesMiddlewareOptions, RoleScopesLookup, } from './tenant-user-scopes-middleware.js';
|
|
10
|
+
export { TENANT_ROLES_CLAIM, TENANT_ROLES_MAX_LENGTH, packTenantRoles, unpackTenantRoles, } from './tenant-roles-claim.js';
|
|
11
|
+
export type { TenantRoleEntry } from './tenant-roles-claim.js';
|
|
12
|
+
export { createTenantHandoffAuthorize, normalizeHandoffHost, } from './handoff-authorize.js';
|
|
13
|
+
export type { HandoffTenantResolver, TenantHandoffAuthorizeOptions, } from './handoff-authorize.js';
|
|
8
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AACxC,YAAY,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,+BAA+B,GAChC,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AACxC,YAAY,EACV,qBAAqB,EACrB,8BAA8B,EAC9B,+BAA+B,GAChC,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,gCAAgC,EAChC,aAAa,EACb,iBAAiB,GAClB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EACV,kBAAkB,EAClB,kBAAkB,EAClB,iCAAiC,EACjC,gBAAgB,GACjB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,EACf,iBAAiB,GAClB,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EACL,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,qBAAqB,EACrB,6BAA6B,GAC9B,MAAM,wBAAwB,CAAC"}
|
package/dist/middleware/index.js
CHANGED
|
@@ -4,4 +4,7 @@
|
|
|
4
4
|
export { createTenantMiddleware, TenantNotFoundError, TenantSuspendedError, TenantInactiveError, } from './tenant-middleware.js';
|
|
5
5
|
export { createQuotaMiddleware, QuotaExceededError, checkQuotas } from './quota-middleware.js';
|
|
6
6
|
export { createRuntimeTenantMiddleware, hostPrefixDomainResolver, } from './runtime-tenant-middleware.js';
|
|
7
|
+
export { createTenantUserScopesMiddleware, getTenantUser, requireTenantUser, } from './tenant-user-scopes-middleware.js';
|
|
8
|
+
export { TENANT_ROLES_CLAIM, TENANT_ROLES_MAX_LENGTH, packTenantRoles, unpackTenantRoles, } from './tenant-roles-claim.js';
|
|
9
|
+
export { createTenantHandoffAuthorize, normalizeHandoffHost, } from './handoff-authorize.js';
|
|
7
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/middleware/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,EACL,6BAA6B,EAC7B,wBAAwB,GACzB,MAAM,gCAAgC,CAAC;AAMxC,OAAO,EACL,gCAAgC,EAChC,aAAa,EACb,iBAAiB,GAClB,MAAM,oCAAoC,CAAC;AAO5C,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,eAAe,EACf,iBAAiB,GAClB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed per-tenant roles claim — the token-side half of
|
|
3
|
+
* `createTenantUserScopesMiddleware`'s claims-first fast path.
|
|
4
|
+
*
|
|
5
|
+
* Multi-tenant apps assign users one role PER TENANT (a membership,
|
|
6
|
+
* a staff assignment, a seat, …). A single Cognito custom attribute
|
|
7
|
+
* can carry all of them:
|
|
8
|
+
*
|
|
9
|
+
* custom:tenantRoles = "<tenantId>:<role>|<tenantId>:<role>|…"
|
|
10
|
+
*
|
|
11
|
+
* Contract (both halves matter — read the write-path rules!):
|
|
12
|
+
*
|
|
13
|
+
* READ — the middleware unpacks the claim from the verified ID
|
|
14
|
+
* token and grants `scopesByRole[role]` for the current tenant
|
|
15
|
+
* with NO store round-trip. A missing tenant falls back to the
|
|
16
|
+
* app's `tenantUser` resolver, so a stale-but-too-narrow claim
|
|
17
|
+
* can never lock a user out — it only costs the query the claim
|
|
18
|
+
* would have saved.
|
|
19
|
+
*
|
|
20
|
+
* WRITE — the app re-derives the FULL pack from its tenant-user
|
|
21
|
+
* store and writes it (e.g. `adminUpdateUserAttributes`) on every
|
|
22
|
+
* tenant-user mutation: sign-in upsert, approve, role change,
|
|
23
|
+
* suspend / unsuspend, leave. Two rules:
|
|
24
|
+
* 1. Pack ACTIVE tenant users only (approved, not suspended,
|
|
25
|
+
* not deleted). "Active" is decided at write time — the
|
|
26
|
+
* claims path deliberately has no `isActive` hook.
|
|
27
|
+
* 2. Always recompute from the store; never string-edit the
|
|
28
|
+
* previous value. Recomputing is self-healing.
|
|
29
|
+
*
|
|
30
|
+
* Staleness — the honest trade-off vs. the per-request store lookup:
|
|
31
|
+
* attributes are baked into tokens at issue time, so a change lands
|
|
32
|
+
* on the caller's NEXT token refresh (≤ the ID-token TTL, 1h by
|
|
33
|
+
* default). GRANTS take effect immediately anyway thanks to the
|
|
34
|
+
* claim-miss fallback; REVOCATIONS (suspend, demote) keep honoring
|
|
35
|
+
* the old role until refresh. Apps for which that window is
|
|
36
|
+
* unacceptable should not enable the claims path.
|
|
37
|
+
*
|
|
38
|
+
* Size — Cognito caps custom attribute values at 2048 chars
|
|
39
|
+
* ({@link TENANT_ROLES_MAX_LENGTH}), ≈ 40 UUID-keyed entries.
|
|
40
|
+
* {@link packTenantRoles} keeps entries in the given order and drops
|
|
41
|
+
* whatever doesn't fit; dropped tenants degrade to the fallback query.
|
|
42
|
+
* Order entries by likelihood of use (e.g. most recently active).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default claim name. The pool must declare a `tenantRoles` custom
|
|
46
|
+
* attribute (`customAttributes: ['tenantRoles', …]` on the auth
|
|
47
|
+
* intent in vk.config.ts) — Cognito prefixes it to
|
|
48
|
+
* `custom:tenantRoles` in JWTs.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately NOT `custom:role` (the conventional login-tenant role
|
|
51
|
+
* claim, per-user and single-valued) and NOT `custom:roles`
|
|
52
|
+
* (`@venturekit/auth`'s session helpers parse that as a flat
|
|
53
|
+
* comma-separated role list).
|
|
54
|
+
*/
|
|
55
|
+
export declare const TENANT_ROLES_CLAIM = "custom:tenantRoles";
|
|
56
|
+
/** Cognito's hard cap on a custom attribute value. */
|
|
57
|
+
export declare const TENANT_ROLES_MAX_LENGTH = 2048;
|
|
58
|
+
/** One packed entry: which role the user holds in which tenant. */
|
|
59
|
+
export interface TenantRoleEntry {
|
|
60
|
+
tenantId: string;
|
|
61
|
+
role: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Pack tenant-role entries into the claim value, first-entry-wins on
|
|
65
|
+
* duplicate tenants, dropping trailing entries that would push the
|
|
66
|
+
* value past {@link TENANT_ROLES_MAX_LENGTH} (or a custom `maxLength`
|
|
67
|
+
* in tests). Returns `''` for no entries — write the empty string
|
|
68
|
+
* anyway; it must OVERWRITE the previous pack when the user's last
|
|
69
|
+
* tenant goes away.
|
|
70
|
+
*
|
|
71
|
+
* Throws on entries the codec cannot round-trip (empty fields or
|
|
72
|
+
* separator characters inside ids/roles) — those indicate a bug in
|
|
73
|
+
* the caller's derivation query, not user input.
|
|
74
|
+
*/
|
|
75
|
+
export declare function packTenantRoles(entries: readonly TenantRoleEntry[], maxLength?: number): string;
|
|
76
|
+
/**
|
|
77
|
+
* Decode a packed claim into a `tenantId → role` map. Fail-closed on
|
|
78
|
+
* anything unexpected: a non-string claim, malformed segments, or
|
|
79
|
+
* duplicate tenants (first wins) silently contribute nothing — a
|
|
80
|
+
* garbled claim must never grant scopes, and the middleware's
|
|
81
|
+
* fallback query covers the affected tenant.
|
|
82
|
+
*/
|
|
83
|
+
export declare function unpackTenantRoles(raw: unknown): ReadonlyMap<string, string>;
|
|
84
|
+
//# sourceMappingURL=tenant-roles-claim.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-roles-claim.d.ts","sourceRoot":"","sources":["../../src/middleware/tenant-roles-claim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAKD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,SAAS,eAAe,EAAE,EACnC,SAAS,GAAE,MAAgC,GAC1C,MAAM,CAyBR;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAY3E"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packed per-tenant roles claim — the token-side half of
|
|
3
|
+
* `createTenantUserScopesMiddleware`'s claims-first fast path.
|
|
4
|
+
*
|
|
5
|
+
* Multi-tenant apps assign users one role PER TENANT (a membership,
|
|
6
|
+
* a staff assignment, a seat, …). A single Cognito custom attribute
|
|
7
|
+
* can carry all of them:
|
|
8
|
+
*
|
|
9
|
+
* custom:tenantRoles = "<tenantId>:<role>|<tenantId>:<role>|…"
|
|
10
|
+
*
|
|
11
|
+
* Contract (both halves matter — read the write-path rules!):
|
|
12
|
+
*
|
|
13
|
+
* READ — the middleware unpacks the claim from the verified ID
|
|
14
|
+
* token and grants `scopesByRole[role]` for the current tenant
|
|
15
|
+
* with NO store round-trip. A missing tenant falls back to the
|
|
16
|
+
* app's `tenantUser` resolver, so a stale-but-too-narrow claim
|
|
17
|
+
* can never lock a user out — it only costs the query the claim
|
|
18
|
+
* would have saved.
|
|
19
|
+
*
|
|
20
|
+
* WRITE — the app re-derives the FULL pack from its tenant-user
|
|
21
|
+
* store and writes it (e.g. `adminUpdateUserAttributes`) on every
|
|
22
|
+
* tenant-user mutation: sign-in upsert, approve, role change,
|
|
23
|
+
* suspend / unsuspend, leave. Two rules:
|
|
24
|
+
* 1. Pack ACTIVE tenant users only (approved, not suspended,
|
|
25
|
+
* not deleted). "Active" is decided at write time — the
|
|
26
|
+
* claims path deliberately has no `isActive` hook.
|
|
27
|
+
* 2. Always recompute from the store; never string-edit the
|
|
28
|
+
* previous value. Recomputing is self-healing.
|
|
29
|
+
*
|
|
30
|
+
* Staleness — the honest trade-off vs. the per-request store lookup:
|
|
31
|
+
* attributes are baked into tokens at issue time, so a change lands
|
|
32
|
+
* on the caller's NEXT token refresh (≤ the ID-token TTL, 1h by
|
|
33
|
+
* default). GRANTS take effect immediately anyway thanks to the
|
|
34
|
+
* claim-miss fallback; REVOCATIONS (suspend, demote) keep honoring
|
|
35
|
+
* the old role until refresh. Apps for which that window is
|
|
36
|
+
* unacceptable should not enable the claims path.
|
|
37
|
+
*
|
|
38
|
+
* Size — Cognito caps custom attribute values at 2048 chars
|
|
39
|
+
* ({@link TENANT_ROLES_MAX_LENGTH}), ≈ 40 UUID-keyed entries.
|
|
40
|
+
* {@link packTenantRoles} keeps entries in the given order and drops
|
|
41
|
+
* whatever doesn't fit; dropped tenants degrade to the fallback query.
|
|
42
|
+
* Order entries by likelihood of use (e.g. most recently active).
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Default claim name. The pool must declare a `tenantRoles` custom
|
|
46
|
+
* attribute (`customAttributes: ['tenantRoles', …]` on the auth
|
|
47
|
+
* intent in vk.config.ts) — Cognito prefixes it to
|
|
48
|
+
* `custom:tenantRoles` in JWTs.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately NOT `custom:role` (the conventional login-tenant role
|
|
51
|
+
* claim, per-user and single-valued) and NOT `custom:roles`
|
|
52
|
+
* (`@venturekit/auth`'s session helpers parse that as a flat
|
|
53
|
+
* comma-separated role list).
|
|
54
|
+
*/
|
|
55
|
+
export const TENANT_ROLES_CLAIM = 'custom:tenantRoles';
|
|
56
|
+
/** Cognito's hard cap on a custom attribute value. */
|
|
57
|
+
export const TENANT_ROLES_MAX_LENGTH = 2048;
|
|
58
|
+
const ENTRY_SEPARATOR = '|';
|
|
59
|
+
const PAIR_SEPARATOR = ':';
|
|
60
|
+
/**
|
|
61
|
+
* Pack tenant-role entries into the claim value, first-entry-wins on
|
|
62
|
+
* duplicate tenants, dropping trailing entries that would push the
|
|
63
|
+
* value past {@link TENANT_ROLES_MAX_LENGTH} (or a custom `maxLength`
|
|
64
|
+
* in tests). Returns `''` for no entries — write the empty string
|
|
65
|
+
* anyway; it must OVERWRITE the previous pack when the user's last
|
|
66
|
+
* tenant goes away.
|
|
67
|
+
*
|
|
68
|
+
* Throws on entries the codec cannot round-trip (empty fields or
|
|
69
|
+
* separator characters inside ids/roles) — those indicate a bug in
|
|
70
|
+
* the caller's derivation query, not user input.
|
|
71
|
+
*/
|
|
72
|
+
export function packTenantRoles(entries, maxLength = TENANT_ROLES_MAX_LENGTH) {
|
|
73
|
+
let packed = '';
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
for (const { tenantId, role } of entries) {
|
|
76
|
+
if (!tenantId ||
|
|
77
|
+
!role ||
|
|
78
|
+
tenantId.includes(ENTRY_SEPARATOR) ||
|
|
79
|
+
tenantId.includes(PAIR_SEPARATOR) ||
|
|
80
|
+
role.includes(ENTRY_SEPARATOR) ||
|
|
81
|
+
role.includes(PAIR_SEPARATOR)) {
|
|
82
|
+
throw new Error(`packTenantRoles: entry {tenantId: '${tenantId}', role: '${role}'} ` +
|
|
83
|
+
`is empty or contains a reserved separator ('${ENTRY_SEPARATOR}' / '${PAIR_SEPARATOR}')`);
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(tenantId))
|
|
86
|
+
continue;
|
|
87
|
+
const segment = `${tenantId}${PAIR_SEPARATOR}${role}`;
|
|
88
|
+
const next = packed ? `${packed}${ENTRY_SEPARATOR}${segment}` : segment;
|
|
89
|
+
if (next.length > maxLength)
|
|
90
|
+
break;
|
|
91
|
+
packed = next;
|
|
92
|
+
seen.add(tenantId);
|
|
93
|
+
}
|
|
94
|
+
return packed;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Decode a packed claim into a `tenantId → role` map. Fail-closed on
|
|
98
|
+
* anything unexpected: a non-string claim, malformed segments, or
|
|
99
|
+
* duplicate tenants (first wins) silently contribute nothing — a
|
|
100
|
+
* garbled claim must never grant scopes, and the middleware's
|
|
101
|
+
* fallback query covers the affected tenant.
|
|
102
|
+
*/
|
|
103
|
+
export function unpackTenantRoles(raw) {
|
|
104
|
+
const out = new Map();
|
|
105
|
+
if (typeof raw !== 'string' || raw === '')
|
|
106
|
+
return out;
|
|
107
|
+
for (const segment of raw.split(ENTRY_SEPARATOR)) {
|
|
108
|
+
const sep = segment.indexOf(PAIR_SEPARATOR);
|
|
109
|
+
if (sep <= 0 || sep === segment.length - 1)
|
|
110
|
+
continue;
|
|
111
|
+
const tenantId = segment.slice(0, sep);
|
|
112
|
+
const role = segment.slice(sep + 1);
|
|
113
|
+
if (role.includes(PAIR_SEPARATOR))
|
|
114
|
+
continue;
|
|
115
|
+
if (!out.has(tenantId))
|
|
116
|
+
out.set(tenantId, role);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=tenant-roles-claim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-roles-claim.js","sourceRoot":"","sources":["../../src/middleware/tenant-roles-claim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD,sDAAsD;AACtD,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAQ5C,MAAM,eAAe,GAAG,GAAG,CAAC;AAC5B,MAAM,cAAc,GAAG,GAAG,CAAC;AAE3B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,OAAmC,EACnC,YAAoB,uBAAuB;IAE3C,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,KAAK,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,CAAC;QACzC,IACE,CAAC,QAAQ;YACT,CAAC,IAAI;YACL,QAAQ,CAAC,QAAQ,CAAC,eAAe,CAAC;YAClC,QAAQ,CAAC,QAAQ,CAAC,cAAc,CAAC;YACjC,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,KAAK,CACb,sCAAsC,QAAQ,aAAa,IAAI,KAAK;gBAClE,+CAA+C,eAAe,QAAQ,cAAc,IAAI,CAC3F,CAAC;QACJ,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,SAAS;QACjC,MAAM,OAAO,GAAG,GAAG,QAAQ,GAAG,cAAc,GAAG,IAAI,EAAE,CAAC;QACtD,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,eAAe,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;QACxE,IAAI,IAAI,CAAC,MAAM,GAAG,SAAS;YAAE,MAAM;QACnC,MAAM,GAAG,IAAI,CAAC;QACd,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC5C,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC;IACtD,KAAK,MAAM,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,eAAe,CAAC,EAAE,CAAC;QACjD,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;QAC5C,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG,KAAK,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS;QACrD,MAAM,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACvC,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QACpC,IAAI,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;YAAE,SAAS;QAC5C,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Middleware, RequestContext } from '@venturekit/runtime';
|
|
2
|
+
/**
|
|
3
|
+
* Dynamic role → scopes lookup. Structurally identical to
|
|
4
|
+
* `@venturekit/auth`'s `RoleScopesLookup` (the `vk_role_scopes`-backed
|
|
5
|
+
* resolver) — declared locally so this package carries no dependency
|
|
6
|
+
* on the auth package; any `(role) => scopes` function fits.
|
|
7
|
+
*/
|
|
8
|
+
export type RoleScopesLookup = (role: string) => Promise<readonly string[]> | readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Minimal structural requirement on the app's tenant-user row: the
|
|
11
|
+
* per-tenant role that keys into `scopesByRole`. Apps extend it with
|
|
12
|
+
* their own columns (id, status, timestamps, …) and get them back,
|
|
13
|
+
* typed, from {@link getTenantUser}.
|
|
14
|
+
*/
|
|
15
|
+
export interface TenantUserWithRole {
|
|
16
|
+
role: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Loads the caller's tenant-user row for the CURRENT tenant. Called
|
|
20
|
+
* only when both `ctx.user` and `ctx.tenant` are set. Return `null`
|
|
21
|
+
* for "no row" — never throw for the not-found case.
|
|
22
|
+
*/
|
|
23
|
+
export interface TenantUserResolver<M extends TenantUserWithRole> {
|
|
24
|
+
(ctx: RequestContext): Promise<M | null> | M | null;
|
|
25
|
+
}
|
|
26
|
+
export interface TenantUserScopesMiddlewareOptions<M extends TenantUserWithRole> {
|
|
27
|
+
/**
|
|
28
|
+
* Tenant-user lookup for `(ctx.user, ctx.tenant)` — the store
|
|
29
|
+
* fallback for claim misses, and the lazy row loader behind
|
|
30
|
+
* {@link getTenantUser} / {@link requireTenantUser}.
|
|
31
|
+
*/
|
|
32
|
+
tenantUser: TenantUserResolver<M>;
|
|
33
|
+
/**
|
|
34
|
+
* JWT claim carrying the packed per-tenant roles (see
|
|
35
|
+
* ./tenant-roles-claim.ts for the format and the write-path
|
|
36
|
+
* contract). Defaults to {@link TENANT_ROLES_CLAIM}
|
|
37
|
+
* (`custom:tenantRoles`) — harmless for apps that never write the
|
|
38
|
+
* attribute, since an absent claim always falls back to
|
|
39
|
+
* {@link TenantUserScopesMiddlewareOptions.tenantUser}. Pass
|
|
40
|
+
* `false` to force the store lookup on every request (instant
|
|
41
|
+
* revocation at one query per privileged request).
|
|
42
|
+
*/
|
|
43
|
+
rolesClaim?: string | false;
|
|
44
|
+
/**
|
|
45
|
+
* The role → scopes matrix of the app — either a static table or a
|
|
46
|
+
* (possibly async) lookup function. Roles that resolve to nothing
|
|
47
|
+
* grant nothing (fail-closed). Tiered role models should build
|
|
48
|
+
* cumulative sets so higher roles are supersets of lower ones.
|
|
49
|
+
*
|
|
50
|
+
* For a DB-backed matrix (mutable at runtime, manageable from an
|
|
51
|
+
* admin UI) pass `@venturekit/auth`'s
|
|
52
|
+
* `createRoleScopesResolver(...).lookup` — the mapping lives in
|
|
53
|
+
* `vk_role_scopes` (structure shipped by the auth package's
|
|
54
|
+
* migrations, rows seeded by the app).
|
|
55
|
+
*/
|
|
56
|
+
scopesByRole: Record<string, readonly string[]> | RoleScopesLookup;
|
|
57
|
+
/**
|
|
58
|
+
* Gate evaluated before any role scope is granted — the hook for
|
|
59
|
+
* app-specific "row exists but is not in good standing" rules
|
|
60
|
+
* (pending verification, active suspension, unpaid seat, …).
|
|
61
|
+
* The tenant-user row is still cached for {@link getTenantUser} so
|
|
62
|
+
* handlers can inspect WHY the caller is inactive.
|
|
63
|
+
*
|
|
64
|
+
* Default: every resolved row is active.
|
|
65
|
+
*/
|
|
66
|
+
isActive?: (tenantUser: M) => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Tenant-INDEPENDENT extra scopes for the authenticated user —
|
|
69
|
+
* evaluated even when no tenant resolved. Use for cross-tenant
|
|
70
|
+
* operator allowlists (platform admins) or per-user scope
|
|
71
|
+
* overrides. Keep it fail-closed: return `[]` on any doubt.
|
|
72
|
+
*/
|
|
73
|
+
userScopes?: (ctx: RequestContext) => Promise<readonly string[]> | readonly string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build the middleware. See the module header for placement and
|
|
77
|
+
* semantics.
|
|
78
|
+
*/
|
|
79
|
+
export declare function createTenantUserScopesMiddleware<M extends TenantUserWithRole>(options: TenantUserScopesMiddlewareOptions<M>): Middleware<RequestContext>;
|
|
80
|
+
/**
|
|
81
|
+
* The caller's tenant-user row in the current tenant, or `null` when
|
|
82
|
+
* there is none (or the middleware didn't run for this request).
|
|
83
|
+
*
|
|
84
|
+
* Lazy: the claims fast path grants scopes without loading the row,
|
|
85
|
+
* so the first call here triggers the middleware's `tenantUser`
|
|
86
|
+
* resolver; the result — including `null` — is cached on the request
|
|
87
|
+
* context and shared with concurrent callers.
|
|
88
|
+
*/
|
|
89
|
+
export declare function getTenantUser<M extends TenantUserWithRole>(ctx: RequestContext): Promise<M | null>;
|
|
90
|
+
/**
|
|
91
|
+
* Like {@link getTenantUser} but throws `ForbiddenError` (403) when
|
|
92
|
+
* no tenant-user row resolves. For handlers whose scope gate already
|
|
93
|
+
* implies a row exists (any role-granted scope does), a miss here
|
|
94
|
+
* means either a wiring bug (middleware absent from the route's
|
|
95
|
+
* `middleware: [...]` array) or a stale packed claim for an
|
|
96
|
+
* assignment that was just revoked — in both cases 403 is the right
|
|
97
|
+
* answer.
|
|
98
|
+
*/
|
|
99
|
+
export declare function requireTenantUser<M extends TenantUserWithRole>(ctx: RequestContext): Promise<M>;
|
|
100
|
+
//# sourceMappingURL=tenant-user-scopes-middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-user-scopes-middleware.d.ts","sourceRoot":"","sources":["../../src/middleware/tenant-user-scopes-middleware.ts"],"names":[],"mappings":"AA+EA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGtE;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAC7B,IAAI,EAAE,MAAM,KACT,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC;AAEpD;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,kBAAkB;IAC9D,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,iCAAiC,CAChD,CAAC,SAAS,kBAAkB;IAE5B;;;;OAIG;IACH,UAAU,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC;IAElC;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAE5B;;;;;;;;;;;OAWG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,GAAG,gBAAgB,CAAC;IAEnE;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,CAAC,KAAK,OAAO,CAAC;IAEtC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CACX,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,GAAG,SAAS,MAAM,EAAE,CAAC;CACrD;AAkBD;;;GAGG;AACH,wBAAgB,gCAAgC,CAAC,CAAC,SAAS,kBAAkB,EAC3E,OAAO,EAAE,iCAAiC,CAAC,CAAC,CAAC,GAC5C,UAAU,CAAC,cAAc,CAAC,CA2D5B;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,CAAC,SAAS,kBAAkB,EAC9D,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAKnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,SAAS,kBAAkB,EAClE,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,CAAC,CAAC,CAMZ"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-user scopes middleware — per-tenant role → scope grants.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant apps assign users a role PER TENANT (the `TenantUser`
|
|
5
|
+
* shape: one row per user × tenant — a community membership, a staff
|
|
6
|
+
* assignment, a seat, …). Grants come from two sources, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. CLAIMS FAST PATH — the packed `custom:tenantRoles` attribute
|
|
9
|
+
* (see ./tenant-roles-claim.ts) carries the caller's role in
|
|
10
|
+
* EVERY tenant, written by the app on each tenant-user mutation.
|
|
11
|
+
* When the current tenant is in the pack, its role maps through
|
|
12
|
+
* {@link TenantUserScopesMiddlewareOptions.scopesByRole} onto
|
|
13
|
+
* `ctx.user.scopes` with zero store round-trips.
|
|
14
|
+
* 2. STORE FALLBACK — when the tenant is NOT in the pack (fresh
|
|
15
|
+
* approval the token hasn't caught up with, first sign-in,
|
|
16
|
+
* overflowed pack, app never writes the claim), the app-supplied
|
|
17
|
+
* {@link TenantUserScopesMiddlewareOptions.tenantUser} resolver
|
|
18
|
+
* loads the tenant-user row and
|
|
19
|
+
* {@link TenantUserScopesMiddlewareOptions.isActive} gates the
|
|
20
|
+
* grant — exactly the pre-claims behavior.
|
|
21
|
+
*
|
|
22
|
+
* The fallback means a too-NARROW claim can never lock a user out.
|
|
23
|
+
* The reverse staleness is the accepted trade-off: a REVOKED or
|
|
24
|
+
* demoted tenant user keeps their packed role until their next token
|
|
25
|
+
* refresh (≤ the ID-token TTL). Apps that cannot tolerate that
|
|
26
|
+
* window should disable the fast path with `rolesClaim: false` and
|
|
27
|
+
* eat one store query per privileged request.
|
|
28
|
+
*
|
|
29
|
+
* Placement: AFTER auth and AFTER tenant resolution
|
|
30
|
+
* (`createRuntimeTenantMiddleware`) in the route's middleware array —
|
|
31
|
+
* it needs `ctx.user` and `ctx.tenant`. The runtime's default auth
|
|
32
|
+
* middleware always runs before route middleware, so auth ordering is
|
|
33
|
+
* automatic.
|
|
34
|
+
*
|
|
35
|
+
* Fail-closed by design: no tenant-user row, an unknown role, a
|
|
36
|
+
* garbled claim, or a row rejected by `isActive` simply gains no
|
|
37
|
+
* scopes — the middleware never throws — and the runtime responds 403
|
|
38
|
+
* to any privileged route.
|
|
39
|
+
*
|
|
40
|
+
* The tenant-user ROW is resolved lazily: the claims path grants
|
|
41
|
+
* scopes without touching the store, so handlers that need the row
|
|
42
|
+
* (ids, timestamps, …) pull it through the async
|
|
43
|
+
* {@link getTenantUser} / {@link requireTenantUser} — one query on
|
|
44
|
+
* first access, cached on the request context afterwards. Routes
|
|
45
|
+
* that only scope-gate never pay it.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* // app/lib/authz.ts
|
|
50
|
+
* const SCOPES_BY_ROLE = {
|
|
51
|
+
* member: ['member.verified'],
|
|
52
|
+
* moderator: ['member.verified', 'moderation.reports.read'],
|
|
53
|
+
* admin: ['member.verified', 'moderation.reports.read', 'admin.members.read'],
|
|
54
|
+
* };
|
|
55
|
+
*
|
|
56
|
+
* export const tenantUserScopes = () =>
|
|
57
|
+
* createTenantUserScopesMiddleware({
|
|
58
|
+
* tenantUser: (ctx) => loadMembership(ctx.tenant!.id, ctx.user!.id),
|
|
59
|
+
* scopesByRole: SCOPES_BY_ROLE,
|
|
60
|
+
* isActive: (m) => m.status === 'approved' && !m.suspendedUntil,
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // app/routes/admin/members/get.ts
|
|
64
|
+
* export const main = handler(async (_b, ctx) => {
|
|
65
|
+
* const me = await requireTenantUser(ctx); // lazy row, cached
|
|
66
|
+
* return listMembers(ctx.tenant!.id, me.id);
|
|
67
|
+
* }, {
|
|
68
|
+
* scopes: ['admin.members.read'],
|
|
69
|
+
* middleware: [tenancy, tenantUserScopes()],
|
|
70
|
+
* });
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* Unlike the rest of this package's middleware, this module imports a
|
|
74
|
+
* VALUE (`ForbiddenError`) from `@venturekit/runtime`, not just types.
|
|
75
|
+
* That is deliberate: the middleware only makes sense inside
|
|
76
|
+
* `@venturekit/runtime.handler()` stacks, so the peer is always
|
|
77
|
+
* present wherever this module is loaded.
|
|
78
|
+
*/
|
|
79
|
+
import { ForbiddenError } from '@venturekit/runtime';
|
|
80
|
+
import { TENANT_ROLES_CLAIM, unpackTenantRoles } from './tenant-roles-claim.js';
|
|
81
|
+
const tenantUserStateByCtx = new WeakMap();
|
|
82
|
+
/**
|
|
83
|
+
* Build the middleware. See the module header for placement and
|
|
84
|
+
* semantics.
|
|
85
|
+
*/
|
|
86
|
+
export function createTenantUserScopesMiddleware(options) {
|
|
87
|
+
const { tenantUser: resolveTenantUser, scopesByRole, isActive, userScopes } = options;
|
|
88
|
+
const rolesClaim = options.rolesClaim ?? TENANT_ROLES_CLAIM;
|
|
89
|
+
return {
|
|
90
|
+
name: 'tenant-user-scopes',
|
|
91
|
+
fn: async (ctx, next) => {
|
|
92
|
+
// Unauthenticated request: nothing to grant. Public routes pass
|
|
93
|
+
// through; scoped routes 401 at the runtime gate as usual.
|
|
94
|
+
if (!ctx.user)
|
|
95
|
+
return next();
|
|
96
|
+
const grant = (scopes) => {
|
|
97
|
+
for (const s of scopes) {
|
|
98
|
+
if (!ctx.user.scopes.includes(s))
|
|
99
|
+
ctx.user.scopes.push(s);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (userScopes) {
|
|
103
|
+
grant(await userScopes(ctx));
|
|
104
|
+
}
|
|
105
|
+
// Static table or dynamic lookup — normalized to one async shape.
|
|
106
|
+
const scopesForRole = async (role) => typeof scopesByRole === 'function'
|
|
107
|
+
? (await scopesByRole(role)) ?? []
|
|
108
|
+
: scopesByRole[role] ?? [];
|
|
109
|
+
if (ctx.tenant) {
|
|
110
|
+
// Handlers resolve the row on demand through getTenantUser().
|
|
111
|
+
const state = { load: () => Promise.resolve(resolveTenantUser(ctx)) };
|
|
112
|
+
tenantUserStateByCtx.set(ctx, state);
|
|
113
|
+
// Claims fast path: role for the current tenant straight from
|
|
114
|
+
// the verified token — no store round-trip. Packed roles are
|
|
115
|
+
// active-by-contract (the write path packs active tenant
|
|
116
|
+
// users only), so isActive is not consulted here.
|
|
117
|
+
const packedRole = rolesClaim === false
|
|
118
|
+
? undefined
|
|
119
|
+
: unpackTenantRoles(ctx.user.claims?.[rolesClaim]).get(ctx.tenant.id);
|
|
120
|
+
if (packedRole !== undefined) {
|
|
121
|
+
grant(await scopesForRole(packedRole));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Store fallback — also covers "no row" (query returns
|
|
125
|
+
// null and nothing is granted). Seed the lazy state so
|
|
126
|
+
// handlers reading the row back don't query twice.
|
|
127
|
+
const row = state.load();
|
|
128
|
+
state.row = row;
|
|
129
|
+
const tenantUser = (await row);
|
|
130
|
+
if (tenantUser && (!isActive || isActive(tenantUser))) {
|
|
131
|
+
grant(await scopesForRole(tenantUser.role));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return next();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The caller's tenant-user row in the current tenant, or `null` when
|
|
141
|
+
* there is none (or the middleware didn't run for this request).
|
|
142
|
+
*
|
|
143
|
+
* Lazy: the claims fast path grants scopes without loading the row,
|
|
144
|
+
* so the first call here triggers the middleware's `tenantUser`
|
|
145
|
+
* resolver; the result — including `null` — is cached on the request
|
|
146
|
+
* context and shared with concurrent callers.
|
|
147
|
+
*/
|
|
148
|
+
export async function getTenantUser(ctx) {
|
|
149
|
+
const state = tenantUserStateByCtx.get(ctx);
|
|
150
|
+
if (!state)
|
|
151
|
+
return null;
|
|
152
|
+
if (!state.row)
|
|
153
|
+
state.row = state.load();
|
|
154
|
+
return (await state.row) ?? null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Like {@link getTenantUser} but throws `ForbiddenError` (403) when
|
|
158
|
+
* no tenant-user row resolves. For handlers whose scope gate already
|
|
159
|
+
* implies a row exists (any role-granted scope does), a miss here
|
|
160
|
+
* means either a wiring bug (middleware absent from the route's
|
|
161
|
+
* `middleware: [...]` array) or a stale packed claim for an
|
|
162
|
+
* assignment that was just revoked — in both cases 403 is the right
|
|
163
|
+
* answer.
|
|
164
|
+
*/
|
|
165
|
+
export async function requireTenantUser(ctx) {
|
|
166
|
+
const tenantUser = await getTenantUser(ctx);
|
|
167
|
+
if (!tenantUser) {
|
|
168
|
+
throw new ForbiddenError('No tenant-user record in this tenant');
|
|
169
|
+
}
|
|
170
|
+
return tenantUser;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=tenant-user-scopes-middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenant-user-scopes-middleware.js","sourceRoot":"","sources":["../../src/middleware/tenant-user-scopes-middleware.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venturekit-pro/tenancy",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Multi-tenant utilities for VentureKit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@venturekit/core": "0.0.
|
|
33
|
+
"@venturekit/core": "0.0.2"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@venturekit/runtime": "0.0.
|
|
36
|
+
"@venturekit/runtime": "0.0.2"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
39
39
|
"@venturekit/runtime": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "^25.6.0",
|
|
45
|
-
"@venturekit/runtime": "0.0.
|
|
45
|
+
"@venturekit/runtime": "0.0.2",
|
|
46
46
|
"typescript": "^5.3.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|