@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.
Files changed (41) hide show
  1. package/README.md +98 -0
  2. package/dist/auth.d.ts +28 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +47 -0
  5. package/dist/context.d.ts +121 -0
  6. package/dist/context.d.ts.map +1 -0
  7. package/dist/context.js +84 -0
  8. package/dist/errors.d.ts +10 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/errors.js +6 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/prismaExtension.d.ts +89 -0
  15. package/dist/prismaExtension.d.ts.map +1 -0
  16. package/dist/prismaExtension.js +583 -0
  17. package/dist/web/OrgContext.d.ts +8 -0
  18. package/dist/web/OrgContext.d.ts.map +1 -0
  19. package/dist/web/OrgContext.js +7 -0
  20. package/dist/web/OrgScope.d.ts +29 -0
  21. package/dist/web/OrgScope.d.ts.map +1 -0
  22. package/dist/web/OrgScope.js +107 -0
  23. package/dist/web/getMemberships.d.ts +8 -0
  24. package/dist/web/getMemberships.d.ts.map +1 -0
  25. package/dist/web/getMemberships.js +25 -0
  26. package/dist/web/hasOrgRole.d.ts +12 -0
  27. package/dist/web/hasOrgRole.d.ts.map +1 -0
  28. package/dist/web/hasOrgRole.js +16 -0
  29. package/dist/web/index.d.ts +9 -0
  30. package/dist/web/index.d.ts.map +1 -0
  31. package/dist/web/index.js +14 -0
  32. package/dist/web/orgClients.d.ts +27 -0
  33. package/dist/web/orgClients.d.ts.map +1 -0
  34. package/dist/web/orgClients.js +37 -0
  35. package/dist/web/types.d.ts +36 -0
  36. package/dist/web/types.d.ts.map +1 -0
  37. package/dist/web/types.js +0 -0
  38. package/dist/web/useCurrentOrg.d.ts +10 -0
  39. package/dist/web/useCurrentOrg.d.ts.map +1 -0
  40. package/dist/web/useCurrentOrg.js +12 -0
  41. 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":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,YAAY,CAAA;AAEhF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAQ3C;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,EAAE,MAAM,CAAA;IACtB,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,iBAAiB,EAAE,CAAA;CACjC;AAED;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,cAAc,CAAA;AAE3C;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,mBAAmB,CAM9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACjC,WAAW,EAAE,mBAAmB,GAC/B,UAAU,CAmBZ;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,UAAU,GAAG,SAAS,CAEtD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,UAAU,CAY9C;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE;IAC5C,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAA;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACnC,WAAW,EAAE,mBAAmB,CAAA;IAChC,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;CAC9E,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAsBlC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CACzB,KAAK,SAAS,oBAAoB,GAAG,OAAO,EAC5C,GAAG,SAAS,aAAa,GAAG,aAAa,EACzC,MAAM,GAAG,OAAO,EAEhB,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,EACjE,OAAO,EAAE;IACP,WAAW,EAAE,OAAO,GAAG,OAAO,EAAE,CAAA;IAChC,cAAc,EAAE,CACd,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EACpD,GAAG,EAAE;QACH,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAA;QACrC,OAAO,CAAC,EAAE,OAAO,CAAA;QACjB,OAAO,CAAC,EAAE,aAAa,CAAA;KACxB,KACE,OAAO,CAAC,OAAO,CAAC,CAAA;IACrB,SAAS,EAAE,CACT,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;CAClD,GACA,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,MAAM,CAAC,CA4BjD;AAED,OAAO,QAAQ,kBAAkB,CAAC;IAChC,UAAU,aAAa;QACrB,UAAU,CAAC,EAAE,UAAU,CAAA;KACxB;CACF"}
@@ -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
+ };
@@ -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
@@ -0,0 +1,6 @@
1
+ class TenantScopeError extends Error {
2
+ name = "TenantScopeError";
3
+ }
4
+ export {
5
+ TenantScopeError
6
+ };
@@ -0,0 +1,6 @@
1
+ export * from './errors.js';
2
+ export * from './context.js';
3
+ export * from './auth.js';
4
+ export { createTenancyExtension } from './prismaExtension.js';
5
+ export type { ModelNamesFor, TenancyConfig } from './prismaExtension.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -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,7 @@
1
+ export * from "./errors.js";
2
+ export * from "./context.js";
3
+ export * from "./auth.js";
4
+ import { createTenancyExtension } from "./prismaExtension.js";
5
+ export {
6
+ createTenancyExtension
7
+ };
@@ -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"}