Skip to content

Commit

Permalink
fix: correctly handle long ability names
Browse files Browse the repository at this point in the history
This change restricts role names to 63 bytes, which is important to
prevent ambiguity when managing role names.

In PostgreSQL, the maximum length for a role (user) name is 63 bytes.
This limitation is derived from the value of the NAMEDATALEN configuration
parameter, which is set to 64 bytes by default.
One byte is reserved for the null-terminator, leaving 63 bytes for the actual role name.

Keep in mind that if you use multi-byte characters in the role name, the actual number of characters may be less than 63, as each multi-byte character will consume more than one byte.

Signed-off-by: Lucian Buzzo <lucian.buzzo@gmail.com>
  • Loading branch information
LucianBuzzo committed Apr 11, 2023
1 parent 60c6e14 commit f4600d0
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 6 deletions.
24 changes: 19 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import difference from "lodash/difference";
import flatMap from "lodash/flatMap";
import map from "lodash/map";
import toPairs from "lodash/toPairs";
import * as crypto from "crypto";
import { Expression, expressionToSQL } from "./expressions";

const VALID_OPERATIONS = ["SELECT", "UPDATE", "INSERT", "DELETE"] as const;
Expand Down Expand Up @@ -44,17 +45,30 @@ declare module "@prisma/client" {
const takeLock = (prisma: PrismaClient) =>
prisma.$executeRawUnsafe("SELECT pg_advisory_xact_lock(2142616474639426746);");

/**
* In PostgreSQL, the maximum length for a role or policy name is 63 bytes.
* This limitation is derived from the value of the NAMEDATALEN configuration parameter,
* which is set to 64 bytes by default. One byte is reserved for the null-terminator,
* leaving 63 bytes for the actual role name.
* This function hashes the ability name to ensure it is within the 63 byte limit.
*/
const hashWithPrefix = (prefix: string, abilityName: string) => {
const hash = crypto.createHash("sha256");
hash.update(abilityName);
const hashedAbilityName = hash.digest("hex");
const maxLength = 63 - prefix.length;
return prefix + hashedAbilityName.slice(0, maxLength);
};

// 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, "");

export const createAbilityName = (model: string, ability: string) => {
return sanitizeSlug(`yates_ability_${model}_${ability}_role`);
return sanitizeSlug(hashWithPrefix("yates_ability_", `${model}_${ability}`));
};

export const createRoleName = (name: string) => {
// Ensure the role name only has lowercase alpha characters and underscores
// This also doubles as a check against SQL injection
return sanitizeSlug(`yates_role_${name}`);
return sanitizeSlug(hashWithPrefix("yates_role_", `${name}`));
};

// This uses client extensions to set the role and context for the current user so that RLS can be applied
Expand Down Expand Up @@ -155,7 +169,7 @@ const setRLS = async (
let expression = await expressionToSQL(rawExpression, table);

// Check if RLS exists
const policyName = `${roleName}_policy`;
const policyName = roleName;
const rows: any[] = await prisma.$queryRawUnsafe(`
select * from pg_catalog.pg_policies where tablename = '${table}' AND policyname = '${policyName}';
`);
Expand Down
90 changes: 90 additions & 0 deletions test/integration/abilities.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { PrismaClient } from "@prisma/client";
import _ from "lodash";
import { v4 as uuid } from "uuid";
import { setup } from "../../src";

jest.setTimeout(30000);

let adminClient: PrismaClient;

beforeAll(async () => {
adminClient = new PrismaClient();
});

describe("abilities", () => {
it("should be able to handle long ability names", async () => {
const initial = new PrismaClient();
const role = `USER_${uuid()}`;

const mail = `test-user-${uuid()}@example.com`;

const dummyUser = await adminClient.user.create({
data: {
email: `test-user-${uuid()}@example.com`,
},
});

const longAbilityName =
"thisIsAnIncrediblyLongAbilityNameDesignedToTestTheSixtyThreeByteLimitOnRoleNamesInPostgres";

const readAbility = `CAN_${longAbilityName}_USER_READ`;
const writeAbility = `CAN_${longAbilityName}_USER_WRITE`;

const client = await setup({
prisma: initial,
customAbilities: {
User: {
[readAbility]: {
description: "Read",
operation: "SELECT",
expression: (_client, _row, _context) => {
return {
email: mail,
};
},
},
[writeAbility]: {
description: "Write",
operation: "INSERT",
expression: (_client, _row, _context) => {
return {
email: mail,
};
},
},
},
},
getRoles(abilities) {
return {
[role]: [abilities.User[readAbility], abilities.User[writeAbility]],
};
},
getContext: () => ({
role,
context: {},
}),
});

const notFound = await client.user.findUnique({
where: {
id: dummyUser.id,
},
});

expect(notFound).toBeNull();

const user = await client.user.create({
data: {
email: mail,
},
});

const ownUser = await client.user.findUnique({
where: {
id: user.id,
},
});

expect(ownUser).toBeDefined();
});
});
2 changes: 1 addition & 1 deletion test/integration/expressions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PrismaClient } from "@prisma/client";
import _, { initial } from "lodash";
import _ from "lodash";
import { v4 as uuid } from "uuid";
import { setup } from "../../src";

Expand Down

0 comments on commit f4600d0

Please sign in to comment.