chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user