Skip to content

Commit

Permalink
feat: improve type safety for expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
LucianBuzzo committed Feb 26, 2024
1 parent c4af5b4 commit 16b2a0f
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 52 deletions.
1 change: 1 addition & 0 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ jobs:
node-version: 16
- run: npm i
- run: npm test
- run: npm test:types
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc",
"test": "rome ci src test",
"lint:fix": "rome check --apply src test",
"test:types": "tsc --noEmit",
"test:integration": "jest --runInBand test/integration",
"test:compose:integration": "docker compose -f docker-compose.yml --profile with-sut up db sut --exit-code-from sut",
"setup": "prisma generate && prisma migrate dev",
Expand All @@ -30,8 +31,9 @@
"uuid": "^9.0.0"
},
"dependencies": {
"lodash": "^4.17.21",
"node-sql-parser": "^4.12.0",
"lodash": "^4.17.21"
"type-fest": "^4.10.3"
},
"peerDependencies": {
"@prisma/client": "^5.0.0",
Expand Down
30 changes: 22 additions & 8 deletions src/expressions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { PrismaClient } from "@prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
import random from "lodash/random";
import matches from "lodash/matches";
import { Parser } from "node-sql-parser";
import { AsyncReturnType } from "type-fest";
import { escapeLiteral } from "./escape";
import { defineDmmfProperty } from "@prisma/client/runtime/library";
import { jsonb_array_elements_text } from "./ast-fragments";
Expand Down Expand Up @@ -31,16 +32,23 @@ type Token = {
};
type Tokens = Record<string, Token>;

export type Expression<ContextKeys extends string = string> =
type FFMeta<M extends Prisma.ModelName> = PrismaClient[Uncapitalize<M>]["findFirst"];
type ModelWhereArgs<M extends Prisma.ModelName> = Exclude<Parameters<FFMeta<M>>["0"], undefined>["where"];
type ModelResult<M extends Prisma.ModelName> = AsyncReturnType<FFMeta<M>>;
type NonNullableModelResult<M extends Prisma.ModelName> = Exclude<ModelResult<M>, null>;

// The expression below explicitly excludes returning a client query for the model the expression is for, as this can create infinite loops as the access logic recurses
export type Expression<ContextKeys extends string, M extends Prisma.ModelName> =
| string
| ((
client: PrismaClient,
// Explicitly return any, so that the prisma client doesn't error
row: (col: string) => any,
row: <K extends keyof NonNullableModelResult<M>>(col: K) => NonNullableModelResult<M>[K],
// TODO infer the return type of the context function automatically
context: (key: ContextKeys) => string,
) => Promise<any> | { [col: string]: any });
) => Promise<ModelResult<Exclude<Prisma.ModelName, M>>> | ModelWhereArgs<M>);

const expressionRowName = (col: string) => `___yates_row_${col}`;
const expressionRowName = (col: any) => `___yates_row_${col}`;
const expressionContext = (context: string) => `___yates_context_${context}`;
// Generate a big 32bit signed integer to use as an ID
const getLargeRandomInt = () => random(1000000000, 2147483647);
Expand Down Expand Up @@ -265,7 +273,10 @@ const tokenizeWhereExpression = (
};
};

export const expressionToSQL = async (getExpression: Expression, table: string): Promise<string> => {
export const expressionToSQL = async <ContextKeys extends string, YModel extends Prisma.ModelName>(
getExpression: Expression<ContextKeys, YModel>,
table: string,
): Promise<string> => {
if (typeof getExpression === "string") {
return getExpression;
}
Expand Down Expand Up @@ -308,12 +319,15 @@ export const expressionToSQL = async (getExpression: Expression, table: string):
async (resolve, reject) => {
const rawExpression = getExpression(
expressionClient as any as PrismaClient,
expressionRowName,
expressionRowName as any,
expressionContext,
);
// If the raw expression is a promise, then this is a client subselect,
// as opposed to a plain SQL expression or "where" object
const isSubselect = typeof rawExpression === "object" && typeof rawExpression.then === "function";
const isSubselect =
typeof rawExpression === "object" &&
"then" in rawExpression &&
typeof (rawExpression as Promise<any>).then === "function";

baseClient.$on("query", (e: any) => {
try {
Expand Down
71 changes: 45 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Expression, expressionToSQL, RuntimeDataModel } from "./expressions";

const VALID_OPERATIONS = ["SELECT", "UPDATE", "INSERT", "DELETE"] as const;

type Operation = typeof VALID_OPERATIONS[number];
type Operation = (typeof VALID_OPERATIONS)[number];
export type Models = Prisma.ModelName;

interface ClientOptions {
Expand All @@ -18,18 +18,26 @@ interface ClientOptions {
txTimeout?: number;
}

export interface Ability<ContextKeys extends string = string> {
export interface Ability<ContextKeys extends string, M extends Models> {
description?: string;
expression?: Expression<ContextKeys>;
expression?: Expression<ContextKeys, M>;
operation: Operation;
model?: Models;
model?: M;
slug?: string;
}

// This creates a union type of all possible abilities for a given set of models
export type AllAbilities<ContextKeys extends string, YModels extends Models> = {
[model in YModels]: Ability<ContextKeys, model>;
}[YModels];

type CRUDOperations = "read" | "create" | "update" | "delete";
export type DefaultAbilities = { [Model in Models]: { [op in CRUDOperations]: Ability } };
export type CustomAbilities<ContextKeys extends string = string> = {
[model in Models]?: {
[op in string]?: Ability<ContextKeys>;
export type DefaultAbilities<ContextKeys extends string = string, YModels extends Models = Models> = {
[Model in YModels]: { [op in CRUDOperations]: Ability<ContextKeys, Model> };
};
export type CustomAbilities<ContextKeys extends string = string, YModels extends Models = Models> = {
[model in YModels]?: {
[op in string]?: Ability<ContextKeys, model>;
};
};

Expand Down Expand Up @@ -69,7 +77,11 @@ const hashWithPrefix = (prefix: string, abilityName: string) => {
};

// Sanitize a single string by ensuring the it has only lowercase alpha characters and underscores
const sanitizeSlug = (slug: string) => slug.toLowerCase().replace("-", "_").replace(/[^a-z0-9_]/gi, "");
const sanitizeSlug = (slug: string) =>
slug
.toLowerCase()
.replace("-", "_")
.replace(/[^a-z0-9_]/gi, "");

export const createAbilityName = (model: string, ability: string) => {
return sanitizeSlug(hashWithPrefix("yates_ability_", `${model}_${ability}`));
Expand Down Expand Up @@ -184,12 +196,12 @@ export const createClient = (prisma: PrismaClient, getContext: GetContextFn, opt
return client;
};

const setRLS = async (
const setRLS = async <ContextKeys extends string, YModel extends Models>(
prisma: PrismaClient,
table: string,
roleName: string,
operation: Operation,
rawExpression: Expression,
rawExpression: Expression<ContextKeys, YModel>,
) => {
let expression = await expressionToSQL(rawExpression, table);

Expand Down Expand Up @@ -223,15 +235,20 @@ const setRLS = async (
}
};

export const createRoles = async <K extends CustomAbilities = CustomAbilities, T = DefaultAbilities & K>({
export const createRoles = async <
ContextKeys extends string,
YModels extends Models,
K extends CustomAbilities = CustomAbilities,
T = DefaultAbilities<ContextKeys, YModels> & K,
>({
prisma,
customAbilities,
getRoles,
}: {
prisma: PrismaClient;
customAbilities?: Partial<K>;
getRoles: (abilities: T) => {
[key: string]: Ability[] | "*";
[role: string]: AllAbilities<ContextKeys, YModels>[] | "*";
};
}) => {
const abilities: Partial<DefaultAbilities> = {};
Expand All @@ -254,28 +271,28 @@ export const createRoles = async <K extends CustomAbilities = CustomAbilities, T
description: `Create ${model}`,
expression: "true",
operation: "INSERT",
model,
model: model as any,
slug: "create",
},
read: {
description: `Read ${model}`,
expression: "true",
operation: "SELECT",
model,
model: model as any,
slug: "read",
},
update: {
description: `Update ${model}`,
expression: "true",
operation: "UPDATE",
model,
model: model as any,
slug: "update",
},
delete: {
description: `Delete ${model}`,
expression: "true",
operation: "DELETE",
model,
model: model as any,
slug: "delete",
},
};
Expand All @@ -286,7 +303,7 @@ export const createRoles = async <K extends CustomAbilities = CustomAbilities, T
abilities[model]![ability as CRUDOperations] = {
...customAbilities[model]![ability],
operation,
model,
model: model as any,
slug: ability,
};
}
Expand Down Expand Up @@ -334,12 +351,12 @@ export const createRoles = async <K extends CustomAbilities = CustomAbilities, T
]);

if (ability.expression) {
await setRLS(prisma, table, roleName, ability.operation, ability.expression);
await setRLS(prisma, table, roleName, ability.operation, ability.expression as any);
}
}
}

// For each of the Cortex roles, create a role in the database and grant it the relevant permissions.
// For each of the given roles, create a role in the database and grant it the relevant permissions.
// By defining each permission as a seperate role, we can GRANT them to the user role here, re-using them.
// It's not possible to dynamically GRANT these to a shared user role, as the GRANT is not isolated per transaction and leads to broken permissions.
for (const key in roles) {
Expand Down Expand Up @@ -403,7 +420,8 @@ export const createRoles = async <K extends CustomAbilities = CustomAbilities, T

export interface SetupParams<
ContextKeys extends string = string,
K extends CustomAbilities<ContextKeys> = CustomAbilities<ContextKeys>,
YModels extends Models = Models,
K extends CustomAbilities<ContextKeys, YModels> = CustomAbilities<ContextKeys, YModels>,
> {
/**
* The Prisma client instance. Used for database queries and model introspection.
Expand All @@ -417,8 +435,8 @@ export interface SetupParams<
* A function that returns the roles for your application.
* This is paramaterised by the abilities, so you can use it to create roles that are a combination of abilities.
*/
getRoles: (abilities: DefaultAbilities & K) => {
[key: string]: Ability[] | "*";
getRoles: (abilities: DefaultAbilities<ContextKeys, YModels> & K) => {
[role: string]: AllAbilities<ContextKeys, YModels>[] | "*";
};
/**
* A function that returns the context for the current request.
Expand All @@ -435,12 +453,13 @@ export interface SetupParams<
**/
export const setup = async <
ContextKeys extends string = string,
K extends CustomAbilities<ContextKeys> = CustomAbilities<ContextKeys>,
YModels extends Models = Models,
K extends CustomAbilities<ContextKeys, YModels> = CustomAbilities<ContextKeys, YModels>,
>(
params: SetupParams<ContextKeys, K>,
params: SetupParams<ContextKeys, YModels, K>,
) => {
const { prisma, customAbilities, getRoles, getContext } = params;
await createRoles<K>({ prisma, customAbilities, getRoles });
await createRoles<ContextKeys, YModels, K>({ prisma, customAbilities, getRoles });
const client = createClient(prisma, getContext, params.options);

return client;
Expand Down
15 changes: 11 additions & 4 deletions test/integration/expressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe("expressions", () => {
operation: "SELECT",
expression: (_client: PrismaClient, _row, context) => {
return {
value: context("item.value"),
value: context("item.value") as any as number,
};
},
},
Expand Down Expand Up @@ -337,7 +337,8 @@ describe("expressions", () => {
operation: "SELECT",
expression: () => {
return {
stock: "escape'--",
// We're intentionally using the wrong type to run this test
stock: "escape'--" as any as number,
};
},
},
Expand Down Expand Up @@ -369,7 +370,8 @@ describe("expressions", () => {
operation: "SELECT",
expression: (_client, row) => {
return {
name: row(`escape"--`),
// We're intentionally using the wrong type to run this test
name: row(`escape"--` as any),
};
},
},
Expand Down Expand Up @@ -1125,7 +1127,12 @@ describe("expressions", () => {
},
getRoles(abilities) {
return {
[role]: [abilities.Post.customCreateAbility, abilities.Post.read, abilities.Tag.read, abilities.Tag.create],
[role]: [
abilities.Post.customCreateAbility!,
abilities.Post.read,
abilities.Tag.read,
abilities.Tag.create,
],
};
},
getContext: () => ({
Expand Down
Loading

0 comments on commit 16b2a0f

Please sign in to comment.