chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and
|
||||
// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts
|
||||
// dynamic-import this module from inside the loader/action body, so it
|
||||
// never reaches the client bundle.
|
||||
|
||||
import { json, redirect } from "@remix-run/server-runtime";
|
||||
import type { RbacAbility } from "@trigger.dev/rbac";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { permissionDeniedResponse } from "~/utils/permissionDenied";
|
||||
import type { AuthorizationOption, DashboardLoaderOptions, SessionUser } from "./dashboardBuilder";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { z } from "zod";
|
||||
|
||||
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
|
||||
function loginRedirectFor(request: Request, override?: string): Response {
|
||||
if (override) return redirect(override);
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
|
||||
return redirect(`/login?redirectTo=${redirectTo}`);
|
||||
}
|
||||
|
||||
function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean {
|
||||
if ("requireSuper" in authorization) {
|
||||
return ability.canSuper();
|
||||
}
|
||||
return ability.can(authorization.action, authorization.resource);
|
||||
}
|
||||
|
||||
type AuthScope = { organizationId?: string; projectId?: string };
|
||||
|
||||
export async function authenticateAndAuthorize<TParams, TSearchParams, TContext extends AuthScope>(
|
||||
request: Request,
|
||||
rawParams: unknown,
|
||||
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>
|
||||
): Promise<
|
||||
| { ok: false; response: Response }
|
||||
| {
|
||||
ok: true;
|
||||
user: SessionUser;
|
||||
ability: RbacAbility;
|
||||
params: unknown;
|
||||
searchParams: unknown;
|
||||
context: TContext;
|
||||
}
|
||||
> {
|
||||
let parsedParams: any = undefined;
|
||||
if (options.params) {
|
||||
const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
parsedParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedSearchParams: any = undefined;
|
||||
if (options.searchParams) {
|
||||
const fromUrl = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
parsedSearchParams = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = (
|
||||
options.context ? await options.context(parsedParams, request) : ({} as TContext)
|
||||
) as TContext;
|
||||
// Resolve userId from the session cookie *here* (the dashboard
|
||||
// request boundary) and feed it into the rbac plugin context. The
|
||||
// plugin no longer takes a `helpers.getSessionUserId` callback —
|
||||
// statically importing session.server from rbac.server dragged the
|
||||
// entire remix-auth strategy chain (each strategy validates its
|
||||
// secret at module load) into anything that pulled `rbac` in,
|
||||
// including PAT-only callers.
|
||||
const userId = (await getUserId(request)) ?? null;
|
||||
const auth = await rbac.authenticateSession(request, { ...ctx, userId });
|
||||
if (!auth.ok) {
|
||||
if (auth.reason === "unauthenticated") {
|
||||
return { ok: false, response: loginRedirectFor(request, options.loginRedirect) };
|
||||
}
|
||||
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
|
||||
}
|
||||
|
||||
if (options.authorization) {
|
||||
const isSuperGate = "requireSuper" in options.authorization;
|
||||
// Every catalogue resource is org- or project-scoped; requireSuper is the
|
||||
// only global gate. An org/project-scoped check with no resolved scope
|
||||
// would evaluate an unscoped ability, making the authorization a silent
|
||||
// no-op for a missing org. Fail closed instead of relying on the ability
|
||||
// to happen to deny.
|
||||
const hasScope = Boolean(ctx.organizationId || ctx.projectId);
|
||||
const denied = isSuperGate
|
||||
? !isAuthorized(auth.ability, options.authorization)
|
||||
: !hasScope || !isAuthorized(auth.ability, options.authorization);
|
||||
|
||||
if (denied) {
|
||||
// Super-admin gates must not reveal that the route exists, so they
|
||||
// redirect away rather than render the panel. A redirect is also used by
|
||||
// routes that opt in via unauthorizedRedirect (credential endpoints with
|
||||
// no UI).
|
||||
if (options.unauthorizedRedirect || isSuperGate) {
|
||||
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
|
||||
}
|
||||
// Role-based denial: throw a permission-denied 403. Both loader and
|
||||
// action wrappers throw this, so it bubbles to the nearest route
|
||||
// ErrorBoundary, where RouteErrorDisplay renders the permission panel.
|
||||
return { ok: false, response: permissionDeniedResponse(options.authorization.message) };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: auth.user,
|
||||
ability: auth.ability,
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
context: ctx,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Client-safe shim for the dashboard route builder. The actual server
|
||||
// implementation lives in dashboardBuilder.server.ts; the wrappers here
|
||||
// just return closures that lazily import that impl on first invocation.
|
||||
//
|
||||
// Why split: routes use `export const loader = dashboardLoader(...)` at
|
||||
// module top-level. Remix's dev build preserves the top-level call when
|
||||
// resolving the loader export, so the import target needs to exist on
|
||||
// the client even though the closure body never executes there. A
|
||||
// `.server.ts` file is excluded from the client bundle, which would
|
||||
// resolve `dashboardLoader` to undefined and crash with
|
||||
// "dashboardLoader is not a function" on first navigation. Keeping this
|
||||
// file non-`.server` puts the wrappers in the client bundle as
|
||||
// effectively no-op closures (they're never called there), and the
|
||||
// closure body's dynamic import only resolves at server runtime.
|
||||
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
|
||||
import type { z } from "zod";
|
||||
|
||||
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
|
||||
type InferZod<T> = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<T>
|
||||
: undefined;
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
admin: boolean;
|
||||
confirmedBasicDetails: boolean;
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit
|
||||
// action + resource pair is checked via ability.can(...). On failure the
|
||||
// builder throws a permission-denied 403, rendered as the permission panel by
|
||||
// the nearest route ErrorBoundary (RouteErrorDisplay), unless
|
||||
// `unauthorizedRedirect` is set; `message` customizes the panel copy.
|
||||
export type AuthorizationOption =
|
||||
| { requireSuper: true; message?: string }
|
||||
| {
|
||||
action: string;
|
||||
resource: RbacResource | RbacResource[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// Plugin-side scope: whatever the route's `context` returns must include
|
||||
// these (or just be `{}` when the route doesn't scope by org/project).
|
||||
// rbac.authenticateSession reads them off the value to filter UserRole.
|
||||
type AuthScope = { organizationId?: string; projectId?: string };
|
||||
|
||||
export type DashboardLoaderOptions<TParams, TSearchParams, TContext extends AuthScope> = {
|
||||
params?: TParams;
|
||||
searchParams?: TSearchParams;
|
||||
// Resolves any per-request data the handler + auth check both need
|
||||
// (typically org/project lookups from URL params). The returned object
|
||||
// is fed to `rbac.authenticateSession` as the auth scope AND passed
|
||||
// through to the handler in `args.context`, so the route does each
|
||||
// lookup once.
|
||||
context?: (params: InferZod<TParams>, request: Request) => TContext | Promise<TContext>;
|
||||
authorization?: AuthorizationOption;
|
||||
// Where to send unauthenticated requests. Defaults to /login with a
|
||||
// redirectTo back to the original path.
|
||||
loginRedirect?: string;
|
||||
// Where to send users who pass auth but fail the ability check. Defaults
|
||||
// to "/" (the home page).
|
||||
unauthorizedRedirect?: string;
|
||||
};
|
||||
|
||||
export type DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext> = {
|
||||
params: InferZod<TParams>;
|
||||
searchParams: InferZod<TSearchParams>;
|
||||
user: SessionUser;
|
||||
ability: RbacAbility;
|
||||
context: TContext;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
export function dashboardLoader<
|
||||
TParams extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParams extends AnyZodSchema | undefined = undefined,
|
||||
TContext extends AuthScope = AuthScope,
|
||||
TReturn extends Response = Response,
|
||||
>(
|
||||
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>,
|
||||
handler: (args: DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>) => Promise<TReturn>
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs): Promise<TReturn> {
|
||||
// Server-only — see comment at top. Node caches the module after the
|
||||
// first call, so the dynamic import is effectively free past warmup.
|
||||
const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
|
||||
const result = await authenticateAndAuthorize(request, params, options);
|
||||
if (!result.ok) throw result.response;
|
||||
|
||||
return handler({
|
||||
params: result.params as InferZod<TParams>,
|
||||
searchParams: result.searchParams as InferZod<TSearchParams>,
|
||||
user: result.user,
|
||||
ability: result.ability,
|
||||
context: result.context as TContext,
|
||||
request,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardActionOptions<
|
||||
TParams,
|
||||
TSearchParams,
|
||||
TContext extends AuthScope,
|
||||
> = DashboardLoaderOptions<TParams, TSearchParams, TContext>;
|
||||
|
||||
export type DashboardActionHandlerArgs<TParams, TSearchParams, TContext> =
|
||||
DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>;
|
||||
|
||||
export function dashboardAction<
|
||||
TParams extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParams extends AnyZodSchema | undefined = undefined,
|
||||
TContext extends AuthScope = AuthScope,
|
||||
TReturn extends Response = Response,
|
||||
>(
|
||||
options: DashboardActionOptions<TParams, TSearchParams, TContext>,
|
||||
handler: (args: DashboardActionHandlerArgs<TParams, TSearchParams, TContext>) => Promise<TReturn>
|
||||
) {
|
||||
return async function action({ request, params }: ActionFunctionArgs): Promise<TReturn> {
|
||||
const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
|
||||
const result = await authenticateAndAuthorize(request, params, options);
|
||||
if (!result.ok) throw result.response;
|
||||
|
||||
return handler({
|
||||
params: result.params as InferZod<TParams>,
|
||||
searchParams: result.searchParams as InferZod<TSearchParams>,
|
||||
user: result.user,
|
||||
ability: result.ability,
|
||||
context: result.context as TContext,
|
||||
request,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
|
||||
|
||||
/**
|
||||
* A single permission check, mirroring the `authorization` option the
|
||||
* dashboard/api route builders accept: either a super-user check or an
|
||||
* action + resource(s) pair.
|
||||
*/
|
||||
export type PermissionCheck =
|
||||
| { requireSuper: true }
|
||||
| { action: string; resource: RbacResource | RbacResource[] };
|
||||
|
||||
/**
|
||||
* Evaluate a set of permission checks against an already-resolved `ability`
|
||||
* and return a plain boolean map for the client to gate UI on.
|
||||
*
|
||||
* The matching lives entirely in the injected ability — permissive by
|
||||
* default, and fully enforced when an RBAC plugin is installed — so this only
|
||||
* calls `can`/`canSuper` and no permission-model logic lives here. The
|
||||
* returned booleans are display-only: the route builder's `authorization`
|
||||
* block is the real security boundary.
|
||||
*/
|
||||
export function canManageBillingLimits(ability: RbacAbility): boolean {
|
||||
return ability.can("manage", { type: "billing-limits" });
|
||||
}
|
||||
|
||||
export function checkPermissions<K extends string>(
|
||||
ability: RbacAbility,
|
||||
checks: Record<K, PermissionCheck>
|
||||
): Record<K, boolean> {
|
||||
const result = {} as Record<K, boolean>;
|
||||
for (const key in checks) {
|
||||
const check = checks[key];
|
||||
result[key] =
|
||||
"requireSuper" in check ? ability.canSuper() : ability.can(check.action, check.resource);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user