Skip to content

Commit b64b54c

Browse files
authored
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)
1 parent 25eb0c7 commit b64b54c

9 files changed

Lines changed: 93 additions & 5 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2072,9 +2072,22 @@ const EnvironmentSchema = z
20722072
// Force RBAC to not use the plugin
20732073
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
20742074

2075+
// Per-process pool sizes for an RBAC plugin that owns its own database
2076+
// client (the fallback queries through Prisma and ignores these). Writes
2077+
// are rare role mutations; reads run on the per-request auth hot path.
2078+
RBAC_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
2079+
RBAC_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
2080+
20752081
// Force SSO to not use the plugin (contributors without the cloud
20762082
// plugin installed can opt in to a clean OSS-only experience).
20772083
SSO_FORCE_FALLBACK: BoolEnv.default(false),
2084+
2085+
// Per-process pool sizes for an SSO plugin that owns its own database
2086+
// client (the fallback queries through Prisma and ignores these). Writes
2087+
// are rare config mutations and webhook processing; reads run on the
2088+
// login path.
2089+
SSO_DATABASE_WRITER_CONNECTION_LIMIT: z.coerce.number().int().default(2),
2090+
SSO_DATABASE_READER_CONNECTION_LIMIT: z.coerce.number().int().default(5),
20782091
// Emit a console.log when the SSO fallback is selected because no
20792092
// plugin is installed. Default off so OSS deployments stay quiet.
20802093
SSO_LOG_FALLBACK: BoolEnv.default(false),

apps/webapp/app/services/rbac.server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,18 @@ export const rbac = plugin.create(
2727
{ primary: prisma, replica: $replica as PrismaClient },
2828
// SESSION_SECRET signs delegated user-actor tokens; the plugin verifies
2929
// them with it in authenticateUserActor.
30-
{ forceFallback: env.RBAC_FORCE_FALLBACK, userActorSecret: env.SESSION_SECRET }
30+
{
31+
forceFallback: env.RBAC_FORCE_FALLBACK,
32+
userActorSecret: env.SESSION_SECRET,
33+
// A plugin that owns its own database client gets the same
34+
// writer/replica topology the webapp's Prisma clients use (see
35+
// getClient/getReplicaClient in db.server.ts): control-plane URLs win,
36+
// and with no replica configured reads share the writer.
37+
database: {
38+
writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL,
39+
readerUrl: env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL,
40+
writerConnectionLimit: env.RBAC_DATABASE_WRITER_CONNECTION_LIMIT,
41+
readerConnectionLimit: env.RBAC_DATABASE_READER_CONNECTION_LIMIT,
42+
},
43+
}
3144
);

apps/webapp/app/services/sso.server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,17 @@ export const ssoController = sso.create(
1818
// fallback so the entire SSO surface (login, settings, callback,
1919
// re-validation) stays inert. SSO_FORCE_FALLBACK remains an
2020
// independent contributor/debug override.
21-
{ forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK }
21+
{
22+
forceFallback: !env.SSO_ENABLED || env.SSO_FORCE_FALLBACK,
23+
// A plugin that owns its own database client gets the same
24+
// writer/replica topology the webapp's Prisma clients use (see
25+
// getClient/getReplicaClient in db.server.ts): control-plane URLs win,
26+
// and with no replica configured reads share the writer.
27+
database: {
28+
writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL,
29+
readerUrl: env.CONTROL_PLANE_DATABASE_READ_REPLICA_URL ?? env.DATABASE_READ_REPLICA_URL,
30+
writerConnectionLimit: env.SSO_DATABASE_WRITER_CONNECTION_LIMIT,
31+
readerConnectionLimit: env.SSO_DATABASE_READER_CONNECTION_LIMIT,
32+
},
33+
}
2234
);

internal-packages/rbac/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
Permission,
33
RbacAbility,
4+
RbacDatabaseConfig,
45
Role,
56
RbacResource,
67
RoleAssignmentResult,
@@ -33,6 +34,11 @@ export type RbacCreateOptions = {
3334
// Platform secret used to verify delegated user-actor tokens (tr_uat_).
3435
// Threaded through to the plugin / fallback's authenticateUserActor.
3536
userActorSecret?: string;
37+
// Writer/reader connection URLs + pool sizes for a plugin that owns its
38+
// own database client, resolved by the host from its env so the plugin
39+
// follows the host's writer/replica topology. The fallback ignores this —
40+
// it queries through the Prisma clients passed as `RbacPrismaInput`.
41+
database?: RbacDatabaseConfig;
3642
};
3743

3844
// Route actions that historically authorised via the legacy checkAuthorization's
@@ -88,7 +94,10 @@ class LazyController implements RoleBaseAccessController {
8894
const module = await import(moduleName);
8995
const plugin: RoleBasedAccessControlPlugin = module.default;
9096
console.log("RBAC: using plugin implementation");
91-
return plugin.create({ userActorSecret: options?.userActorSecret });
97+
return plugin.create({
98+
userActorSecret: options?.userActorSecret,
99+
database: options?.database,
100+
});
92101
} catch (err) {
93102
// The dynamic import either succeeded or failed for one of two
94103
// distinct reasons. Distinguishing them is critical for debugging

internal-packages/sso/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
DirectorySyncEffect,
33
DirectorySyncStatus,
44
OrgSsoStatus,
5+
PluginDatabaseConfig,
56
SsoBeginError,
67
SsoCompleteError,
78
SsoController,
@@ -32,6 +33,11 @@ export type SsoCreateOptions = {
3233
// module or a synthetic ERR_MODULE_NOT_FOUND failure without touching
3334
// the real plugin install on disk.
3435
importer?: (moduleName: string) => Promise<{ default: SsoPlugin }>;
36+
// Writer/reader connection URLs + pool sizes for a plugin that owns its
37+
// own database client, resolved by the host from its env so the plugin
38+
// follows the host's writer/replica topology. The fallback ignores this —
39+
// it queries through the Prisma clients passed as `SsoPrismaInput`.
40+
database?: PluginDatabaseConfig;
3541
};
3642

3743
// Loads the cloud plugin lazily; falls back to the OSS no-op
@@ -55,7 +61,7 @@ export class LazyController implements SsoController {
5561
const module = await importer(moduleName);
5662
const plugin: SsoPlugin = module.default;
5763
console.log("SSO: using plugin implementation");
58-
return plugin.create();
64+
return plugin.create({ database: options?.database });
5965
} catch (err) {
6066
// Distinguish the two failure modes the dynamic import can hit:
6167
//
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Database connections for a plugin that owns its own client. The host
2+
// resolves the URLs from its env so the plugin follows the same
3+
// writer/replica topology the host uses. Shared by every plugin contract
4+
// that carries a `database` section.
5+
export type PluginDatabaseConfig = {
6+
// Primary (writer) connection URL. Mutations and read-your-writes
7+
// management reads run here.
8+
writerUrl: string;
9+
// Read-replica URL for the per-request auth reads. Omitted → those reads
10+
// share the writer connection.
11+
readerUrl?: string;
12+
// Per-process pool sizes. Omitted → plugin defaults (writer 2, reader 5).
13+
writerConnectionLimit?: number;
14+
readerConnectionLimit?: number;
15+
};

packages/plugins/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type {
1616
UserActorAuthResult,
1717
UserActorClaims,
1818
RbacPluginConfig,
19+
RbacDatabaseConfig,
1920
SystemRole,
2021
AuthenticatedEnvironment,
2122
} from "./rbac.js";
@@ -28,8 +29,11 @@ export {
2829
USER_ACTOR_TOKEN_PREFIX,
2930
} from "./rbac.js";
3031

32+
export type { PluginDatabaseConfig } from "./databaseConfig.js";
33+
3134
export type {
3235
SsoPlugin,
36+
SsoPluginConfig,
3337
SsoController,
3438
OrgSsoStatus,
3539
SsoRouteDecision,

packages/plugins/src/rbac.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,14 +424,21 @@ export type RoleMutationResult = { ok: true; role: Role } | { ok: false; error:
424424
// Result for assignment / deletion mutations that don't return a value.
425425
export type RoleAssignmentResult = { ok: true } | { ok: false; error: string };
426426

427+
import type { PluginDatabaseConfig } from "./databaseConfig.js";
428+
427429
// Host-injected configuration the plugin can't read from the environment
428430
// itself (the plugin runs in the host's process but owns no env contract).
429431
export type RbacPluginConfig = {
430432
// Platform secret the host signs user-actor tokens with; the plugin uses
431433
// it to verify them in `authenticateUserActor`. Omitted → UAT auth 401s.
432434
userActorSecret?: string;
435+
// Database connections for a plugin that owns its own client. Omitted →
436+
// the plugin falls back to its own defaults.
437+
database?: PluginDatabaseConfig;
433438
};
434439

440+
export type { PluginDatabaseConfig as RbacDatabaseConfig } from "./databaseConfig.js";
441+
435442
export interface RoleBasedAccessControlPlugin {
436443
create(config?: RbacPluginConfig): RoleBaseAccessController | Promise<RoleBaseAccessController>;
437444
}

packages/plugins/src/sso.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ResultAsync } from "neverthrow";
2+
import type { PluginDatabaseConfig } from "./databaseConfig.js";
23

34
// === Domain types ===
45

@@ -368,6 +369,14 @@ export interface SsoController {
368369
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError>;
369370
}
370371

372+
// Host-injected configuration the plugin can't read from the environment
373+
// itself (the plugin runs in the host's process but owns no env contract).
374+
export type SsoPluginConfig = {
375+
// Database connections for a plugin that owns its own client. Omitted →
376+
// the plugin falls back to its own defaults.
377+
database?: PluginDatabaseConfig;
378+
};
379+
371380
export interface SsoPlugin {
372-
create(): SsoController | Promise<SsoController>;
381+
create(config?: SsoPluginConfig): SsoController | Promise<SsoController>;
373382
}

0 commit comments

Comments
 (0)