Skip to content

Commit 11ec72b

Browse files
committed
feat: add test for multiple Yates clients operating in the same DB
1 parent 0b6a4f3 commit 11ec72b

File tree

9 files changed

+238
-13
lines changed

9 files changed

+238
-13
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,4 +257,5 @@ tags
257257

258258
dist/
259259
.npmrc
260-
*.tgz
260+
*.tgz
261+
prisma/generated

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
33
"files": {
4-
"ignore": ["dist", "coverage", "package.json"]
4+
"ignore": ["dist", "coverage", "package.json", "prisma/generated"]
55
},
66
"organizeImports": {
77
"enabled": true

docker-compose.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ services:
1717
- internal
1818
environment:
1919
- DATABASE_URL=postgresql://postgres:postgres@db:5432/yates?connection_limit=30
20+
- DATABASE_URL_2=postgresql://postgres:postgres@db:5432/yates_2?connection_limit=30
2021

2122
db:
2223
image: postgres:11
24+
volumes:
25+
- ./docker-init:/docker-entrypoint-initdb.d
2326
restart: always
2427
environment:
2528
POSTGRES_USER: postgres
2629
POSTGRES_PASSWORD: postgres
27-
POSTGRES_DB: yates
30+
POSTGRES_MULTIPLE_DATABASES: yates,yates_2
2831
ports:
2932
- 5432:5432
3033
networks:

docker-init/setup-databases.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/bash
2+
# Borrowed from https://github.com/mrts/docker-postgresql-multiple-databases
3+
# This script creates multiple Postgres databases, so we can test multi-tenant setups
4+
5+
set -e
6+
set -u
7+
8+
function create_user_and_database() {
9+
local database=$1
10+
echo " Creating user and database '$database'"
11+
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
12+
CREATE USER $database;
13+
CREATE DATABASE $database;
14+
GRANT ALL PRIVILEGES ON DATABASE $database TO $database;
15+
EOSQL
16+
}
17+
18+
if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
19+
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
20+
for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
21+
create_user_and_database $db
22+
done
23+
echo "Multiple databases created"
24+
fi

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"test:types": "tsc --noEmit",
1818
"test:integration": "jest --runInBand test/integration",
1919
"test:compose:integration": "docker compose -f docker-compose.yml --profile with-sut up db sut --exit-code-from sut",
20-
"setup": "prisma generate && prisma migrate dev",
20+
"setup": "prisma generate --schema prisma/schema.prisma && prisma migrate dev --schema prisma/schema.prisma && prisma generate --schema prisma/schema2.prisma && prisma migrate dev --schema prisma/schema2.prisma",
2121
"prepublishOnly": "npm run build"
2222
},
2323
"author": "Cerebrum <[email protected]> (https://cerebrum.com)",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost.
5+
- You are about to drop the `Hat` table. If the table is not empty, all the data it contains will be lost.
6+
- You are about to drop the `Item` table. If the table is not empty, all the data it contains will be lost.
7+
- You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost.
8+
- You are about to drop the `Role` table. If the table is not empty, all the data it contains will be lost.
9+
- You are about to drop the `RoleAssignment` table. If the table is not empty, all the data it contains will be lost.
10+
- You are about to drop the `Tag` table. If the table is not empty, all the data it contains will be lost.
11+
- You are about to drop the `_PostToTag` table. If the table is not empty, all the data it contains will be lost.
12+
13+
*/
14+
-- DropForeignKey
15+
ALTER TABLE "Hat" DROP CONSTRAINT "Hat_userId_fkey";
16+
17+
-- DropForeignKey
18+
ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_organizationId_fkey";
19+
20+
-- DropForeignKey
21+
ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_roleId_fkey";
22+
23+
-- DropForeignKey
24+
ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_userId_fkey";
25+
26+
-- DropForeignKey
27+
ALTER TABLE "_PostToTag" DROP CONSTRAINT "_PostToTag_A_fkey";
28+
29+
-- DropForeignKey
30+
ALTER TABLE "_PostToTag" DROP CONSTRAINT "_PostToTag_B_fkey";
31+
32+
-- DropTable
33+
DROP TABLE "Account";
34+
35+
-- DropTable
36+
DROP TABLE "Hat";
37+
38+
-- DropTable
39+
DROP TABLE "Item";
40+
41+
-- DropTable
42+
DROP TABLE "Organization";
43+
44+
-- DropTable
45+
DROP TABLE "Role";
46+
47+
-- DropTable
48+
DROP TABLE "RoleAssignment";
49+
50+
-- DropTable
51+
DROP TABLE "Tag";
52+
53+
-- DropTable
54+
DROP TABLE "_PostToTag";

prisma/schema2.prisma

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// This particular schema has a subset of models compared to schema.prisma
2+
// It is used to test the behaviour of Yates when multiple schemas are being used on the same database server
3+
datasource db {
4+
provider = "postgresql"
5+
url = env("DATABASE_URL_2")
6+
}
7+
8+
generator client {
9+
provider = "prisma-client-js"
10+
output = "./generated/client2"
11+
}
12+
13+
model User {
14+
id String @id @default(uuid())
15+
createdAt DateTime @default(now())
16+
email String @unique
17+
name String?
18+
posts Post[]
19+
}
20+
21+
model Post {
22+
id Int @id @default(autoincrement())
23+
createdAt DateTime @default(now())
24+
updatedAt DateTime @updatedAt
25+
published Boolean @default(false)
26+
title String @db.VarChar(255)
27+
author User? @relation(fields: [authorId], references: [id])
28+
authorId String?
29+
}

src/index.ts

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export const sanitizeSlug = (slug: string) =>
185185
.replace(/-/g, "_")
186186
.replace(/[^a-z0-9_]/gi, "");
187187

188-
class Yates {
188+
export class Yates {
189189
private databaseScope: string | null = null;
190190

191191
constructor(private prisma: PrismaClient) {}
@@ -229,6 +229,8 @@ class Yates {
229229

230230
const currentDatabase = result[0]?.current_database;
231231

232+
debug("Current database for Yates:", currentDatabase);
233+
232234
if (!currentDatabase) {
233235
throw new Error(
234236
"Failed to determine the current database for scoping Yates roles.",
@@ -668,14 +670,7 @@ class Yates {
668670
}) => {
669671
await this.ensureDatabaseScope();
670672

671-
// See https://github.com/prisma/prisma/discussions/14777
672-
// We are reaching into the prisma internals to get the data model.
673-
// This is a bit sketchy, but we can get the internal type definition from the runtime library
674-
// and there is even a test case in prisma that checks that this value is exported
675-
// See https://github.com/prisma/prisma/blob/5.1.0/packages/client/tests/functional/extensions/pdp.ts#L51
676-
// This is a private API, so not much we can do about the cast
677-
const runtimeDataModel = (this.prisma as any)
678-
._runtimeDataModel as RuntimeDataModel;
673+
const runtimeDataModel = this.inspectRunTimeDataModel();
679674
const models = Object.keys(runtimeDataModel.models).map(
680675
(m) => runtimeDataModel.models[m].dbName || m,
681676
) as Models[];
@@ -903,6 +898,73 @@ class Yates {
903898
}
904899
}
905900
};
901+
902+
inspectDBRoles = async (role: string) => {
903+
await this.ensureDatabaseScope();
904+
const hashedRoleName = this.createRoleName(role);
905+
906+
// Load all policies for the role
907+
const roles = await this.prisma.$queryRawUnsafe<
908+
{
909+
tablename: string;
910+
policyname: string;
911+
cmd: string;
912+
policy_roles: string[];
913+
matched_role: string[];
914+
}[]
915+
>(`
916+
WITH RECURSIVE role_tree AS(
917+
--Start from your role
918+
SELECT
919+
r.oid,
920+
r.rolname
921+
FROM pg_roles r
922+
WHERE r.rolname = '${hashedRoleName}'
923+
924+
UNION
925+
926+
--Walk "upwards": all parent roles granted to it
927+
SELECT
928+
parent.oid,
929+
parent.rolname
930+
FROM pg_auth_members m
931+
JOIN role_tree rt
932+
ON m.member = rt.oid
933+
JOIN pg_roles parent
934+
ON parent.oid = m.roleid
935+
)
936+
SELECT
937+
p.tablename,
938+
p.policyname,
939+
p.cmd,
940+
p.roles:: text[] AS policy_roles,
941+
rt.rolname AS matched_role
942+
FROM pg_policies p
943+
JOIN role_tree rt
944+
ON(
945+
p.roles IS NULL
946+
OR array_length(p.roles, 1) = 0
947+
OR rt.rolname = ANY(p.roles:: text[])
948+
)
949+
WHERE p.schemaname = 'public'
950+
ORDER BY p.policyname, matched_role;
951+
`);
952+
953+
return roles;
954+
};
955+
956+
inspectRunTimeDataModel = (): RuntimeDataModel => {
957+
// See https://github.com/prisma/prisma/discussions/14777
958+
// We are reaching into the prisma internals to get the data model.
959+
// This is a bit sketchy, but we can get the internal type definition from the runtime library
960+
// and there is even a test case in prisma that checks that this value is exported
961+
// See https://github.com/prisma/prisma/blob/5.1.0/packages/client/tests/functional/extensions/pdp.ts#L51
962+
// This is a private API, so not much we can do about the cast
963+
const runtimeDataModel = (this.prisma as any)
964+
._runtimeDataModel as RuntimeDataModel;
965+
966+
return runtimeDataModel;
967+
};
906968
}
907969

908970
/**
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { PrismaClient } from "@prisma/client";
2+
import { PrismaClient as PrismaClient2 } from "../../prisma/generated/client2";
3+
import { Yates, setup } from "../../src";
4+
5+
describe("Multi-tenant database tests", () => {
6+
it("should not overwrite data between tenants", async () => {
7+
const rootClient1 = new PrismaClient();
8+
const rootClient2 = new PrismaClient2();
9+
10+
const role = "ADMIN";
11+
12+
const _client1 = await setup({
13+
prisma: rootClient1,
14+
getRoles(_abilities) {
15+
return {
16+
[role]: "*",
17+
};
18+
},
19+
getContext: () => ({
20+
role,
21+
}),
22+
});
23+
24+
const yates1 = new Yates(rootClient1);
25+
const roles1 = await yates1.inspectDBRoles(role);
26+
const models1 = Object.keys(yates1.inspectRunTimeDataModel().models);
27+
// Expect to see 4 roles (CRUD) for each defined model when using wildcard abilities (*)
28+
expect(roles1.length).toBe(models1.length * 4);
29+
30+
const _client2 = await setup({
31+
prisma: rootClient2 as PrismaClient,
32+
getRoles(abilities) {
33+
return {
34+
[role]: [abilities.User.create, abilities.User.read],
35+
};
36+
},
37+
getContext: () => ({
38+
role,
39+
}),
40+
});
41+
42+
const yates2 = new Yates(rootClient2 as PrismaClient);
43+
const roles2 = await yates2.inspectDBRoles(role);
44+
// Expect to see 2 roles (CREATE, READ) for User model only
45+
expect(roles2.length).toBe(2);
46+
47+
// The setup of client2 should not have affected client1
48+
const rolesAfterSetup = await yates1.inspectDBRoles(role);
49+
// Expect to see 4 roles (CRUD) for each defined model when using wildcard abilities (*)
50+
expect(rolesAfterSetup.length).toBe(models1.length * 4);
51+
});
52+
});

0 commit comments

Comments
 (0)