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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user