Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
15 changes: 14 additions & 1 deletion apps/webapp/app/services/rbac.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
);
14 changes: 13 additions & 1 deletion apps/webapp/app/services/sso.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
);
11 changes: 10 additions & 1 deletion internal-packages/rbac/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
Permission,
RbacAbility,
RbacDatabaseConfig,
Role,
RbacResource,
RoleAssignmentResult,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion internal-packages/sso/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
DirectorySyncEffect,
DirectorySyncStatus,
OrgSsoStatus,
PluginDatabaseConfig,
SsoBeginError,
SsoCompleteError,
SsoController,
Expand Down Expand Up @@ -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
Expand All @@ -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:
//
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/src/databaseConfig.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/plugins/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
UserActorAuthResult,
UserActorClaims,
RbacPluginConfig,
RbacDatabaseConfig,
SystemRole,
AuthenticatedEnvironment,
} from "./rbac.js";
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/src/rbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
11 changes: 10 additions & 1 deletion packages/plugins/src/sso.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ResultAsync } from "neverthrow";
import type { PluginDatabaseConfig } from "./databaseConfig.js";

// === Domain types ===

Expand Down Expand Up @@ -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>;
}
Loading