chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@trigger.dev/rbac",
"private": true,
"version": "0.0.1",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"dependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/plugins": "workspace:*"
},
"devDependencies": {
"@trigger.dev/database": "workspace:*",
"@types/node": "^22.20.0",
"rimraf": "6.0.1"
},
"scripts": {
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration",
"dev": "tsc --noEmit false --outDir dist --declaration --watch",
"test": "vitest run",
"test:watch": "vitest"
}
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from "vitest";
import {
permissiveAbility,
superAbility,
denyAbility,
buildFallbackAbility,
buildJwtAbility,
} from "./ability.js";
describe("permissiveAbility", () => {
it("allows any action on any resource type", () => {
expect(permissiveAbility.can("read", { type: "run" })).toBe(true);
expect(permissiveAbility.can("write", { type: "deployment" })).toBe(true);
expect(permissiveAbility.can("delete", { type: "task" })).toBe(true);
});
it("allows actions on specific resource instances", () => {
expect(permissiveAbility.can("read", { type: "run", id: "run_abc123" })).toBe(true);
});
it("does not grant super-user access", () => {
expect(permissiveAbility.canSuper()).toBe(false);
});
});
describe("superAbility", () => {
it("allows any action on any resource", () => {
expect(superAbility.can("read", { type: "run" })).toBe(true);
expect(superAbility.can("write", { type: "deployment" })).toBe(true);
});
it("grants super-user access", () => {
expect(superAbility.canSuper()).toBe(true);
});
});
describe("denyAbility", () => {
it("denies all actions", () => {
expect(denyAbility.can("read", { type: "run" })).toBe(false);
expect(denyAbility.can("write", { type: "deployment" })).toBe(false);
});
it("does not grant super-user access", () => {
expect(denyAbility.canSuper()).toBe(false);
});
});
describe("buildJwtAbility", () => {
it("allows action matching a general scope", () => {
const ability = buildJwtAbility(["read:runs"]);
expect(ability.can("read", { type: "runs" })).toBe(true);
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
});
it("allows only the specific ID for a scoped permission", () => {
const ability = buildJwtAbility(["read:runs:run_abc"]);
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
expect(ability.can("read", { type: "runs", id: "run_xyz" })).toBe(false);
expect(ability.can("read", { type: "runs" })).toBe(false);
});
it("preserves colons in the resource id (everything after the 2nd colon)", () => {
// Resource ids can contain colons (e.g. user-provided tags like
// `env:staging`). The naive `[a, b, c] = scope.split(":")` form
// truncated `read:tags:env:staging` → scopeId="env" and silently
// mis-matched. Regression coverage for the multi-colon id path.
const ability = buildJwtAbility(["read:tags:env:staging"]);
expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(true);
expect(ability.can("read", { type: "tags", id: "env" })).toBe(false);
expect(ability.can("read", { type: "tags", id: "env:prod" })).toBe(false);
});
it("allows any read with read:all scope", () => {
const ability = buildJwtAbility(["read:all"]);
expect(ability.can("read", { type: "runs" })).toBe(true);
expect(ability.can("read", { type: "tasks" })).toBe(true);
expect(ability.can("write", { type: "runs" })).toBe(false);
});
it("allows everything with admin scope", () => {
const ability = buildJwtAbility(["admin"]);
expect(ability.can("read", { type: "runs" })).toBe(true);
expect(ability.can("write", { type: "deployments" })).toBe(true);
});
// Pre-RBAC, the legacy checkAuthorization string-matched superScopes;
// a scope `admin:sessions` only granted access to routes that
// explicitly listed it. After the JWT-ability split we must not let
// `admin:<anything>` act as a universal wildcard — it should grant
// only the `admin` action against resources of that type.
it("admin:<type> is not a universal wildcard", () => {
const ability = buildJwtAbility(["admin:sessions"]);
expect(ability.can("read", { type: "runs" })).toBe(false);
expect(ability.can("write", { type: "tasks" })).toBe(false);
expect(ability.can("admin", { type: "runs" })).toBe(false);
// But it does grant the admin action on its own type.
expect(ability.can("admin", { type: "sessions" })).toBe(true);
expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true);
});
it("admin:<type>:<id> grants admin action only on that exact resource", () => {
const ability = buildJwtAbility(["admin:sessions:ses_abc"]);
expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true);
expect(ability.can("admin", { type: "sessions", id: "ses_xyz" })).toBe(false);
expect(ability.can("admin", { type: "runs" })).toBe(false);
expect(ability.can("read", { type: "sessions", id: "ses_abc" })).toBe(false);
});
it("never grants canSuper", () => {
expect(buildJwtAbility(["admin"]).canSuper()).toBe(false);
expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false);
expect(buildJwtAbility([]).canSuper()).toBe(false);
});
it("denies everything for empty scopes", () => {
const ability = buildJwtAbility([]);
expect(ability.can("read", { type: "runs" })).toBe(false);
});
it("denies wrong action with general resource scope", () => {
const ability = buildJwtAbility(["read:runs"]);
expect(ability.can("write", { type: "runs" })).toBe(false);
});
});
describe("buildJwtAbility — array resources", () => {
it("authorizes when any resource in the array passes a scope check", () => {
const ability = buildJwtAbility(["read:batch:batch_abc"]);
const resources = [
{ type: "runs", id: "run_xyz" },
{ type: "batch", id: "batch_abc" },
{ type: "tasks", id: "task_other" },
];
expect(ability.can("read", resources)).toBe(true);
});
it("rejects when no resource in the array passes a scope check", () => {
const ability = buildJwtAbility(["read:batch:batch_abc"]);
const resources = [
{ type: "runs", id: "run_xyz" },
{ type: "batch", id: "batch_other" },
{ type: "tasks", id: "task_other" },
];
expect(ability.can("read", resources)).toBe(false);
});
it("empty array never authorizes", () => {
const ability = buildJwtAbility(["read:all"]);
expect(ability.can("read", [])).toBe(false);
});
it("authorizes a single resource via the non-array form (backwards compatible)", () => {
const ability = buildJwtAbility(["read:runs:run_abc"]);
expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
});
});
describe("buildFallbackAbility", () => {
it("returns permissiveAbility for non-admin users", () => {
const ability = buildFallbackAbility(false);
expect(ability.can("read", { type: "run" })).toBe(true);
expect(ability.canSuper()).toBe(false);
});
it("returns superAbility for admin users", () => {
const ability = buildFallbackAbility(true);
expect(ability.can("read", { type: "run" })).toBe(true);
expect(ability.canSuper()).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { RbacAbility } from "@trigger.dev/plugins";
// Scope-string interpretation is shared with any auth plugin via
// @trigger.dev/plugins so a public token decodes identically whoever
// serves the request. Re-exported here so existing importers keep their
// `./ability.js` import.
export { buildJwtAbility } from "@trigger.dev/plugins";
/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
export const permissiveAbility: RbacAbility = {
can: () => true,
canSuper: () => false,
};
/** Platform admin (user.admin = true): can do everything including super-user actions. */
export const superAbility: RbacAbility = {
can: () => true,
canSuper: () => true,
};
/** Deprecated PUBLIC tokens and unauthenticated subjects: denied everything. */
export const denyAbility: RbacAbility = {
can: () => false,
canSuper: () => false,
};
export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
return isAdmin ? superAbility : permissiveAbility;
}
+495
View File
@@ -0,0 +1,495 @@
import type {
Permission,
Role,
RbacEnvironment,
RbacUser,
RbacSubject,
RbacResource,
BearerAuthResult,
PatAuthResult,
SessionAuthResult,
RoleAssignmentResult,
RoleBaseAccessController,
RoleMutationResult,
UserActorAuthResult,
} from "@trigger.dev/plugins";
import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins";
import { createHash } from "node:crypto";
import type { PrismaClient } from "@trigger.dev/database";
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js";
export type FallbackPrismaClients = {
// Used for writes (setUserRole, mutateRole, etc.) and any reads that
// can't tolerate replica lag (currently none on this controller, but
// kept for symmetry with the rest of the webapp).
primary: PrismaClient;
// Used for read-only auth-path queries: bearer-token env lookup,
// PAT lookup, session user lookup. Spreads the high-frequency auth
// load away from the primary, matching what `findEnvironmentByApiKey`
// / `findEnvironmentById` did before this PR.
replica: PrismaClient;
};
// Backwards-compat: a single PrismaClient is treated as both primary
// and replica. Callers that care about replica isolation pass the
// explicit FallbackPrismaClients shape.
type PrismaInput = PrismaClient | FallbackPrismaClients;
function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients {
return "primary" in input ? input : { primary: input, replica: input };
}
export type FallbackOptions = {
// Platform secret for verifying delegated user-actor tokens (tr_uat_).
userActorSecret?: string;
};
export class RoleBaseAccessFallback {
private readonly clients: FallbackPrismaClients;
private readonly options: FallbackOptions;
constructor(prisma: PrismaInput, options?: FallbackOptions) {
this.clients = resolvePrismaClients(prisma);
this.options = options ?? {};
}
create(): RoleBaseAccessFallbackController {
return new RoleBaseAccessFallbackController(this.clients, this.options);
}
}
class RoleBaseAccessFallbackController implements RoleBaseAccessController {
private readonly prisma: PrismaClient; // alias for primary — used by writes
private readonly replica: PrismaClient;
private readonly userActorSecret?: string;
constructor(clients: FallbackPrismaClients, options?: FallbackOptions) {
this.prisma = clients.primary;
this.replica = clients.replica;
this.userActorSecret = options?.userActorSecret;
}
async isUsingPlugin(): Promise<boolean> {
return false;
}
async authenticateBearer(
request: Request,
options?: { allowJWT?: boolean }
): Promise<BearerAuthResult> {
// Deprecated public API keys (`pk_*` minted long before public JWTs
// landed) are intentionally NOT handled here. The legacy
// `findEnvironmentByPublicApiKey` path looked them up via the
// `pkApiKey` column, but that token format hasn't been issued for
// years and no live client should be sending one. Any `pk_*` bearer
// on a route that goes through the apiBuilder now returns 401 —
// public access goes through the JWT path (`isPublicJWT(rawToken)`
// below) instead. The deprecated lookup is still exported from
// `apps/webapp/app/models/runtimeEnvironment.server.ts` for the
// pre-RBAC routes that haven't been migrated, but it's a dead
// code path for any route that uses `createLoaderApiRoute` /
// `createActionApiRoute`.
const rawToken = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" };
if (options?.allowJWT && isPublicJWT(rawToken)) {
const envId = extractJWTSub(rawToken);
if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" };
// Match the include shape of the slim AuthenticatedEnvironment so
// the bridge can use the returned env without a follow-up fetch.
const env = await this.replica.runtimeEnvironment.findFirst({
where: { id: envId },
include: {
project: true,
organization: true,
orgMember: {
select: {
userId: true,
user: { select: { id: true, displayName: true, name: true } },
},
},
parentEnvironment: { select: { id: true, apiKey: true } },
},
});
if (!env || env.project.deletedAt !== null) {
return { ok: false, status: 401, error: "Invalid Public Access Token" };
}
const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey;
const result = await validateJWT(rawToken, signingKey);
if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" };
const scopes = Array.isArray(result.payload.scopes)
? (result.payload.scopes as string[])
: [];
const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined;
const oneTimeUse = result.payload.otu === true;
// A JWT minted from a PAT/UAT exchange stamps `act: { sub: userId }` for
// attribution. Surface it so write handlers can record the acting user.
const act = result.payload.act as { sub?: unknown } | undefined;
const actSub = typeof act?.sub === "string" ? act.sub : undefined;
return {
ok: true,
environment: toAuthenticatedEnvironment(env),
subject: {
type: "publicJWT",
environmentId: env.id,
organizationId: env.organizationId,
projectId: env.projectId,
},
ability: buildJwtAbility(scopes),
jwt: { realtime, oneTimeUse, ...(actSub ? { act: { sub: actSub } } : {}) },
};
}
// PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing
// to a child env keyed by branchName. The customer authenticates
// with the parent's apiKey + an `x-trigger-branch` header. Mirror
// findEnvironmentByApiKey: include the matching child env so the
// pivot below can adopt its identity.
const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch"));
// Match the include shape of the slim AuthenticatedEnvironment so
// the apiBuilder bridge can use the returned env directly without a
// follow-up findEnvironmentById call.
const include = {
project: true,
organization: true,
orgMember: {
select: {
userId: true,
user: { select: { id: true, displayName: true, name: true } },
},
},
parentEnvironment: { select: { id: true, apiKey: true } },
childEnvironments: branchName ? { where: { branchName, archivedAt: null } } : undefined,
} as const;
let env = await this.replica.runtimeEnvironment.findFirst({
where: { apiKey: rawToken },
include,
});
// Revoked API key grace window — mirrors `findEnvironmentByApiKey`
// in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently
// rotated keys keep working until their `expiresAt`; without this
// branch a customer who rotates an env API key gets immediate 401s
// on the new auth path. The PR's e2e suite covers this in
// auth-cross-cutting.e2e.full.test.ts ("revoked key within grace").
if (!env) {
const revoked = await this.replica.revokedApiKey.findFirst({
where: { apiKey: rawToken, expiresAt: { gt: new Date() } },
include: { runtimeEnvironment: { include } },
});
env = revoked?.runtimeEnvironment ?? null;
}
if (!env || env.project.deletedAt !== null) {
return { ok: false, status: 401, error: "Invalid API key" };
}
if (env.type === "PREVIEW" && !branchName) {
return {
ok: false,
status: 401,
error: "x-trigger-branch header required for preview env",
};
}
if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") {
// The "default" root branch is DEVELOPMENT-only: it maps to the dev root env
// (which carries no branch), so we skip the pivot there. For PREVIEW,
// "default" is an ordinary branch name and must still pivot to its child.
const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName);
if (branchName !== null && !isDevAndDefault) {
const child = env.childEnvironments?.[0];
if (!child) {
return { ok: false, status: 401, error: "No matching branch env" };
}
// Pivot to the child env: child's id/type/branchName, parent's
// apiKey/orgMember/organization/project. parentEnvironment is set
// explicitly here so the slim shape stays internally consistent.
env = {
...child,
apiKey: env.apiKey,
orgMember: env.orgMember,
organization: env.organization,
project: env.project,
parentEnvironment: { id: env.id, apiKey: env.apiKey },
childEnvironments: [],
};
}
}
const subject: RbacSubject = {
type: "user",
userId: env.orgMember?.userId ?? "",
organizationId: env.organizationId,
projectId: env.projectId,
};
return {
ok: true,
environment: toAuthenticatedEnvironment(env),
subject,
ability: permissiveAbility,
};
}
async authenticateSession(
_request: Request,
context: { userId: string | null; organizationId?: string; projectId?: string }
): Promise<SessionAuthResult> {
if (!context.userId) return { ok: false, reason: "unauthenticated" };
const user = await this.replica.user.findFirst({ where: { id: context.userId } });
if (!user) return { ok: false, reason: "unauthenticated" };
const subject: RbacSubject = {
type: "user",
userId: user.id,
organizationId: context.organizationId ?? "",
projectId: context.projectId,
};
return {
ok: true,
user: toRbacUser(user),
subject,
ability: buildFallbackAbility(user.admin),
};
}
async authenticateAuthorizeBearer(
request: Request,
check: { action: string; resource: RbacResource | RbacResource[] },
options?: { allowJWT?: boolean }
): Promise<BearerAuthResult> {
const auth = await this.authenticateBearer(request, options);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false, status: 403, error: "Unauthorized" };
}
return auth;
}
async authenticateAuthorizeSession(
request: Request,
context: { userId: string | null; organizationId?: string; projectId?: string },
check: { action: string; resource: RbacResource | RbacResource[] }
): Promise<SessionAuthResult> {
const auth = await this.authenticateSession(request, context);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false, reason: "unauthorized" };
}
return auth;
}
async authenticatePat(
request: Request,
context: { organizationId?: string; projectId?: string }
): Promise<PatAuthResult> {
const rawToken = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
if (!rawToken || !rawToken.startsWith("tr_pat_")) {
return { ok: false, status: 401, error: "Invalid or Missing PAT" };
}
const hashedToken = createHash("sha256").update(rawToken).digest("hex");
const pat = await this.replica.personalAccessToken.findFirst({
where: { hashedToken, revokedAt: null },
// Include `lastAccessedAt` so the host can throttle its own write
// (see `PatAuthResult.lastAccessedAt` jsdoc). Without this the host
// would need a second findFirst just to decide whether to fire the
// updateMany, turning 1 DB roundtrip into 2.
select: { id: true, userId: true, lastAccessedAt: true },
});
if (!pat) {
return { ok: false, status: 401, error: "Invalid PAT" };
}
return {
ok: true,
tokenId: pat.id,
userId: pat.userId,
lastAccessedAt: pat.lastAccessedAt,
subject: {
type: "personalAccessToken",
tokenId: pat.id,
organizationId: context.organizationId ?? "",
projectId: context.projectId,
},
// No plugin → no role lookup. PATs in the OSS world are pure
// user-identity tokens; the route's own authorization block (or
// the absence of one) decides what they can do, same as it did
// before this method existed.
ability: permissiveAbility,
};
}
async authenticateUserActor(
request: Request,
context: { organizationId?: string; projectId?: string }
): Promise<UserActorAuthResult> {
const rawToken = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
if (!rawToken || !isUserActorToken(rawToken)) {
return { ok: false, status: 401, error: "Invalid or Missing user-actor token" };
}
if (!this.userActorSecret) {
return { ok: false, status: 401, error: "User-actor tokens are not configured" };
}
const claims = await verifyUserActorToken(this.userActorSecret, rawToken);
if (!claims) {
return { ok: false, status: 401, error: "Invalid user-actor token" };
}
return {
ok: true,
userId: claims.userId,
subject: {
type: "userActor",
userId: claims.userId,
client: claims.client,
organizationId: context.organizationId ?? "",
projectId: context.projectId,
},
// No plugin → permissive, matching the fallback's PAT behaviour.
ability: permissiveAbility,
};
}
async systemRoles(_organizationId: string) {
// No plugin installed → no seeded roles. Callers handle null by
// hiding role-picker UI / skipping role assignment writes.
return null;
}
async allPermissions(): Promise<Permission[]> {
return [];
}
async allRoles(): Promise<Role[]> {
return [];
}
// Permissive — the default fallback applies no gating. The Teams
// page UI uses this to decide which role options to render as
// disabled; with no plugin installed allRoles() returns [] anyway,
// so the practical effect is "no roles to gate".
async getAssignableRoleIds(): Promise<string[]> {
return [];
}
async createRole(): Promise<RoleMutationResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async updateRole(): Promise<RoleMutationResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async deleteRole(): Promise<RoleAssignmentResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async getUserRole(): Promise<Role | null> {
return null;
}
async getUserRoles(userIds: string[]): Promise<Map<string, Role | null>> {
return new Map(userIds.map((id) => [id, null]));
}
async setUserRole(): Promise<RoleAssignmentResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async removeUserRole(): Promise<RoleAssignmentResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async getTokenRole(): Promise<Role | null> {
return null;
}
async setTokenRole(): Promise<RoleAssignmentResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
async removeTokenRole(): Promise<RoleAssignmentResult> {
return { ok: false, error: "RBAC plugin not installed" };
}
}
function isPublicJWT(token: string): boolean {
const parts = token.split(".");
if (parts.length !== 3) return false;
try {
const payload = JSON.parse(
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
);
return payload !== null && typeof payload === "object" && payload.pub === true;
} catch {
return false;
}
}
function extractJWTSub(token: string): string | undefined {
const parts = token.split(".");
if (parts.length !== 3) return undefined;
try {
const payload = JSON.parse(
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
);
return payload !== null && typeof payload === "object" && typeof payload.sub === "string"
? payload.sub
: undefined;
} catch {
return undefined;
}
}
// Coerce a Prisma RuntimeEnvironment payload (with project/organization/
// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment
// the auth contract carries. The slim type accepts both `number` and
// Decimal-like for `concurrencyLimitBurstFactor`, but explicit coercion
// here keeps the value a plain number across the auth boundary so
// downstream consumers don't have to narrow before doing arithmetic.
function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment {
const burst = env.concurrencyLimitBurstFactor;
return {
...env,
concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(),
};
}
function toRbacUser(user: {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
admin: boolean;
confirmedBasicDetails: boolean;
}): RbacUser {
return {
id: user.id,
email: user.email,
name: user.name,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
admin: user.admin,
confirmedBasicDetails: user.confirmedBasicDetails,
isImpersonating: false,
};
}
+302
View File
@@ -0,0 +1,302 @@
import type {
Permission,
RbacAbility,
RbacDatabaseConfig,
Role,
RbacResource,
RoleAssignmentResult,
RoleBaseAccessController,
RoleBasedAccessControlPlugin,
RoleMutationResult,
} from "@trigger.dev/plugins";
import type { PrismaClient } from "@trigger.dev/database";
import { RoleBaseAccessFallback } from "./fallback.js";
export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
export type { UserActorAuthResult, UserActorClaims } from "@trigger.dev/plugins";
// Re-export the user-actor token grammar so the webapp mints/checks tokens
// through @trigger.dev/rbac (it doesn't import @trigger.dev/plugins directly).
export {
isUserActorToken,
signUserActorToken,
verifyUserActorToken,
USER_ACTOR_TOKEN_PREFIX,
} from "@trigger.dev/plugins";
// Either a single PrismaClient (used for both writes and reads — fine
// for callers that don't have a separate replica), or `{primary, replica}`
// where reads on the auth hot path go to the replica. The fallback
// reads on every request, so callers with a replica should pass both.
export type RbacPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient };
export type RbacCreateOptions = {
// When true, skip loading the plugin, useful for tests
forceFallback?: boolean;
// 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
// superScopes escape hatch — e.g. a JWT with scope "write:tasks" was accepted by
// a route with action: "trigger" because "write:tasks" was listed in the route's
// superScopes array. The new ability model matches scope-action strictly, so we
// restore the prior semantic here: when the underlying ability denies for action
// X, retry with each aliased action.
const ACTION_ALIASES: Record<string, readonly string[]> = {
trigger: ["write"],
batchTrigger: ["write"],
update: ["write"],
};
export function withActionAliases(underlying: RbacAbility): RbacAbility {
return {
can(action: string, resource: RbacResource | RbacResource[]): boolean {
if (underlying.can(action, resource)) return true;
const aliases = ACTION_ALIASES[action] ?? [];
return aliases.some((a) => underlying.can(a, resource));
},
canSuper: () => underlying.canSuper(),
};
}
// Loads the plugin lazily; falls back to the fallback implementation if not installed.
// Synchronous create() avoids top-level await (not supported in the webapp's CJS build).
class LazyController implements RoleBaseAccessController {
private readonly _init: Promise<RoleBaseAccessController>;
constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) {
this._init = this.load(prisma, options);
// load() runs eagerly but the result is awaited lazily on first method
// call. If load() rejects (e.g. REQUIRE_PLUGINS=1 + plugin missing) and
// nothing awaits _init before Node ticks past, the rejection surfaces
// as unhandledRejection and kills the process. Attach a no-op .catch
// so Node sees the rejection as handled; the error is re-thrown when
// any consumer awaits this._init via c().
this._init.catch(() => {});
}
private async load(
prisma: RbacPrismaInput,
options?: RbacCreateOptions
): Promise<RoleBaseAccessController> {
if (options?.forceFallback) {
return new RoleBaseAccessFallback(prisma, {
userActorSecret: options?.userActorSecret,
}).create();
}
const moduleName = "@triggerdotdev/plugins/rbac";
try {
const module = await import(moduleName);
const plugin: RoleBasedAccessControlPlugin = module.default;
console.log("RBAC: using plugin implementation");
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
// — silently swallowing the error here is what produced "why is
// the fallback being used?" mysteries before.
//
// 1. The plugin itself is absent (no install) — expected.
// Logged at info level only when RBAC_LOG_FALLBACK=1 so
// production logs stay quiet.
// 2. Anything else (transitive dep missing, init error, syntax
// error in the plugin's dist, etc.) — a real bug. Always
// logged loudly so it surfaces in CI / production logs.
//
// Node throws ERR_MODULE_NOT_FOUND for both cases — the *plugin*
// module being absent and a *transitive* dep of the plugin
// being absent. Disambiguate by checking whether the missing
// specifier in the error message is the plugin's own moduleName.
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const message = err instanceof Error ? err.message : String(err);
const isModuleNotFound = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
const isPluginItselfMissing = isModuleNotFound && message.includes(moduleName);
if (!isPluginItselfMissing) {
// Either the error wasn't a missing-module error at all, or the
// plugin was found but a transitive dep failed to resolve.
// Either way: a real problem worth surfacing.
console.error(
"RBAC: plugin found but failed to load; falling back to default implementation",
err
);
} else if (process.env.RBAC_LOG_FALLBACK === "1") {
console.log("RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback");
}
// Fail-fast for deployments that require plugins to be present. Set
// REQUIRE_PLUGINS=1 in environments where the fallback is not an
// acceptable degraded state — the throw surfaces on the first method
// call on the lazy controller (e.g. via the webapp's /healthcheck
// route), so the rollout's readiness probe fails and the deploy is
// rolled back. Self-hosters leave REQUIRE_PLUGINS unset and continue
// to use the fallback when no plugin is installed.
if (process.env.REQUIRE_PLUGINS === "1") {
throw new Error(`REQUIRE_PLUGINS=1 but plugin "${moduleName}" did not load: ${message}`);
}
return new RoleBaseAccessFallback(prisma, {
userActorSecret: options?.userActorSecret,
}).create();
}
}
private async c(): Promise<RoleBaseAccessController> {
return this._init;
}
async isUsingPlugin(): Promise<boolean> {
return (await this.c()).isUsingPlugin();
}
async authenticateBearer(...args: Parameters<RoleBaseAccessController["authenticateBearer"]>) {
const result = await (await this.c()).authenticateBearer(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
async authenticateSession(...args: Parameters<RoleBaseAccessController["authenticateSession"]>) {
const result = await (await this.c()).authenticateSession(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
// Don't delegate to the underlying Authorize variants — that would run the
// inline ability check against the unwrapped ability. Use our wrapped
// authenticate* and do the ability check here instead.
async authenticateAuthorizeBearer(
request: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[0],
check: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[1],
options?: Parameters<RoleBaseAccessController["authenticateAuthorizeBearer"]>[2]
) {
const auth = await this.authenticateBearer(request, options);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false as const, status: 403 as const, error: "Unauthorized" };
}
return auth;
}
async authenticateAuthorizeSession(
request: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[0],
context: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[1],
check: Parameters<RoleBaseAccessController["authenticateAuthorizeSession"]>[2]
) {
const auth = await this.authenticateSession(request, context);
if (!auth.ok) return auth;
if (!auth.ability.can(check.action, check.resource)) {
return { ok: false as const, reason: "unauthorized" as const };
}
return auth;
}
async authenticatePat(...args: Parameters<RoleBaseAccessController["authenticatePat"]>) {
const result = await (await this.c()).authenticatePat(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
async authenticateUserActor(
...args: Parameters<RoleBaseAccessController["authenticateUserActor"]>
) {
const result = await (await this.c()).authenticateUserActor(...args);
return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
}
async systemRoles(...args: Parameters<RoleBaseAccessController["systemRoles"]>) {
return (await this.c()).systemRoles(...args);
}
async allPermissions(
...args: Parameters<RoleBaseAccessController["allPermissions"]>
): Promise<Permission[]> {
return (await this.c()).allPermissions(...args);
}
async allRoles(...args: Parameters<RoleBaseAccessController["allRoles"]>): Promise<Role[]> {
return (await this.c()).allRoles(...args);
}
async getAssignableRoleIds(
...args: Parameters<RoleBaseAccessController["getAssignableRoleIds"]>
): Promise<string[]> {
return (await this.c()).getAssignableRoleIds(...args);
}
async createRole(
...args: Parameters<RoleBaseAccessController["createRole"]>
): Promise<RoleMutationResult> {
return (await this.c()).createRole(...args);
}
async updateRole(
...args: Parameters<RoleBaseAccessController["updateRole"]>
): Promise<RoleMutationResult> {
return (await this.c()).updateRole(...args);
}
async deleteRole(
...args: Parameters<RoleBaseAccessController["deleteRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).deleteRole(...args);
}
async getUserRole(
...args: Parameters<RoleBaseAccessController["getUserRole"]>
): Promise<Role | null> {
return (await this.c()).getUserRole(...args);
}
async getUserRoles(
...args: Parameters<RoleBaseAccessController["getUserRoles"]>
): Promise<Map<string, Role | null>> {
return (await this.c()).getUserRoles(...args);
}
async setUserRole(
...args: Parameters<RoleBaseAccessController["setUserRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).setUserRole(...args);
}
async removeUserRole(
...args: Parameters<RoleBaseAccessController["removeUserRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).removeUserRole(...args);
}
async getTokenRole(
...args: Parameters<RoleBaseAccessController["getTokenRole"]>
): Promise<Role | null> {
return (await this.c()).getTokenRole(...args);
}
async setTokenRole(
...args: Parameters<RoleBaseAccessController["setTokenRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).setTokenRole(...args);
}
async removeTokenRole(
...args: Parameters<RoleBaseAccessController["removeTokenRole"]>
): Promise<RoleAssignmentResult> {
return (await this.c()).removeTokenRole(...args);
}
}
class RoleBaseAccess {
// Synchronous — returns a lazy controller that resolves any installed
// plugin on first call.
create(prisma: RbacPrismaInput, options?: RbacCreateOptions): RoleBaseAccessController {
return new LazyController(prisma, options);
}
}
const loader = new RoleBaseAccess();
export default loader;
+69
View File
@@ -0,0 +1,69 @@
import type { RbacAbility } from "@trigger.dev/plugins";
import { describe, expect, it } from "vitest";
import { buildJwtAbility } from "./ability.js";
import { withActionAliases } from "./index.js";
describe("withActionAliases", () => {
it("direct action match passes through unchanged", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
expect(ability.can("write", { type: "tasks", id: "task_x" })).toBe(true);
});
it("trigger action is satisfied by a write:tasks scope (alias retry)", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
});
it("batchTrigger action is satisfied by a write:tasks scope (alias retry)", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
});
it("update action is satisfied by a write:prompts scope (alias retry)", () => {
const ability = withActionAliases(buildJwtAbility(["write:prompts"]));
expect(ability.can("update", { type: "prompts", id: "p_x" })).toBe(true);
});
it("id-scoped write scope satisfies the aliased action on matching id", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
});
it("id-scoped write scope denies the aliased action on a different id", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
expect(ability.can("trigger", { type: "tasks", id: "task_other" })).toBe(false);
});
it("read scope does not satisfy a trigger action (aliases are write-only)", () => {
const ability = withActionAliases(buildJwtAbility(["read:tasks"]));
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(false);
});
it("non-aliased custom action only matches its direct action scope", () => {
const ability = withActionAliases(buildJwtAbility(["read:runs"]));
expect(ability.can("someOtherAction", { type: "runs", id: "run_x" })).toBe(false);
});
it("admin scope continues to grant everything regardless of aliases", () => {
const ability = withActionAliases(buildJwtAbility(["admin"]));
expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
expect(ability.can("anything", { type: "whatever", id: "x" })).toBe(true);
});
it("array resource form: alias retry applies when any element passes", () => {
const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
const resources = [
{ type: "tasks", id: "task_other" },
{ type: "tasks", id: "task_x" },
];
expect(ability.can("trigger", resources)).toBe(true);
});
it("canSuper is delegated unchanged", () => {
const allowSuper: RbacAbility = { can: () => false, canSuper: () => true };
const denySuper: RbacAbility = { can: () => false, canSuper: () => false };
expect(withActionAliases(allowSuper).canSuper()).toBe(true);
expect(withActionAliases(denySuper).canSuper()).toBe(false);
});
});
@@ -0,0 +1,44 @@
import type { PrismaClient } from "@trigger.dev/database";
import { afterEach, describe, expect, it, vi } from "vitest";
import loader from "./index.js";
// The plugin module `@triggerdotdev/plugins/rbac` is not installed in this
// repo (it lives in the cloud monorepo), so a real dynamic import inside
// the loader will reliably fail with ERR_MODULE_NOT_FOUND. These tests
// exercise the loader's branching on that natural failure — no module
// mocking required.
// The fallback's isUsingPlugin() returns false synchronously without
// touching prisma, so a placeholder client is fine for tests that only
// drive the loader path.
const prismaPlaceholder = {} as unknown as PrismaClient;
describe("LazyController plugin loading", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("falls back silently when REQUIRE_PLUGINS is unset and the plugin is missing", async () => {
vi.stubEnv("REQUIRE_PLUGINS", "");
const controller = loader.create(prismaPlaceholder);
await expect(controller.isUsingPlugin()).resolves.toBe(false);
});
it("throws when REQUIRE_PLUGINS=1 and the plugin is missing", async () => {
vi.stubEnv("REQUIRE_PLUGINS", "1");
const controller = loader.create(prismaPlaceholder);
await expect(controller.isUsingPlugin()).rejects.toThrow(/REQUIRE_PLUGINS=1/);
});
it("forceFallback wins over REQUIRE_PLUGINS=1 (so tests inheriting the env aren't broken)", async () => {
vi.stubEnv("REQUIRE_PLUGINS", "1");
const controller = loader.create(prismaPlaceholder, { forceFallback: true });
await expect(controller.isUsingPlugin()).resolves.toBe(false);
});
it("treats any non-'1' REQUIRE_PLUGINS value as unset (must be exactly '1' to enforce)", async () => {
vi.stubEnv("REQUIRE_PLUGINS", "true");
const controller = loader.create(prismaPlaceholder);
await expect(controller.isUsingPlugin()).resolves.toBe(false);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2019",
"lib": ["ES2019", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"customConditions": ["@triggerdotdev/source"]
},
"exclude": ["node_modules"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["**/*.test.ts"],
globals: true,
isolate: true,
testTimeout: 10_000,
},
});