supa-forge 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin/cli.js +116 -0
- package/dist/index.d.ts +210 -0
- package/dist/index.js +79 -0
- package/dist/index.mjs +1364 -0
- package/dist/typeorm.d.ts +26 -0
- package/dist/typeorm.js +1 -0
- package/dist/typeorm.mjs +15 -0
- package/examples/Only CLS/package-lock.json +3140 -0
- package/examples/Only CLS/package.json +25 -0
- package/examples/Only CLS/src/employee.test.ts +109 -0
- package/examples/Only CLS/src/entities/Employee.entity.ts +27 -0
- package/examples/Only CLS/src/index.ts +45 -0
- package/examples/Only CLS/tsconfig.json +17 -0
- package/examples/Only CLS/vite.config.ts +8 -0
- package/examples/Only RLS/package-lock.json +3140 -0
- package/examples/Only RLS/package.json +25 -0
- package/examples/Only RLS/src/entities/Note.entity.ts +40 -0
- package/examples/Only RLS/src/entities/User.entity.ts +29 -0
- package/examples/Only RLS/src/index.ts +51 -0
- package/examples/Only RLS/src/notes.test.ts +123 -0
- package/examples/Only RLS/tsconfig.json +17 -0
- package/examples/Only RLS/vite.config.ts +8 -0
- package/examples/RBAC + CLS + RLS/package-lock.json +3140 -0
- package/examples/RBAC + CLS + RLS/package.json +21 -0
- package/examples/RBAC + CLS + RLS/src/entities/Project.entity.ts +47 -0
- package/examples/RBAC + CLS + RLS/src/entities/User.entity.ts +31 -0
- package/examples/RBAC + CLS + RLS/src/entities/UserRole.entity.ts +110 -0
- package/examples/RBAC + CLS + RLS/src/index.ts +42 -0
- package/examples/RBAC + CLS + RLS/src/project.test.ts +117 -0
- package/examples/RBAC + CLS + RLS/tsconfig.json +17 -0
- package/examples/RBAC + CLS + RLS/vite.config.ts +8 -0
- package/examples/Without Security/package-lock.json +3940 -0
- package/examples/Without Security/package.json +25 -0
- package/examples/Without Security/src/entities/Pharmacy.entity.ts +20 -0
- package/examples/Without Security/src/index.ts +52 -0
- package/examples/Without Security/src/pharmacy.test.ts +104 -0
- package/examples/Without Security/tsconfig.json +17 -0
- package/examples/Without Security/vite.config.ts +8 -0
- package/package.json +72 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example-only-rls",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate": "vite-node src/index.ts --generate",
|
|
7
|
+
"apply": "vite-node src/index.ts --apply",
|
|
8
|
+
"temp:generate": "vite-node src/index.ts --temp --generate",
|
|
9
|
+
"temp:apply": "vite-node src/index.ts --temp --apply",
|
|
10
|
+
"migration:create": "vite-node src/index.ts migration:create",
|
|
11
|
+
"docgen": "vite-node src/index.ts --docgen",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"supaforge": "file:../.."
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20.0.0",
|
|
19
|
+
"typeorm": "^0.3.17",
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"vite": "^5.0.0",
|
|
22
|
+
"vite-node": "^1.0.0",
|
|
23
|
+
"vitest": "^0.34.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'supaforge/typeorm';
|
|
3
|
+
import { security, rls, runSql } from 'supaforge';
|
|
4
|
+
import { UserView } from './User.entity';
|
|
5
|
+
|
|
6
|
+
@Entity('personal_notes')
|
|
7
|
+
@security()
|
|
8
|
+
export class Note {
|
|
9
|
+
@PrimaryGeneratedColumn('uuid')
|
|
10
|
+
id!: string;
|
|
11
|
+
|
|
12
|
+
@Column({ type: 'text' })
|
|
13
|
+
title!: string;
|
|
14
|
+
|
|
15
|
+
@Column({ type: 'text' })
|
|
16
|
+
content!: string;
|
|
17
|
+
|
|
18
|
+
@Column({ type: 'uuid' })
|
|
19
|
+
owner_id!: string;
|
|
20
|
+
|
|
21
|
+
@ManyToOne(() => UserView)
|
|
22
|
+
@JoinColumn({ name: 'owner_id' })
|
|
23
|
+
owner?: UserView;
|
|
24
|
+
|
|
25
|
+
@rls('crud')
|
|
26
|
+
static ownerAccess() {
|
|
27
|
+
return {
|
|
28
|
+
role: 'authenticated',
|
|
29
|
+
expression: "auth.uid()::text = owner_id::text"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@runSql()
|
|
34
|
+
static grantToRoles() {
|
|
35
|
+
return `
|
|
36
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE personal_notes TO authenticated;
|
|
37
|
+
GRANT SELECT ON TABLE personal_notes TO anon;
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { ViewEntity, ViewColumn } from 'supaforge/typeorm';
|
|
3
|
+
import { runSql } from 'supaforge';
|
|
4
|
+
|
|
5
|
+
@ViewEntity({
|
|
6
|
+
name: 'auth_users_view',
|
|
7
|
+
schema: 'public',
|
|
8
|
+
expression: `
|
|
9
|
+
SELECT
|
|
10
|
+
id,
|
|
11
|
+
email
|
|
12
|
+
FROM auth.users
|
|
13
|
+
`
|
|
14
|
+
})
|
|
15
|
+
export class UserView {
|
|
16
|
+
@ViewColumn()
|
|
17
|
+
id!: string;
|
|
18
|
+
|
|
19
|
+
@ViewColumn()
|
|
20
|
+
email!: string;
|
|
21
|
+
|
|
22
|
+
@runSql()
|
|
23
|
+
static grantViewPermissions() {
|
|
24
|
+
return `
|
|
25
|
+
GRANT SELECT ON TABLE auth_users_view TO authenticated;
|
|
26
|
+
GRANT SELECT ON TABLE auth_users_view TO anon;
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { runSupaforge, createMigrationTemplate, applyGeneratedMigrations, generateDocumentation, extractCliOptions, RunnerOptions } from 'supaforge';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const flags = process.argv;
|
|
12
|
+
const cliOptions = extractCliOptions(flags);
|
|
13
|
+
|
|
14
|
+
const options: RunnerOptions = {
|
|
15
|
+
...cliOptions,
|
|
16
|
+
dbConfig: {
|
|
17
|
+
host: process.env.DB_HOST || 'localhost',
|
|
18
|
+
port: Number(process.env.DB_PORT) || 5432,
|
|
19
|
+
user: process.env.DB_USER || 'postgres',
|
|
20
|
+
password: process.env.DB_PASSWORD || 'postgres',
|
|
21
|
+
database: flags.includes('--temp') ? 'temp_notes' : (process.env.DB_NAME || 'postgres'),
|
|
22
|
+
},
|
|
23
|
+
entityPath: path.join(__dirname, 'entities'),
|
|
24
|
+
outputDir: path.join(process.cwd(), 'migrations'),
|
|
25
|
+
syncSchema: cliOptions.syncSchema || flags.includes('--temp'),
|
|
26
|
+
cls: false,
|
|
27
|
+
rls: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (flags.includes('migration:create')) {
|
|
32
|
+
return await createMigrationTemplate(options);
|
|
33
|
+
}
|
|
34
|
+
if (flags.includes('--apply')) {
|
|
35
|
+
return await applyGeneratedMigrations(options);
|
|
36
|
+
}
|
|
37
|
+
if (flags.includes('--generate')) {
|
|
38
|
+
const sql = await runSupaforge(options);
|
|
39
|
+
console.log('Migration generated.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (flags.includes('--docgen')) {
|
|
43
|
+
return await generateDocumentation(options);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('\nError:', error);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
main();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { DataSource, QueryRunner } from 'supaforge/typeorm';
|
|
5
|
+
import { runSupaforge, applyGeneratedMigrations, runAsRole } from 'supaforge';
|
|
6
|
+
import { Note } from './entities/Note.entity';
|
|
7
|
+
import { UserView } from './entities/User.entity';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import process from 'process';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
describe('Personal Notes (OnlyRls) Tests', () => {
|
|
17
|
+
let dataSource: DataSource;
|
|
18
|
+
let queryRunner: QueryRunner;
|
|
19
|
+
|
|
20
|
+
const DB_CONFIG = {
|
|
21
|
+
host: process.env.DB_HOST || 'localhost',
|
|
22
|
+
port: Number(process.env.DB_PORT) || 5432,
|
|
23
|
+
user: process.env.DB_USER || 'postgres',
|
|
24
|
+
password: process.env.DB_PASSWORD || 'postgres',
|
|
25
|
+
database: process.env.DB_NAME || 'postgres',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ALICE_ID = '11111111-1111-1111-1111-111111111111';
|
|
29
|
+
const BOB_ID = '22222222-2222-2222-2222-222222222222';
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const migrationDir = path.join(process.cwd(), 'migrations');
|
|
33
|
+
if (fs.existsSync(migrationDir)) {
|
|
34
|
+
fs.rmSync(migrationDir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const options = {
|
|
38
|
+
dbConfig: DB_CONFIG,
|
|
39
|
+
entityPath: path.join(__dirname, 'entities'),
|
|
40
|
+
outputDir: migrationDir,
|
|
41
|
+
syncSchema: true,
|
|
42
|
+
migrationName: 'NotesInit',
|
|
43
|
+
rls: true,
|
|
44
|
+
cls: false
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
await runSupaforge(options);
|
|
48
|
+
await applyGeneratedMigrations(options);
|
|
49
|
+
|
|
50
|
+
dataSource = new DataSource({
|
|
51
|
+
type: 'postgres',
|
|
52
|
+
...DB_CONFIG,
|
|
53
|
+
username: DB_CONFIG.user,
|
|
54
|
+
entities: [Note, UserView],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await dataSource.initialize();
|
|
58
|
+
queryRunner = dataSource.createQueryRunner();
|
|
59
|
+
await queryRunner.connect();
|
|
60
|
+
|
|
61
|
+
await queryRunner.query('DROP TRIGGER IF EXISTS on_auth_user_sync_user_roles ON auth.users');
|
|
62
|
+
await queryRunner.query('DROP FUNCTION IF EXISTS public.handle_auth_user_sync() CASCADE');
|
|
63
|
+
await queryRunner.query('DELETE FROM auth.users CASCADE');
|
|
64
|
+
await queryRunner.query('DELETE FROM personal_notes');
|
|
65
|
+
await queryRunner.query("INSERT INTO auth.users (id, email) VALUES ($1, 'alice@example.com'), ($2, 'bob@example.com')", [ALICE_ID, BOB_ID]);
|
|
66
|
+
|
|
67
|
+
await queryRunner.query("INSERT INTO personal_notes (id, title, content, owner_id) VALUES (gen_random_uuid(), 'Alice Note', 'Top Secret!', $1)", [ALICE_ID]);
|
|
68
|
+
await queryRunner.query("INSERT INTO personal_notes (id, title, content, owner_id) VALUES (gen_random_uuid(), 'Bob Note', 'Bobs secret.', $1)", [BOB_ID]);
|
|
69
|
+
}, 45000);
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
if (queryRunner) await queryRunner.release();
|
|
73
|
+
if (dataSource) await dataSource.destroy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('Alice should ONLY see her own notes with user details (JOIN)', async () => {
|
|
77
|
+
const notes = await runAsRole(queryRunner, 'authenticated', ALICE_ID,
|
|
78
|
+
'SELECT n.title, u.email FROM personal_notes n INNER JOIN auth_users_view u ON n.owner_id = u.id'
|
|
79
|
+
);
|
|
80
|
+
expect(notes).toHaveLength(1);
|
|
81
|
+
expect(notes[0].title).toBe('Alice Note');
|
|
82
|
+
expect(notes[0].email).toBe('alice@example.com');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('Bob should ONLY see his own notes with user details (JOIN)', async () => {
|
|
86
|
+
const notes = await runAsRole(queryRunner, 'authenticated', BOB_ID,
|
|
87
|
+
'SELECT n.title, u.email FROM personal_notes n INNER JOIN auth_users_view u ON n.owner_id = u.id'
|
|
88
|
+
);
|
|
89
|
+
expect(notes).toHaveLength(1);
|
|
90
|
+
expect(notes[0].title).toBe('Bob Note');
|
|
91
|
+
expect(notes[0].email).toBe('bob@example.com');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('Alice should be able to create a new note', async () => {
|
|
95
|
+
const newId = '33333333-3333-3333-3333-333333333333';
|
|
96
|
+
await runAsRole(queryRunner, 'authenticated', ALICE_ID,
|
|
97
|
+
"INSERT INTO personal_notes (id, title, content, owner_id) VALUES ($1, $2, $3, $4)",
|
|
98
|
+
[newId, 'Alice Private Note', 'Something new', ALICE_ID]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const check = await runAsRole(queryRunner, 'authenticated', ALICE_ID, 'SELECT * FROM personal_notes WHERE id = $1', [newId]);
|
|
102
|
+
expect(check).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('Alice should NOT be able to delete Bob\'s note', async () => {
|
|
106
|
+
const bobNote = (await queryRunner.query("SELECT id FROM personal_notes WHERE owner_id = $1", [BOB_ID]))[0];
|
|
107
|
+
await runAsRole(queryRunner, 'authenticated', ALICE_ID, "DELETE FROM personal_notes WHERE id = $1", [bobNote.id]);
|
|
108
|
+
|
|
109
|
+
const check = await queryRunner.query("SELECT * FROM personal_notes WHERE id = $1", [bobNote.id]);
|
|
110
|
+
expect(check).toHaveLength(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('Alice should be able to update her own note', async () => {
|
|
114
|
+
const aliceNote = (await runAsRole(queryRunner, 'authenticated', ALICE_ID, "SELECT id FROM personal_notes WHERE title = 'Alice Note'"))[0];
|
|
115
|
+
await runAsRole(queryRunner, 'authenticated', ALICE_ID,
|
|
116
|
+
"UPDATE personal_notes SET content = $1 WHERE id = $2",
|
|
117
|
+
['Alice updated content', aliceNote.id]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const check = await runAsRole(queryRunner, 'authenticated', ALICE_ID, "SELECT content FROM personal_notes WHERE id = $1", [aliceNote.id]);
|
|
121
|
+
expect(check[0].content).toBe('Alice updated content');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"emitDecoratorMetadata": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"noImplicitAny": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"]
|
|
17
|
+
}
|