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.
Files changed (40) hide show
  1. package/README.md +65 -0
  2. package/bin/cli.js +116 -0
  3. package/dist/index.d.ts +210 -0
  4. package/dist/index.js +79 -0
  5. package/dist/index.mjs +1364 -0
  6. package/dist/typeorm.d.ts +26 -0
  7. package/dist/typeorm.js +1 -0
  8. package/dist/typeorm.mjs +15 -0
  9. package/examples/Only CLS/package-lock.json +3140 -0
  10. package/examples/Only CLS/package.json +25 -0
  11. package/examples/Only CLS/src/employee.test.ts +109 -0
  12. package/examples/Only CLS/src/entities/Employee.entity.ts +27 -0
  13. package/examples/Only CLS/src/index.ts +45 -0
  14. package/examples/Only CLS/tsconfig.json +17 -0
  15. package/examples/Only CLS/vite.config.ts +8 -0
  16. package/examples/Only RLS/package-lock.json +3140 -0
  17. package/examples/Only RLS/package.json +25 -0
  18. package/examples/Only RLS/src/entities/Note.entity.ts +40 -0
  19. package/examples/Only RLS/src/entities/User.entity.ts +29 -0
  20. package/examples/Only RLS/src/index.ts +51 -0
  21. package/examples/Only RLS/src/notes.test.ts +123 -0
  22. package/examples/Only RLS/tsconfig.json +17 -0
  23. package/examples/Only RLS/vite.config.ts +8 -0
  24. package/examples/RBAC + CLS + RLS/package-lock.json +3140 -0
  25. package/examples/RBAC + CLS + RLS/package.json +21 -0
  26. package/examples/RBAC + CLS + RLS/src/entities/Project.entity.ts +47 -0
  27. package/examples/RBAC + CLS + RLS/src/entities/User.entity.ts +31 -0
  28. package/examples/RBAC + CLS + RLS/src/entities/UserRole.entity.ts +110 -0
  29. package/examples/RBAC + CLS + RLS/src/index.ts +42 -0
  30. package/examples/RBAC + CLS + RLS/src/project.test.ts +117 -0
  31. package/examples/RBAC + CLS + RLS/tsconfig.json +17 -0
  32. package/examples/RBAC + CLS + RLS/vite.config.ts +8 -0
  33. package/examples/Without Security/package-lock.json +3940 -0
  34. package/examples/Without Security/package.json +25 -0
  35. package/examples/Without Security/src/entities/Pharmacy.entity.ts +20 -0
  36. package/examples/Without Security/src/index.ts +52 -0
  37. package/examples/Without Security/src/pharmacy.test.ts +104 -0
  38. package/examples/Without Security/tsconfig.json +17 -0
  39. package/examples/Without Security/vite.config.ts +8 -0
  40. package/package.json +72 -0
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "example-full-security",
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
+ "test": "vitest run"
9
+ },
10
+ "dependencies": {
11
+ "supaforge": "file:../.."
12
+ },
13
+ "devDependencies": {
14
+ "@types/node": "^20.0.0",
15
+ "typeorm": "^0.3.17",
16
+ "typescript": "^5.0.0",
17
+ "vite": "^5.0.0",
18
+ "vite-node": "^1.0.0",
19
+ "vitest": "0.34.6"
20
+ }
21
+ }
@@ -0,0 +1,47 @@
1
+ import 'reflect-metadata';
2
+ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'supaforge/typeorm';
3
+ import { security, rls, cls, runSql } from 'supaforge';
4
+ import { UserView } from './User.entity';
5
+
6
+ @Entity('projects')
7
+ @security()
8
+ export class Project {
9
+ @PrimaryGeneratedColumn('uuid')
10
+ @cls({ admin: 'r', manager: 'r', employee: 'r' })
11
+ id!: string;
12
+
13
+ @Column({ type: 'text' })
14
+ @cls({ admin: 'r', manager: 'r', employee: 'r' })
15
+ name!: string;
16
+
17
+ @Column({ type: 'text' })
18
+ @cls({ admin: 'r', manager: 'r', employee: 'r' })
19
+ description!: string;
20
+
21
+ @Column({ type: 'numeric', nullable: true })
22
+ @cls({ admin: 'r', manager: 'r' })
23
+ budget!: number;
24
+
25
+ @Column({ type: 'uuid' })
26
+ @cls({ admin: 'r', manager: 'r', employee: 'r' })
27
+ manager_id!: string;
28
+
29
+ @ManyToOne(() => UserView)
30
+ @JoinColumn({ name: 'manager_id' })
31
+ manager?: UserView;
32
+
33
+ @rls('crud')
34
+ static adminAccess() {
35
+ return { role: 'admin', expression: 'true' };
36
+ }
37
+
38
+ @rls('crud')
39
+ static managerAccess() {
40
+ return { role: 'manager', expression: 'auth.uid()::text = manager_id::text' };
41
+ }
42
+
43
+ @rls('r')
44
+ static employeeAccess() {
45
+ return { role: 'employee', expression: 'true' };
46
+ }
47
+ }
@@ -0,0 +1,31 @@
1
+ import { ViewEntity, ViewColumn } from 'supaforge/typeorm';
2
+
3
+ @ViewEntity({
4
+ name: 'auth_users',
5
+ schema: 'public',
6
+ expression: `
7
+ SELECT
8
+ id,
9
+ email,
10
+ raw_user_meta_data,
11
+ created_at,
12
+ last_sign_in_at
13
+ FROM auth.users
14
+ `
15
+ })
16
+ export class UserView {
17
+ @ViewColumn()
18
+ id!: string;
19
+
20
+ @ViewColumn()
21
+ email!: string;
22
+
23
+ @ViewColumn()
24
+ raw_user_meta_data!: any;
25
+
26
+ @ViewColumn()
27
+ created_at!: Date;
28
+
29
+ @ViewColumn()
30
+ last_sign_in_at!: Date;
31
+ }
@@ -0,0 +1,110 @@
1
+ import { Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'supaforge/typeorm';
2
+ import { security, rls, cls, runSql } from 'supaforge';
3
+ import { UserView } from './User.entity';
4
+
5
+ @Entity({ name: 'user_roles' })
6
+ @security()
7
+ export class UserRole {
8
+ @PrimaryGeneratedColumn('identity', { type: 'bigint' })
9
+ @cls({ admin: 'r' })
10
+ id!: string;
11
+
12
+ @Column('uuid', { name: 'user_id', nullable: false })
13
+ @cls({ admin: 'cru', manager: 'r', employee: 'r' })
14
+ user_id!: string;
15
+
16
+ @OneToOne('UserView')
17
+ @JoinColumn({ name: 'user_id' })
18
+ user?: UserView;
19
+
20
+ @Column('text')
21
+ @cls({ admin: 'cru' })
22
+ role!: string;
23
+
24
+ @rls('r')
25
+ static ownRole() {
26
+ return {
27
+ role: ['authenticated', 'admin', 'manager', 'employee'],
28
+ expression: '((select auth.uid()) = user_id)'
29
+ };
30
+ }
31
+
32
+ @rls('cru')
33
+ static adminControl() {
34
+ return {
35
+ role: 'admin',
36
+ expression: 'true'
37
+ };
38
+ }
39
+
40
+ @runSql()
41
+ static setupSyncTrigger() {
42
+ return `
43
+ CREATE OR REPLACE FUNCTION public.handle_auth_user_sync()
44
+ RETURNS TRIGGER AS $$
45
+ BEGIN
46
+ -- Default all new users to 'authenticated' which maps to 'employee' or base access.
47
+ -- In FullSecurity, roles are usually assigned manually by admins.
48
+ INSERT INTO public.user_roles (user_id, role)
49
+ VALUES (new.id, 'authenticated')
50
+ ON CONFLICT (user_id) DO NOTHING;
51
+ RETURN new;
52
+ END;
53
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';
54
+
55
+ DROP TRIGGER IF EXISTS on_auth_user_sync_user_roles ON auth.users;
56
+ CREATE TRIGGER on_auth_user_sync_user_roles
57
+ AFTER INSERT OR UPDATE ON auth.users
58
+ FOR EACH ROW EXECUTE PROCEDURE public.handle_auth_user_sync();
59
+ `;
60
+ }
61
+
62
+ @runSql()
63
+ static setupAuthHook() {
64
+ return `
65
+ CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
66
+ RETURNS jsonb
67
+ LANGUAGE plpgsql
68
+ STABLE
69
+ SECURITY DEFINER
70
+ AS $$
71
+ DECLARE
72
+ claims jsonb;
73
+ user_roles_arr jsonb;
74
+ primary_role text;
75
+ BEGIN
76
+ -- Fetch roles from user_roles table
77
+ SELECT jsonb_agg(role) INTO user_roles_arr FROM public.user_roles WHERE user_id = (event->>'user_id')::uuid;
78
+ SELECT role INTO primary_role FROM public.user_roles WHERE user_id = (event->>'user_id')::uuid LIMIT 1;
79
+ claims := event->'claims';
80
+
81
+ IF user_roles_arr IS NOT NULL THEN
82
+ claims := jsonb_set(claims, '{user_roles}', user_roles_arr);
83
+ claims := jsonb_set(claims, '{user_role}', to_jsonb(primary_role));
84
+ ELSE
85
+ claims := jsonb_set(claims, '{user_roles}', '[]'::jsonb);
86
+ claims := jsonb_set(claims, '{user_role}', 'null');
87
+ END IF;
88
+ event := jsonb_set(event, '{claims}', claims);
89
+ RETURN event;
90
+ END;
91
+ $$;
92
+
93
+ GRANT USAGE ON SCHEMA public TO supabase_auth_admin;
94
+ GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
95
+ REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;
96
+
97
+ GRANT SELECT ON TABLE public.user_roles TO supabase_auth_admin;
98
+
99
+ DO $$
100
+ BEGIN
101
+ IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'user_roles') THEN
102
+ ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
103
+ DROP POLICY IF EXISTS "Allow auth admin to read roles" ON public.user_roles;
104
+ CREATE POLICY "Allow auth admin to read roles" ON public.user_roles
105
+ AS PERMISSIVE FOR SELECT TO supabase_auth_admin USING (true);
106
+ END IF;
107
+ END $$;
108
+ `;
109
+ }
110
+ }
@@ -0,0 +1,42 @@
1
+ import 'reflect-metadata';
2
+ import * as path from 'path';
3
+ import 'dotenv/config';
4
+ import { runSupaforge, applyGeneratedMigrations, 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_full' : (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: true,
27
+ rls: true,
28
+ };
29
+
30
+ try {
31
+ if (flags.includes('--apply')) {
32
+ return await applyGeneratedMigrations(options);
33
+ }
34
+ await runSupaforge(options);
35
+ console.log('Full Security Migration generated.');
36
+ } catch (error) {
37
+ console.error('\nError:', error);
38
+ process.exit(1);
39
+ }
40
+ }
41
+
42
+ main();
@@ -0,0 +1,117 @@
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 { Project } from './entities/Project.entity';
7
+ import { UserView } from './entities/User.entity';
8
+ import { UserRole } from './entities/UserRole.entity';
9
+ import * as path from 'path';
10
+ import * as fs from 'fs';
11
+ import { fileURLToPath } from 'url';
12
+ import process from 'process';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ describe('Full Security (RLS+CLS+RBAC) Tests', () => {
18
+ let dataSource: DataSource;
19
+ let queryRunner: QueryRunner;
20
+
21
+ const DB_CONFIG = {
22
+ host: process.env.DB_HOST || 'localhost',
23
+ port: Number(process.env.DB_PORT) || 5432,
24
+ user: process.env.DB_USER || 'postgres',
25
+ password: process.env.DB_PASSWORD || 'postgres',
26
+ database: process.env.DB_NAME || 'postgres',
27
+ };
28
+
29
+ const USER_MANAGER_A = '10000000-0000-0000-0000-000000000001';
30
+ const USER_MANAGER_B = '20000000-0000-0000-0000-000000000002';
31
+ const USER_EMPLOYEE = '30000000-0000-0000-0000-000000000003';
32
+ const AT_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
33
+ const AT_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
34
+
35
+ beforeAll(async () => {
36
+ const migrationDir = path.join(process.cwd(), 'migrations');
37
+ if (fs.existsSync(migrationDir)) {
38
+ fs.rmSync(migrationDir, { recursive: true, force: true });
39
+ }
40
+
41
+ const options = {
42
+ dbConfig: DB_CONFIG,
43
+ entityPath: path.join(__dirname, 'entities'),
44
+ outputDir: migrationDir,
45
+ syncSchema: true,
46
+ migrationName: 'FullSecurityInit',
47
+ rls: true,
48
+ cls: true
49
+ };
50
+
51
+ await runSupaforge(options);
52
+ await applyGeneratedMigrations(options);
53
+
54
+ dataSource = new DataSource({
55
+ type: 'postgres',
56
+ ...DB_CONFIG,
57
+ username: DB_CONFIG.user,
58
+ entities: [Project, UserView, UserRole],
59
+ });
60
+
61
+ await dataSource.initialize();
62
+ queryRunner = dataSource.createQueryRunner();
63
+ await queryRunner.connect();
64
+
65
+ await queryRunner.query('DROP TRIGGER IF EXISTS on_auth_user_sync_user_roles ON auth.users');
66
+ await queryRunner.query('DROP FUNCTION IF EXISTS public.handle_auth_user_sync() CASCADE');
67
+ await queryRunner.query('DELETE FROM projects');
68
+ await queryRunner.query('DELETE FROM auth.users CASCADE');
69
+ await queryRunner.query("INSERT INTO auth.users (id, email) VALUES ($1, 'managerA@example.com'), ($2, 'managerB@example.com'), ($3, 'employee@example.com')", [USER_MANAGER_A, USER_MANAGER_B, USER_EMPLOYEE]);
70
+
71
+ await queryRunner.query("INSERT INTO projects (id, name, description, budget, manager_id) VALUES ($1, 'Space Mission', 'Very expensive project', 1000000, $2)", [AT_A, USER_MANAGER_A]);
72
+ await queryRunner.query("INSERT INTO projects (id, name, description, budget, manager_id) VALUES ($1, 'Office Renovation', 'Paint the walls', 500, $2)", [AT_B, USER_MANAGER_B]);
73
+ }, 45000);
74
+
75
+ afterAll(async () => {
76
+ if (queryRunner) await queryRunner.release();
77
+ if (dataSource) await dataSource.destroy();
78
+ });
79
+
80
+ it('Admin should see all rows and budget', async () => {
81
+ const results = await runAsRole(queryRunner, 'admin', 'admin-id',
82
+ 'SELECT name, budget FROM projects'
83
+ );
84
+ expect(results).toHaveLength(2);
85
+ expect(results.some(r => r.name === 'Space Mission' && r.budget === '1000000')).toBe(true);
86
+ });
87
+
88
+ it('Manager A should ONLY see Space Mission and its budget', async () => {
89
+ const results = await runAsRole(queryRunner, 'manager', USER_MANAGER_A,
90
+ 'SELECT name, budget FROM projects'
91
+ );
92
+ expect(results).toHaveLength(1);
93
+ expect(results[0].name).toBe('Space Mission');
94
+ expect(results[0].budget).toBe('1000000');
95
+ });
96
+
97
+ it('Manager B should ONLY see Office Renovation and its budget', async () => {
98
+ const results = await runAsRole(queryRunner, 'manager', USER_MANAGER_B,
99
+ 'SELECT name, budget FROM projects'
100
+ );
101
+ expect(results).toHaveLength(1);
102
+ expect(results[0].name).toBe('Office Renovation');
103
+ expect(results[0].budget).toBe('500');
104
+ });
105
+
106
+ it('Employee should see all projects but NOT budget (CLS)', async () => {
107
+ const results = await runAsRole(queryRunner, 'employee', USER_EMPLOYEE,
108
+ 'SELECT name, description FROM projects'
109
+ );
110
+ expect(results).toHaveLength(2);
111
+
112
+ const operation = runAsRole(queryRunner, 'employee', USER_EMPLOYEE,
113
+ 'SELECT budget FROM projects'
114
+ );
115
+ await expect(operation).rejects.toThrow(/permission denied/);
116
+ });
117
+ });
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ },
8
+ });