@cedarjs/tenancy 7.0.0-canary.3092
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/README.md +98 -0
- package/dist/auth.d.ts +28 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +47 -0
- package/dist/context.d.ts +121 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +84 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/prismaExtension.d.ts +89 -0
- package/dist/prismaExtension.d.ts.map +1 -0
- package/dist/prismaExtension.js +583 -0
- package/dist/web/OrgContext.d.ts +8 -0
- package/dist/web/OrgContext.d.ts.map +1 -0
- package/dist/web/OrgContext.js +7 -0
- package/dist/web/OrgScope.d.ts +29 -0
- package/dist/web/OrgScope.d.ts.map +1 -0
- package/dist/web/OrgScope.js +107 -0
- package/dist/web/getMemberships.d.ts +8 -0
- package/dist/web/getMemberships.d.ts.map +1 -0
- package/dist/web/getMemberships.js +25 -0
- package/dist/web/hasOrgRole.d.ts +12 -0
- package/dist/web/hasOrgRole.d.ts.map +1 -0
- package/dist/web/hasOrgRole.js +16 -0
- package/dist/web/index.d.ts +9 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +14 -0
- package/dist/web/orgClients.d.ts +27 -0
- package/dist/web/orgClients.d.ts.map +1 -0
- package/dist/web/orgClients.js +37 -0
- package/dist/web/types.d.ts +36 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +0 -0
- package/dist/web/useCurrentOrg.d.ts +10 -0
- package/dist/web/useCurrentOrg.d.ts.map +1 -0
- package/dist/web/useCurrentOrg.js +12 -0
- package/package.json +105 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @cedarjs/tenancy
|
|
2
|
+
|
|
3
|
+
Opt-in, row-level multi-tenancy for Cedar apps: organizations, memberships,
|
|
4
|
+
per-organization roles and a Prisma client extension that scopes every query on
|
|
5
|
+
a tenant-owned model to the current organization.
|
|
6
|
+
|
|
7
|
+
Set it up with `yarn cedar setup tenancy`. See the
|
|
8
|
+
[multi-tenancy how-to](https://cedarjs.com/docs/how-to/multi-tenancy) and the
|
|
9
|
+
[reference docs](https://cedarjs.com/docs/tenancy).
|
|
10
|
+
|
|
11
|
+
## The Prisma extension
|
|
12
|
+
|
|
13
|
+
`createTenancyExtension()` wraps a Prisma Client so every operation on a
|
|
14
|
+
tenant-owned model gets an `organizationId` equality check added to it — `where`
|
|
15
|
+
for reads, `data` for writes, and any nested relation reached through
|
|
16
|
+
`include`/`select`. A tenant-owned model queried with no organization in scope
|
|
17
|
+
throws `TenantScopeError` instead of silently returning unscoped data.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// api/src/lib/db.ts
|
|
21
|
+
import { createTenancyExtension } from '@cedarjs/tenancy'
|
|
22
|
+
|
|
23
|
+
import { PrismaClient } from './generated/prisma/client'
|
|
24
|
+
|
|
25
|
+
const prismaClient = new PrismaClient()
|
|
26
|
+
|
|
27
|
+
export const db = prismaClient.$extends(
|
|
28
|
+
createTenancyExtension<typeof prismaClient>({
|
|
29
|
+
// Every model is tenant-owned except these three, which `setup tenancy`
|
|
30
|
+
// adds to the schema. New models are scoped automatically.
|
|
31
|
+
models: { allExcept: ['user', 'organization', 'membership'] },
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
By default the extension reads the current organization from
|
|
37
|
+
`context.currentOrg?.id` (set by `resolveCurrentOrg`/`withTenancy`, below). Pass
|
|
38
|
+
`getTenantId` to source it differently, and `tenantField` if the column isn't
|
|
39
|
+
named `organizationId`.
|
|
40
|
+
|
|
41
|
+
### Escape hatches
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
db.$forOrg(organizationId) // scoped to one organization, ignoring context
|
|
45
|
+
db.$withoutTenant() // unscoped, for seeds, scripts, admin tooling
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Both share the same underlying connection pool as `db`. Use `$forOrg` in
|
|
49
|
+
background jobs and webhooks, where there's no request context but the
|
|
50
|
+
organization is known; use `$withoutTenant` for code that intentionally reads or
|
|
51
|
+
writes across organizations. Raw SQL (`$queryRaw`, `$executeRaw`, and their
|
|
52
|
+
`Unsafe` variants) is blocked on a tenant-scoped client and only available on
|
|
53
|
+
`$withoutTenant()`.
|
|
54
|
+
|
|
55
|
+
## Context helpers
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import {
|
|
59
|
+
resolveCurrentOrg,
|
|
60
|
+
requireCurrentOrg,
|
|
61
|
+
getCurrentOrg,
|
|
62
|
+
} from '@cedarjs/tenancy'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `resolveCurrentOrg({ event, variables, currentUser, lookupOrg })` — resolves
|
|
66
|
+
the organization a request targets (the `cedar-org` header, then
|
|
67
|
+
`orgId`/`orgSlug` GraphQL variables), validates the user has a membership in
|
|
68
|
+
it, and sets `context.currentOrg`. Wire it into `createGraphQLHandler`'s
|
|
69
|
+
`context` option.
|
|
70
|
+
- `withTenancy(handler, { authDecoder, getCurrentUser, lookupOrg })` — the same
|
|
71
|
+
setup for a plain `api/src/functions/*` handler that doesn't go through the
|
|
72
|
+
GraphQL context.
|
|
73
|
+
- `getCurrentOrg()` / `requireCurrentOrg()` — read the organization set on the
|
|
74
|
+
current request; the latter throws `TenantScopeError` when none is set.
|
|
75
|
+
|
|
76
|
+
## Auth helpers
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { hasOrgRole, requireMembership } from '@cedarjs/tenancy'
|
|
80
|
+
|
|
81
|
+
// In a service:
|
|
82
|
+
requireMembership({ roles: ['owner', 'admin'] })
|
|
83
|
+
|
|
84
|
+
// Anywhere:
|
|
85
|
+
if (hasOrgRole('owner')) { ... }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`requireMembership` throws `AuthenticationError` with no current user and
|
|
89
|
+
`ForbiddenError` with no current organization or a role mismatch — the same
|
|
90
|
+
error types `requireAuth` uses.
|
|
91
|
+
|
|
92
|
+
## Web
|
|
93
|
+
|
|
94
|
+
`@cedarjs/tenancy/web` exports `OrgScope`, `useCurrentOrg`, `OrgContext` and
|
|
95
|
+
`getMemberships`/`hasOrgRole` for the client side: a per-organization Apollo
|
|
96
|
+
client provided under organization routes, keyed by the immutable organization
|
|
97
|
+
id so switching organizations never mixes caches. See the how-to for the full
|
|
98
|
+
`<Set wrap={OrgScope}>` setup `yarn cedar setup tenancy` generates.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the current request holds one of `roles` in an organization.
|
|
3
|
+
*
|
|
4
|
+
* With `organizationId`, this checks `context.currentUser.memberships` for a
|
|
5
|
+
* membership in that specific organization — useful for checking access to
|
|
6
|
+
* an organization other than the one in scope. Without it, this checks the
|
|
7
|
+
* role on `context.currentOrg`, the organization already resolved for this
|
|
8
|
+
* request.
|
|
9
|
+
*
|
|
10
|
+
* Returns `false` when there's no authenticated user, no matching
|
|
11
|
+
* organization, or no matching membership. An empty `roles` array returns
|
|
12
|
+
* `true` as long as a matching membership exists, mirroring `hasRole()`'s
|
|
13
|
+
* behavior for global roles.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hasOrgRole(roles: string | string[], organizationId?: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Guards a service or directive on the current request having a membership
|
|
18
|
+
* in the current organization, optionally with one of `roles`.
|
|
19
|
+
*
|
|
20
|
+
* Throws `AuthenticationError` when there's no authenticated user, and
|
|
21
|
+
* `ForbiddenError` when there's no current organization or the membership's
|
|
22
|
+
* role doesn't match — the same error types `requireAuth` uses, so existing
|
|
23
|
+
* error handling on the web side applies unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export declare function requireMembership(options?: {
|
|
26
|
+
roles?: string | string[];
|
|
27
|
+
}): void;
|
|
28
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CA4BT;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAAO,GAC1C,IAAI,CAwBN"}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { context } from "@cedarjs/context";
|
|
2
|
+
import { AuthenticationError, ForbiddenError } from "@cedarjs/graphql-server";
|
|
3
|
+
import { getCurrentOrg, isUserWithMemberships } from "./context.js";
|
|
4
|
+
function hasOrgRole(roles, organizationId) {
|
|
5
|
+
const roleList = Array.isArray(roles) ? roles : [roles];
|
|
6
|
+
if (organizationId) {
|
|
7
|
+
const currentUser = context.currentUser;
|
|
8
|
+
if (!isUserWithMemberships(currentUser)) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const membership = currentUser.memberships.find(
|
|
12
|
+
(m) => m.organizationId === organizationId
|
|
13
|
+
);
|
|
14
|
+
if (!membership) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return roleList.length === 0 || roleList.includes(membership.role);
|
|
18
|
+
}
|
|
19
|
+
const currentOrg = getCurrentOrg();
|
|
20
|
+
if (!currentOrg) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return roleList.length === 0 || roleList.includes(currentOrg.role);
|
|
24
|
+
}
|
|
25
|
+
function requireMembership(options = {}) {
|
|
26
|
+
if (!context.currentUser) {
|
|
27
|
+
throw new AuthenticationError("You must be logged in to do that.");
|
|
28
|
+
}
|
|
29
|
+
const currentOrg = getCurrentOrg();
|
|
30
|
+
if (!currentOrg) {
|
|
31
|
+
throw new ForbiddenError(
|
|
32
|
+
"You must be a member of an organization to do that."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const { roles } = options;
|
|
36
|
+
if (roles === void 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const roleList = Array.isArray(roles) ? roles : [roles];
|
|
40
|
+
if (roleList.length > 0 && !roleList.includes(currentOrg.role)) {
|
|
41
|
+
throw new ForbiddenError("You do not have permission to do that.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
hasOrgRole,
|
|
46
|
+
requireMembership
|
|
47
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda';
|
|
2
|
+
import type { Decoder } from '@cedarjs/api';
|
|
3
|
+
/**
|
|
4
|
+
* The organization resolved for the current request: the identity fields
|
|
5
|
+
* from `Organization`, plus the role and membership id derived from the
|
|
6
|
+
* authenticated user's own membership in it.
|
|
7
|
+
*/
|
|
8
|
+
export interface CurrentOrg {
|
|
9
|
+
id: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
role: string;
|
|
12
|
+
membershipId: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The shape of a `Membership` row that `setCurrentOrg` and `resolveCurrentOrg`
|
|
16
|
+
* need: enough to derive a `CurrentOrg` without importing a generated Prisma
|
|
17
|
+
* type into this package.
|
|
18
|
+
*/
|
|
19
|
+
export interface MembershipSummary {
|
|
20
|
+
id: string;
|
|
21
|
+
organizationId: string;
|
|
22
|
+
role: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The shape `getCurrentUser()` must return once an app has set up tenancy:
|
|
26
|
+
* the user's memberships across every organization they belong to.
|
|
27
|
+
*/
|
|
28
|
+
export interface UserWithMemberships {
|
|
29
|
+
memberships: MembershipSummary[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Request header carrying the organization the request targets, by id or by
|
|
33
|
+
* slug. No `X-` prefix, per RFC 6648, matching the existing `auth-provider`
|
|
34
|
+
* header.
|
|
35
|
+
*/
|
|
36
|
+
export declare const CEDAR_ORG_HEADER = "cedar-org";
|
|
37
|
+
/**
|
|
38
|
+
* Narrows an unknown `currentUser` value down to one carrying memberships,
|
|
39
|
+
* without assuming anything else about its shape.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isUserWithMemberships(value: unknown): value is UserWithMemberships;
|
|
42
|
+
/**
|
|
43
|
+
* Sets the current request's organization from its identity alone. `role`
|
|
44
|
+
* and `membershipId` are derived from `currentUser.memberships`, never from
|
|
45
|
+
* the caller, so nothing that calls this function can grant a role that
|
|
46
|
+
* doesn't already exist. Throws `ForbiddenError` when the user has no
|
|
47
|
+
* membership in the organization, the same error a non-member gets, so a
|
|
48
|
+
* caller can't distinguish "no such organization" from "not your
|
|
49
|
+
* organization".
|
|
50
|
+
*/
|
|
51
|
+
export declare function setCurrentOrg(org: {
|
|
52
|
+
id: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
}, currentUser: UserWithMemberships): CurrentOrg;
|
|
55
|
+
/**
|
|
56
|
+
* Reads the organization set on the current request context, if any.
|
|
57
|
+
*/
|
|
58
|
+
export declare function getCurrentOrg(): CurrentOrg | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Reads the organization set on the current request context. Throws
|
|
61
|
+
* `TenantScopeError` when none is set, for code that cannot proceed without
|
|
62
|
+
* one (the Prisma extension uses the same error for the same reason).
|
|
63
|
+
*/
|
|
64
|
+
export declare function requireCurrentOrg(): CurrentOrg;
|
|
65
|
+
/**
|
|
66
|
+
* Resolves the organization a request targets and sets it on the context.
|
|
67
|
+
*
|
|
68
|
+
* Resolution order: the `cedar-org` request header, then the `orgId`
|
|
69
|
+
* GraphQL variable, then the `orgSlug` variable. When none is present, the
|
|
70
|
+
* request has no organization in scope: this returns `undefined` and sets
|
|
71
|
+
* nothing, which is correct for anonymous requests and for operations that
|
|
72
|
+
* don't touch tenant-owned data.
|
|
73
|
+
*
|
|
74
|
+
* An id or slug that doesn't resolve to an organization the user belongs to
|
|
75
|
+
* — whether it doesn't exist at all, or exists but the user isn't a member —
|
|
76
|
+
* is rejected the same way, with `ForbiddenError`, so a caller can't use this
|
|
77
|
+
* function to probe which organizations exist.
|
|
78
|
+
*/
|
|
79
|
+
export declare function resolveCurrentOrg(args: {
|
|
80
|
+
event: APIGatewayProxyEvent | Request;
|
|
81
|
+
variables?: Record<string, unknown>;
|
|
82
|
+
currentUser: UserWithMemberships;
|
|
83
|
+
lookupOrg: (idOrSlug: string) => Promise<{
|
|
84
|
+
id: string;
|
|
85
|
+
slug: string;
|
|
86
|
+
} | null>;
|
|
87
|
+
}): Promise<CurrentOrg | undefined>;
|
|
88
|
+
/**
|
|
89
|
+
* Wraps a plain `api/src/functions/*` handler — one that doesn't go through
|
|
90
|
+
* the GraphQL context — with the same request-context setup a GraphQL
|
|
91
|
+
* request gets: it authenticates the request, loads the current user, and
|
|
92
|
+
* resolves the current organization from the request headers, all inside a
|
|
93
|
+
* fresh `@cedarjs/context` store, before calling the handler.
|
|
94
|
+
*
|
|
95
|
+
* An unauthenticated request, or one whose `getCurrentUser` result doesn't
|
|
96
|
+
* carry memberships, still reaches the handler; it simply runs with no
|
|
97
|
+
* `currentOrg` set, so any tenant-owned query inside it fails with
|
|
98
|
+
* `TenantScopeError` unless the handler uses `db.$forOrg()` explicitly.
|
|
99
|
+
*/
|
|
100
|
+
export declare function withTenancy<Event extends APIGatewayProxyEvent | Request, Ctx extends LambdaContext = LambdaContext, Result = unknown>(handler: (event: Event, context: Ctx) => Promise<Result> | Result, options: {
|
|
101
|
+
authDecoder: Decoder | Decoder[];
|
|
102
|
+
getCurrentUser: (decoded: unknown, raw: {
|
|
103
|
+
type: string;
|
|
104
|
+
schema: string;
|
|
105
|
+
token: string;
|
|
106
|
+
}, req: {
|
|
107
|
+
event: APIGatewayProxyEvent | Request;
|
|
108
|
+
request?: Request;
|
|
109
|
+
context?: LambdaContext;
|
|
110
|
+
}) => Promise<unknown>;
|
|
111
|
+
lookupOrg: (idOrSlug: string) => Promise<{
|
|
112
|
+
id: string;
|
|
113
|
+
slug: string;
|
|
114
|
+
} | null>;
|
|
115
|
+
}): (event: Event, context: Ctx) => Promise<Result>;
|
|
116
|
+
declare module '@cedarjs/context' {
|
|
117
|
+
interface GlobalContext {
|
|
118
|
+
currentOrg?: CurrentOrg;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { getAuthenticationContext, getEventHeader } from "@cedarjs/api";
|
|
2
|
+
import { context, setContext } from "@cedarjs/context";
|
|
3
|
+
import { getAsyncStoreInstance } from "@cedarjs/context/dist/store.js";
|
|
4
|
+
import { ForbiddenError } from "@cedarjs/graphql-server";
|
|
5
|
+
import { TenantScopeError } from "./errors.js";
|
|
6
|
+
const CEDAR_ORG_HEADER = "cedar-org";
|
|
7
|
+
function isUserWithMemberships(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && Array.isArray(value.memberships);
|
|
9
|
+
}
|
|
10
|
+
function setCurrentOrg(org, currentUser) {
|
|
11
|
+
const membership = currentUser.memberships.find(
|
|
12
|
+
(m) => m.organizationId === org.id
|
|
13
|
+
);
|
|
14
|
+
if (!membership) {
|
|
15
|
+
throw new ForbiddenError("You are not a member of this organization.");
|
|
16
|
+
}
|
|
17
|
+
const currentOrg = {
|
|
18
|
+
id: org.id,
|
|
19
|
+
slug: org.slug,
|
|
20
|
+
role: membership.role,
|
|
21
|
+
membershipId: membership.id
|
|
22
|
+
};
|
|
23
|
+
context.currentOrg = currentOrg;
|
|
24
|
+
return currentOrg;
|
|
25
|
+
}
|
|
26
|
+
function getCurrentOrg() {
|
|
27
|
+
return context.currentOrg;
|
|
28
|
+
}
|
|
29
|
+
function requireCurrentOrg() {
|
|
30
|
+
const currentOrg = getCurrentOrg();
|
|
31
|
+
if (!currentOrg) {
|
|
32
|
+
throw new TenantScopeError(
|
|
33
|
+
`No organization is set on the current request. Send the \`${CEDAR_ORG_HEADER}\` header, or an \`orgId\`/\`orgSlug\` variable, to select one.`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return currentOrg;
|
|
37
|
+
}
|
|
38
|
+
async function resolveCurrentOrg(args) {
|
|
39
|
+
const { event, variables, currentUser, lookupOrg } = args;
|
|
40
|
+
const headerOrg = getEventHeader(event, CEDAR_ORG_HEADER);
|
|
41
|
+
const orgIdOrSlug = headerOrg || (typeof variables?.orgId === "string" ? variables.orgId : void 0) || (typeof variables?.orgSlug === "string" ? variables.orgSlug : void 0);
|
|
42
|
+
if (!orgIdOrSlug) {
|
|
43
|
+
return void 0;
|
|
44
|
+
}
|
|
45
|
+
const org = await lookupOrg(orgIdOrSlug);
|
|
46
|
+
if (!org) {
|
|
47
|
+
throw new ForbiddenError("You are not a member of this organization.");
|
|
48
|
+
}
|
|
49
|
+
return setCurrentOrg(org, currentUser);
|
|
50
|
+
}
|
|
51
|
+
function withTenancy(handler, options) {
|
|
52
|
+
return async (event, lambdaContext) => {
|
|
53
|
+
return getAsyncStoreInstance().run(/* @__PURE__ */ new Map(), async () => {
|
|
54
|
+
const authContext = await getAuthenticationContext({
|
|
55
|
+
authDecoder: options.authDecoder,
|
|
56
|
+
event,
|
|
57
|
+
context: lambdaContext
|
|
58
|
+
});
|
|
59
|
+
if (!authContext) {
|
|
60
|
+
return handler(event, lambdaContext);
|
|
61
|
+
}
|
|
62
|
+
const [decoded, raw, req] = authContext;
|
|
63
|
+
const currentUser = await options.getCurrentUser(decoded, raw, req);
|
|
64
|
+
setContext({ currentUser });
|
|
65
|
+
if (isUserWithMemberships(currentUser)) {
|
|
66
|
+
await resolveCurrentOrg({
|
|
67
|
+
event,
|
|
68
|
+
currentUser,
|
|
69
|
+
lookupOrg: options.lookupOrg
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return handler(event, lambdaContext);
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
CEDAR_ORG_HEADER,
|
|
78
|
+
getCurrentOrg,
|
|
79
|
+
isUserWithMemberships,
|
|
80
|
+
requireCurrentOrg,
|
|
81
|
+
resolveCurrentOrg,
|
|
82
|
+
setCurrentOrg,
|
|
83
|
+
withTenancy
|
|
84
|
+
};
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a tenant-owned model is queried with no organization in
|
|
3
|
+
* scope: no `context.currentOrg`, and neither `db.$forOrg(id)` nor
|
|
4
|
+
* `db.$withoutTenant()` was used explicitly. A loud failure here is
|
|
5
|
+
* preferable to a silent cross-tenant read.
|
|
6
|
+
*/
|
|
7
|
+
export declare class TenantScopeError extends Error {
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,IAAI,SAAqB;CAC1B"}
|
package/dist/errors.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,WAAW,CAAA;AACzB,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAA;AAC7D,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
type FilterOutDollarPrefixed<T> = T extends `$${string}` ? never : T extends symbol ? never : T;
|
|
2
|
+
/**
|
|
3
|
+
* The Prisma Client's model accessor names (`project`, `organization`, ...),
|
|
4
|
+
* excluding client-level members like `$transaction` and `$extends`.
|
|
5
|
+
*/
|
|
6
|
+
export type ModelNamesFor<TClient> = FilterOutDollarPrefixed<keyof TClient>;
|
|
7
|
+
export interface TenancyConfig<TClient> {
|
|
8
|
+
/**
|
|
9
|
+
* The column name on every tenant-owned model that stores the
|
|
10
|
+
* organization id. Default: `'organizationId'`.
|
|
11
|
+
*/
|
|
12
|
+
tenantField?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Either the exact list of tenant-owned models, or every model except the
|
|
15
|
+
* ones listed in `allExcept`. `allExcept` is the safer default for an app:
|
|
16
|
+
* a model added later is scoped automatically instead of silently staying
|
|
17
|
+
* unscoped because it was never added to an explicit list. Cedar's own
|
|
18
|
+
* `RW_DataMigration` model is never tenant-owned in either form.
|
|
19
|
+
*/
|
|
20
|
+
models: ModelNamesFor<TClient>[] | {
|
|
21
|
+
allExcept: ModelNamesFor<TClient>[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Returns the current tenant id, or `undefined` when none is in scope.
|
|
25
|
+
* Default: reads `context.currentOrg?.id` from `@cedarjs/context`.
|
|
26
|
+
*/
|
|
27
|
+
getTenantId?: () => string | undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The result of line-parsing one model body: which field names were
|
|
31
|
+
* actually read off a line (`knownFields`), and which of those declared a
|
|
32
|
+
* type ending in `[]` (`listFields`, always a subset of `knownFields`).
|
|
33
|
+
* `knownFields` is what lets `isListRelationField` tell "this field is
|
|
34
|
+
* to-one" apart from "this field was never classified" — see there.
|
|
35
|
+
*/
|
|
36
|
+
interface ParsedModelFields {
|
|
37
|
+
knownFields: Set<string>;
|
|
38
|
+
listFields: Set<string>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parses `model X { ... }` blocks out of a `.prisma` schema source and
|
|
42
|
+
* returns, per model, which fields were read off a line and which of those
|
|
43
|
+
* are lists (type ending in `[]`) — the exact list-cardinality signal
|
|
44
|
+
* Prisma's compact runtime data model doesn't carry (see
|
|
45
|
+
* `isListRelationField`). Model bodies are located by `findModelBodyRange`,
|
|
46
|
+
* which counts brace depth (rather than matching up to the first `}`) so a
|
|
47
|
+
* brace inside a quoted attribute value or a `//` comment can't be mistaken
|
|
48
|
+
* for the model's end. Beyond that, this is a line-oriented reading of the
|
|
49
|
+
* schema, not a full parser: for each non-blank, non-comment, non-`@@`
|
|
50
|
+
* attribute line inside a model block, only the first two whitespace-
|
|
51
|
+
* separated tokens (field name, type) are read, so `@relation(...)`,
|
|
52
|
+
* `@map(...)` and other attributes on the same line are ignored along with
|
|
53
|
+
* everything after them.
|
|
54
|
+
*
|
|
55
|
+
* Exported only for `parseListFieldsFromSchema.test.ts`; not part of this
|
|
56
|
+
* package's public API.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseListFieldsFromSchema(schemaText: string): Map<string, ParsedModelFields>;
|
|
59
|
+
declare function throwRawQueryBlocked(): never;
|
|
60
|
+
/**
|
|
61
|
+
* Builds a Prisma Client extension that scopes every operation on a
|
|
62
|
+
* tenant-owned model to the tenant id `config.getTenantId()` (or the
|
|
63
|
+
* default reader of `context.currentOrg?.id`) returns, and adds
|
|
64
|
+
* `db.$forOrg(organizationId)` / `db.$withoutTenant()` escape hatches.
|
|
65
|
+
*
|
|
66
|
+
* See `docs/implementation-plans/2026-08-26-multi-tenancy.md` ("Layer 1")
|
|
67
|
+
* for the full behavior table this implements.
|
|
68
|
+
*/
|
|
69
|
+
export declare function createTenancyExtension<TClient>(config: TenancyConfig<TClient>): (client: any) => import("@prisma/client/extension").PrismaClientExtends<import("@prisma/client/runtime/client").InternalArgs<{}, {}, {}, {
|
|
70
|
+
/**
|
|
71
|
+
* A client scoped to `organizationId` regardless of request
|
|
72
|
+
* context — for background jobs, webhooks, and other code that
|
|
73
|
+
* knows the tenant but doesn't run inside a request.
|
|
74
|
+
*/
|
|
75
|
+
$forOrg(organizationId: string): TClient;
|
|
76
|
+
/**
|
|
77
|
+
* An unscoped client for code that intentionally reads or writes
|
|
78
|
+
* across organizations: seeds, data migrations, admin tooling.
|
|
79
|
+
* Nothing here is scoped, and raw SQL is allowed.
|
|
80
|
+
*/
|
|
81
|
+
$withoutTenant(): TClient;
|
|
82
|
+
}> & import("@prisma/client/runtime/client").InternalArgs<{}, {}, {}, {
|
|
83
|
+
$queryRaw: typeof throwRawQueryBlocked;
|
|
84
|
+
$queryRawUnsafe: typeof throwRawQueryBlocked;
|
|
85
|
+
$executeRaw: typeof throwRawQueryBlocked;
|
|
86
|
+
$executeRawUnsafe: typeof throwRawQueryBlocked;
|
|
87
|
+
}> & import("@prisma/client/runtime/client").DefaultArgs>;
|
|
88
|
+
export {};
|
|
89
|
+
//# sourceMappingURL=prismaExtension.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prismaExtension.d.ts","sourceRoot":"","sources":["../src/prismaExtension.ts"],"names":[],"mappings":"AAQA,KAAK,uBAAuB,CAAC,CAAC,IAAI,CAAC,SAAS,IAAI,MAAM,EAAE,GACpD,KAAK,GACL,CAAC,SAAS,MAAM,GACd,KAAK,GACL,CAAC,CAAA;AAEP;;;GAGG;AACH,MAAM,MAAM,aAAa,CAAC,OAAO,IAAI,uBAAuB,CAAC,MAAM,OAAO,CAAC,CAAA;AAE3E,MAAM,WAAW,aAAa,CAAC,OAAO;IACpC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,GAAG;QAAE,SAAS,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,CAAA;KAAE,CAAA;IAC1E;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;CACvC;AA4ID;;;;;;GAMG;AACH,UAAU,iBAAiB;IACzB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CACxB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,UAAU,EAAE,MAAM,GACjB,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAwChC;AAiwBD,iBAAS,oBAAoB,IAAI,KAAK,CAErC;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAC5C,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC;IA6DxB;;;;OAIG;4BACqB,MAAM,GAMiC,OAAO;IAEtE;;;;OAIG;sBAE2B,OAAO;;;;;;0DAK5C"}
|