From 057ff73072e6ab66348741b3b18e4724a685d59b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 10 Jul 2026 16:51:58 +0100 Subject: [PATCH] feat(webapp): pass database writer and reader config to auth plugins The host now resolves writer and read-replica URLs from its env (the same fallback chain its own Prisma clients use) and hands them to installed RBAC and SSO plugins at create time, with separate connection limits for writes (default 2) and reads (default 5). A plugin that owns its own database client can then route hot-path reads (per-request auth checks, login routing) to the read replica instead of holding connections on the primary. --- apps/webapp/app/env.server.ts | 13 +++++++++++++ apps/webapp/app/services/rbac.server.ts | 15 ++++++++++++++- apps/webapp/app/services/sso.server.ts | 14 +++++++++++++- internal-packages/rbac/src/index.ts | 11 ++++++++++- internal-packages/sso/src/index.ts | 8 +++++++- packages/plugins/src/databaseConfig.ts | 15 +++++++++++++++ packages/plugins/src/index.ts | 4 ++++ packages/plugins/src/rbac.ts | 7 +++++++ packages/plugins/src/sso.ts | 11 ++++++++++- 9 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 packages/plugins/src/databaseConfig.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 36c2302a5c8..e8bb1027604 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -2072,9 +2072,22 @@ const EnvironmentSchema = z // Force RBAC to not use the plugin RBAC_FORCE_FALLBACK: BoolEnv.default(false), + // Per-process pool sizes for an RBAC plugin that owns its own database + // client (the fallback queries through Prisma and ignores these). Writes + // are rare role mutations; reads run on the per-request auth hot path. + RBAC_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2), + RBAC_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5), + // Force SSO to not use the plugin (contributors without the cloud // plugin installed can opt in to a clean OSS-only experience). SSO_FORCE_FALLBACK: BoolEnv.default(false), + + // Per-process pool sizes for an SSO plugin that owns its own database + // client (the fallback queries through Prisma and ignores these). Writes + // are rare config mutations and webhook processing; reads run on the + // login path. + SSO_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2), + SSO_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5), // Emit a console.log when the SSO fallback is selected because no // plugin is installed. Default off so OSS deployments stay quiet. SSO_LOG_FALLBACK: BoolEnv.default(false), diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts index 0e2c0fc7e16..11510c1358f 100644 --- a/apps/webapp/app/services/rbac.server.ts +++ b/apps/webapp/app/services/rbac.server.ts @@ -27,5 +27,18 @@ export const rbac = plugin.create( { primary: prisma, replica: $replica as PrismaClient }, // SESSION_SECRET signs delegated user-actor tokens; the plugin verifies // them with it in authenticateUserActor. - { forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET } + { + forceFallback: env.RBAC_FORCE_FALLBACK, + userActorSecret: env.SESSION_SECRET, + // A plugin that owns its own database client gets the same + // writer/replica topology the webapp's Prisma clients use (see + // getClient/getReplicaClient in db.server.ts): control-plane URLs win, + // and with no replica configured reads share the writer. + database: { + writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL, + readerUrl: env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL, + writerConnectionLimit: env.RBAC_DATABASE_WRITER_CONNECTION_LIMIT, + readerConnectionLimit: env.RBAC_DATABASE_READER_CONNECTION_LIMIT, + }, + } ); diff --git a/apps/webapp/app/services/sso.server.ts b/apps/webapp/app/services/sso.server.ts index 400ff86190e..857bba29010 100644 --- a/apps/webapp/app/services/sso.server.ts +++ b/apps/webapp/app/services/sso.server.ts @@ -18,5 +18,17 @@ export const ssoController = sso.create( // fallback so the entire SSO surface (login, settings, callback, // re-validation) stays inert. SSO_FORCE_FALLBACK remains an // independent contributor/debug override. - { forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK } + { + forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK, + // A plugin that owns its own database client gets the same + // writer/replica topology the webapp's Prisma clients use (see + // getClient/getReplicaClient in db.server.ts): control-plane URLs win, + // and with no replica configured reads share the writer. + database: { + writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL, + readerUrl: env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL, + writerConnectionLimit: env.SSO_DATABASE_WRITER_CONNECTION_LIMIT, + readerConnectionLimit: env.SSO_DATABASE_READER_CONNECTION_LIMIT, + }, + } ); diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 258dc12f3b1..b9ba140ed9e 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -1,6 +1,7 @@ import type { Permission, RbacAbility, + RbacDatabaseConfig, Role, RbacResource, RoleAssignmentResult, @@ -33,6 +34,11 @@ export type RbacCreateOptions = { // Platform secret used to verify delegated user-actor tokens (tr_uat_). // Threaded through to the plugin / fallback's authenticateUserActor. userActorSecret?: string; + // Writer/reader connection URLs + pool sizes for a plugin that owns its + // own database client, resolved by the host from its env so the plugin + // follows the host's writer/replica topology. The fallback ignores this — + // it queries through the Prisma clients passed as `RbacPrismaInput`. + database?: RbacDatabaseConfig; }; // Route actions that historically authorised via the legacy checkAuthorization's @@ -88,7 +94,10 @@ class LazyController implements RoleBaseAccessController { const module = await import(moduleName); const plugin: RoleBasedAccessControlPlugin = module.default; console.log("RBAC: using plugin implementation"); - return plugin.create({ userActorSecret: options?.userActorSecret }); + return plugin.create({ + userActorSecret: options?.userActorSecret, + database: options?.database, + }); } catch (err) { // The dynamic import either succeeded or failed for one of two // distinct reasons. Distinguishing them is critical for debugging diff --git a/internal-packages/sso/src/index.ts b/internal-packages/sso/src/index.ts index 0ec6df3adbd..b329bfaa031 100644 --- a/internal-packages/sso/src/index.ts +++ b/internal-packages/sso/src/index.ts @@ -2,6 +2,7 @@ import type { DirectorySyncEffect, DirectorySyncStatus, OrgSsoStatus, + PluginDatabaseConfig, SsoBeginError, SsoCompleteError, SsoController, @@ -32,6 +33,11 @@ export type SsoCreateOptions = { // module or a synthetic ERR_MODULE_NOT_FOUND failure without touching // the real plugin install on disk. importer?: (moduleName: string) => Promise<{ default: SsoPlugin }>; + // Writer/reader connection URLs + pool sizes for a plugin that owns its + // own database client, resolved by the host from its env so the plugin + // follows the host's writer/replica topology. The fallback ignores this — + // it queries through the Prisma clients passed as `SsoPrismaInput`. + database?: PluginDatabaseConfig; }; // Loads the cloud plugin lazily; falls back to the OSS no-op @@ -55,7 +61,7 @@ export class LazyController implements SsoController { const module = await importer(moduleName); const plugin: SsoPlugin = module.default; console.log("SSO: using plugin implementation"); - return plugin.create(); + return plugin.create({ database: options?.database }); } catch (err) { // Distinguish the two failure modes the dynamic import can hit: // diff --git a/packages/plugins/src/databaseConfig.ts b/packages/plugins/src/databaseConfig.ts new file mode 100644 index 00000000000..3b8324810a8 --- /dev/null +++ b/packages/plugins/src/databaseConfig.ts @@ -0,0 +1,15 @@ +// Database connections for a plugin that owns its own client. The host +// resolves the URLs from its env so the plugin follows the same +// writer/replica topology the host uses. Shared by every plugin contract +// that carries a `database` section. +export type PluginDatabaseConfig = { + // Primary (writer) connection URL. Mutations and read-your-writes + // management reads run here. + writerUrl: string; + // Read-replica URL for the per-request auth reads. Omitted → those reads + // share the writer connection. + readerUrl?: string; + // Per-process pool sizes. Omitted → plugin defaults (writer 2, reader 5). + writerConnectionLimit?: number; + readerConnectionLimit?: number; +}; diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index cef1e3b356f..1c561d7452a 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -16,6 +16,7 @@ export type { UserActorAuthResult, UserActorClaims, RbacPluginConfig, + RbacDatabaseConfig, SystemRole, AuthenticatedEnvironment, } from "./rbac.js"; @@ -28,8 +29,11 @@ export { USER_ACTOR_TOKEN_PREFIX, } from "./rbac.js"; +export type { PluginDatabaseConfig } from "./databaseConfig.js"; + export type { SsoPlugin, + SsoPluginConfig, SsoController, OrgSsoStatus, SsoRouteDecision, diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts index 189252afd58..cd8c2d03f15 100644 --- a/packages/plugins/src/rbac.ts +++ b/packages/plugins/src/rbac.ts @@ -424,14 +424,21 @@ export type RoleMutationResult = { ok: true; role: Role } | { ok: false; error: // Result for assignment / deletion mutations that don't return a value. export type RoleAssignmentResult = { ok: true } | { ok: false; error: string }; +import type { PluginDatabaseConfig } from "./databaseConfig.js"; + // Host-injected configuration the plugin can't read from the environment // itself (the plugin runs in the host's process but owns no env contract). export type RbacPluginConfig = { // Platform secret the host signs user-actor tokens with; the plugin uses // it to verify them in `authenticateUserActor`. Omitted → UAT auth 401s. userActorSecret?: string; + // Database connections for a plugin that owns its own client. Omitted → + // the plugin falls back to its own defaults. + database?: PluginDatabaseConfig; }; +export type { PluginDatabaseConfig as RbacDatabaseConfig } from "./databaseConfig.js"; + export interface RoleBasedAccessControlPlugin { create(config?: RbacPluginConfig): RoleBaseAccessController | Promise; } diff --git a/packages/plugins/src/sso.ts b/packages/plugins/src/sso.ts index fcfed8bdd97..8d53ae101da 100644 --- a/packages/plugins/src/sso.ts +++ b/packages/plugins/src/sso.ts @@ -1,4 +1,5 @@ import type { ResultAsync } from "neverthrow"; +import type { PluginDatabaseConfig } from "./databaseConfig.js"; // === Domain types === @@ -368,6 +369,14 @@ export interface SsoController { ): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError>; } +// Host-injected configuration the plugin can't read from the environment +// itself (the plugin runs in the host's process but owns no env contract). +export type SsoPluginConfig = { + // Database connections for a plugin that owns its own client. Omitted → + // the plugin falls back to its own defaults. + database?: PluginDatabaseConfig; +}; + export interface SsoPlugin { - create(): SsoController | Promise; + create(config?: SsoPluginConfig): SsoController | Promise; }