feat(webapp): pass database writer and reader config to auth plugins (#4229)

## Summary

The RBAC and SSO auth plugins can own their own database client, but
they could only read `DATABASE_URL`, so every connection they opened
landed on the primary. The host webapp now resolves writer and
read-replica URLs from its env (the same fallback chain its own Prisma
clients use: control-plane URL first, then the default) and passes them
to the plugins at create time via a shared `PluginDatabaseConfig`, along
with separate connection limits for writes (default 2) and reads
(default 5, tunable via `RBAC_DATABASE_*_CONNECTION_LIMIT` and
`SSO_DATABASE_*_CONNECTION_LIMIT`).

A plugin can then route hot-path reads (per-request auth checks, login
routing) to the read replica and keep only rare mutations on the
primary. With no replica configured, or no plugin installed, nothing
changes: the OSS fallback ignores the new option and keeps reading
through the Prisma clients it is already given.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Matt Aitken
2026-07-10 17:25:42 +01:00
committed by GitHub
parent 25eb0c71a0
commit b64b54c74e
9 changed files with 93 additions and 5 deletions
+13
View File
@@ -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),
+14 -1
View File
@@ -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,
},
}
);
+13 -1
View File
@@ -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,
},
}
);
+10 -1
View File
@@ -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
+7 -1
View File
@@ -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:
//
+15
View File
@@ -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;
};
+4
View File
@@ -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,
+7
View File
@@ -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<RoleBaseAccessController>;
}
+10 -1
View File
@@ -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<SsoController>;
create(config?: SsoPluginConfig): SsoController | Promise<SsoController>;
}