[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[2c] Org isolation enforcement - #32

Draft
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c
Draft

[2c] Org isolation enforcement#32
andrmaz wants to merge 5 commits into
developfrom
cursor/org-isolation-enforcement-cc9c

Conversation

@andrmaz

Copy link
Copy Markdown
Owner

What changed

Org-scoped filtering is now enforced at the Prisma query layer for every request, not just opted into per controller.

  • db's new org-scope module: an AsyncLocalStorage-backed org context plus a Prisma Client Extension (createOrgScopedClient) that automatically injects/forces organizationId filters (or the equivalent relation filter for join/child models) on every model operation — reads, creates, updates, deletes. Any query made with no active org context throws MissingOrgContextError (fail closed). A deliberate runWithoutOrgScope escape hatch exists for the one legitimate pre-auth path (resolving a user's org during login).
  • PrismaService no longer extends PrismaClient; every model delegate it exposes is routed through the org-scoped client, and the raw client has no external accessor — there is no way to bypass scoping from application code.
  • OrgContextInterceptor (global, via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active context for the whole request, so controllers/services don't need to remember to filter by org themselves.
  • OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError (a write referencing a foreign-org record) to 403.
  • Fixed a real cross-org leak: OrganizationsController had zero org scoping — any admin could list every organization, fetch any organization by id, and rename any organization. It now behaves like the existing DepartmentsController/AdminUsersController convention (403 on a mismatched id; list returns only the caller's own org).
  • Wired @prisma/adapter-pg into PrismaService (Prisma 7 requires a driver adapter), incidentally fixing a previously-documented boot gap.

Why this design

Building this as an opt-in per-controller check (the existing pattern) is exactly what let OrganizationsController slip through with no scoping at all. The Prisma extension makes scoping mandatory at the query layer regardless of whether a given service author remembers to filter — new models must be registered in ORG_SCOPE_CONFIG or all queries against them fail closed.

Testing

  • 61 new unit tests in packages/db covering the pure scoping logic and the extension itself (via a lightweight fake client that faithfully reproduces Prisma's $extends contract) — no live DB required.
  • New/updated integration tests in apps/api (interceptor wiring, exception filter, organizations cross-org 403s, MCP cross-org isolation and concurrency).
  • Manual end-to-end verification against a real local Postgres (seeded two orgs, confirmed cross-org reads never leak, cross-org writes are rejected with OrgScopeViolationError, $transaction batches stay correctly scoped, and no-context queries fail closed). This caught two real bugs the unit tests alone would have missed:
    • Prisma's client methods return a lazy promise that only registers its .then() reaction when awaited — a runWithOrgContext/runWithoutOrgScope callback that just returns that lazy promise (instead of being async and consuming it) silently loses the bound context. Documented on runWithOrgContext and fixed the one real call site that had this bug (UserService.findByGoogleSub).
    • Prisma's WhereUniqueInput (used by findUnique/update/delete/upsert) requires the unique identifier to stay a direct top-level field — wrapping it in AND fails validation. Added a separate flat-merge strategy (mergeUniqueWhere) for these operations.
  • Full monorepo pnpm test / pnpm check-types / pnpm lint all pass.

Acceptance criteria

  • A middleware/helper enforces org-scoped filtering on all DB queries.
  • Attempting to read another org's data via API returns 403/404.
  • MCP calls are also org-scoped — no cross-org leakage through the MCP path.
  • Integration tests verify isolation for both API and MCP paths.
Open in WebOpen in Cursor

cursoragentand others added 5 commits September 2, 2026 21:53
Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a
Prisma client extension that automatically injects/forces organizationId
filters (or relation-based equivalents) on every model operation. Access
without an active context fails closed (MissingOrgContextError); a
deliberate runWithoutOrgScope escape hatch exists for pre-auth system
paths. Relation-scoped creates are verified via a DB round trip through
the same scoped client, so cross-org foreign keys are rejected
(OrgScopeViolationError).
Covered by 61 unit tests exercising the pure scoping logic and a fake
Prisma-extension client (no live DB needed), plus manual verification
against a real local Postgres.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
- PrismaService no longer extends PrismaClient directly; every model
delegate it exposes is routed through db's org-scoped Prisma client, and
the raw client is a private field with no external accessor.
- Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a
previously-documented boot gap as a side effect of this change.
- OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds
the authenticated caller's organizationId as the active org context for
the duration of each request, so controllers/services don't need to
remember to filter by org themselves.
- OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403
Forbidden.
- Extends the db-client jest mock with a real (duplicated, dependency-free)
copy of the org-context primitives so tests exercise real ALS behavior.
Covered by new interceptor/filter test suites, including a regression
test that a runWithOrgContext callback must synchronously consume any
returned Prisma-like lazy promise (a subtlety documented on
runWithOrgContext itself) and a realistic multi-hop async chain test.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
findByGoogleSub and findOrCreate run before the caller's organization is
known (findOrCreate is literally what determines it, via the email-domain
lookup), so they wrap their Prisma calls in runWithoutOrgScope.
The runWithoutOrgScope callback must be async (or otherwise synchronously
consume the Prisma call) — a bare non-async callback that merely returns
the lazy promise loses the bound context once storage.run() exits.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
OrganizationsController previously had no organization scoping at all:
any authenticated admin, regardless of their own org, could list every
organization in the system, fetch any organization by id, and rename any
organization — the clearest cross-org leak in the API surface this slice
is meant to close.
GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same
convention already used by DepartmentsController and AdminUsersController)
to return 403 for a mismatched id before ever touching the database.
GET / (list) now returns only the caller's own organization instead of
every tenant. POST (create) is unchanged — provisioning a brand-new
organization doesn't read or modify existing tenant data.
Removes OrganizationService.findAll(), which had become dead, unscoped
code once the controller stopped calling it.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
Verifies the userDepartment lookup is scoped by the caller's own
organization, that concurrent requests from different organizations
never cross-contaminate scope resolution, and that a department only
resolves when its relation filter matches the caller's org.
Co-authored-by: Andrea Mazzucchelli <andrmaz@users.noreply.github.com>
@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cursorcursorBot mentioned this pull request Sep 2, 2026
4 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@andrmaz@cursoragent