You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Proposal: expose a Kysely-plugin pass-through on the DB config, for row-level content scoping
Context. We maintain emdash-better-auth (Better Auth for EmDash — email/password + social + verification, users mapped onto EmDash's users table via a custom adapter). We're scoping multi-tenant / organization support on top of it (Better Auth's organization plugin gives orgs, membership, per-org roles, invitations — all storable in plugin storage). The one piece we cannot do from a plugin is content isolation: guaranteeing org A can never read org B's content.
We'd like to propose a small, generic, opt-in seam in EmDash core that would make real content scoping possible without EmDash ever learning the word "organization."
We investigated the installed source. Three findings:
No content read/query hook. The plugin hook vocabulary is write/lifecycle only (content:beforeSave/afterSave/beforeDelete/…). There's no content:beforeRead / content:query a plugin can register to narrow reads.
content:read is coarse. The content API checks requirePerm(user, "content:read") + a draft/published split — no per-item canRead(item, user). It can't express "only this org's entries."
CollectionFilter.where filtering is app-enforced, not enforceable. A site can pass where: { organization_id: activeOrg } to the loaders, but nothing forces it — the admin content list, global search (FTS), RSS, sitemap, and any un-filtered query still see everything. That's best-effort scoping, not isolation.
The proposal: a plugins?: KyselyPlugin[] pass-through on the DB config
EmDash constructs its Kysely instance internally (dist/loader-*.mjs):
There's no plugins: array and DatabaseDescriptor exposes no seam, so a plugin can't attach a Kysely plugin to the instance EmDash actually queries with.
We'd like a way to contribute Kysely plugins into that construction — e.g. an optional plugins?: KyselyPlugin[] on the DB config / DatabaseDescriptor, forwarded into new Kysely({ ..., plugins }) (request-scoped instances too).
Why Kysely specifically: KyselyPlugin.transformQuery(args) receives the parsed query AST before execution and returns a modified tree. A plugin can detect content-table SELECTs and inject WHERE organization_id = ? from request context. Because it runs at the driver level, below all application code, it covers every read path — admin list, search, RSS, relations — with no per-path work in core. That's exactly the un-bypassable coverage that turns "opt-in filter" into "isolation."
Why this is safe for every EmDash site
Opt-in, default no-op. No plugins provided → new Kysely(...) is unchanged → byte-identical behavior. Sites not using it are completely unaffected.
Core stays domain-agnostic. Core forwards opaque KyselyPlugin[]. "Organization"/"tenant" lives entirely in the consuming plugin. This seam is equally usable for other row-level scoping (per-user drafts, region gating, soft-delete filters, etc.).
Tiny surface. Roughly one optional config field + forwarding it at the construction site(s).
Scope / open questions for maintainers
Would you accept plugins at the static DB config level, the request-scoped DB (you already have supportsRequestScope / getRequestContext() ALS, which is where an active-tenant value would come from), or both?
Any concern about Kysely plugins interacting with the query cache or the coalescing/cold-start read batch?
Guidance you'd want documented for authors (the transformer must be surgical — target only content-table SELECTs, handle taxonomy/byline JOINs, aliases, subqueries, CTEs, and not constrain writes) so a badly-written transformer can't silently break or leak.
Full technical write-up (identity/RBAC analysis, WordPress-Multisite precedent, the content-isolation option matrix, and the verified Kysely findings) lives here: theweekendprojects/emdash-better-auth#4
Happy to prototype the plugin side and open a PR for the core pass-through if this direction sounds acceptable.
— maintainers of emdash-better-auth (theweekendprojects)
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Proposal: expose a Kysely-plugin pass-through on the DB config, for row-level content scoping
Context. We maintain
emdash-better-auth(Better Auth for EmDash — email/password + social + verification, users mapped onto EmDash'suserstable via a custom adapter). We're scoping multi-tenant / organization support on top of it (Better Auth'sorganizationplugin gives orgs, membership, per-org roles, invitations — all storable in plugin storage). The one piece we cannot do from a plugin is content isolation: guaranteeing org A can never read org B's content.We'd like to propose a small, generic, opt-in seam in EmDash core that would make real content scoping possible without EmDash ever learning the word "organization."
Why plugin-only isn't enough today (EmDash 0.30.0)
We investigated the installed source. Three findings:
content:beforeSave/afterSave/beforeDelete/…). There's nocontent:beforeRead/content:querya plugin can register to narrow reads.content:readis coarse. The content API checksrequirePerm(user, "content:read")+ a draft/published split — no per-itemcanRead(item, user). It can't express "only this org's entries."CollectionFilter.wherefiltering is app-enforced, not enforceable. A site can passwhere: { organization_id: activeOrg }to the loaders, but nothing forces it — the admin content list, global search (FTS), RSS, sitemap, and any un-filtered query still see everything. That's best-effort scoping, not isolation.The proposal: a
plugins?: KyselyPlugin[]pass-through on the DB configEmDash constructs its Kysely instance internally (
dist/loader-*.mjs):There's no
plugins:array andDatabaseDescriptorexposes no seam, so a plugin can't attach a Kysely plugin to the instance EmDash actually queries with.We'd like a way to contribute Kysely plugins into that construction — e.g. an optional
plugins?: KyselyPlugin[]on the DB config /DatabaseDescriptor, forwarded intonew Kysely({ ..., plugins })(request-scoped instances too).Why Kysely specifically:
KyselyPlugin.transformQuery(args)receives the parsed query AST before execution and returns a modified tree. A plugin can detect content-table SELECTs and injectWHERE organization_id = ?from request context. Because it runs at the driver level, below all application code, it covers every read path — admin list, search, RSS, relations — with no per-path work in core. That's exactly the un-bypassable coverage that turns "opt-in filter" into "isolation."Why this is safe for every EmDash site
new Kysely(...)is unchanged → byte-identical behavior. Sites not using it are completely unaffected.KyselyPlugin[]. "Organization"/"tenant" lives entirely in the consuming plugin. This seam is equally usable for other row-level scoping (per-user drafts, region gating, soft-delete filters, etc.).Scope / open questions for maintainers
supportsRequestScope/getRequestContext()ALS, which is where an active-tenant value would come from), or both?Full technical write-up (identity/RBAC analysis, WordPress-Multisite precedent, the content-isolation option matrix, and the verified Kysely findings) lives here: theweekendprojects/emdash-better-auth#4
Happy to prototype the plugin side and open a PR for the core pass-through if this direction sounds acceptable.
— maintainers of
emdash-better-auth(theweekendprojects)All reactions