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; }