@gencow/core 0.1.18 → 0.1.21
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/dist/crud.d.ts +18 -0
- package/dist/crud.js +231 -50
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/rls-db.d.ts +3 -5
- package/dist/rls-db.js +3 -5
- package/dist/rls.d.ts +44 -1
- package/dist/rls.js +62 -2
- package/dist/server.d.ts +1 -0
- package/dist/storage.d.ts +29 -2
- package/dist/storage.js +404 -15
- package/dist/v.js +5 -1
- package/package.json +42 -39
- package/src/__tests__/crud-owner-rls.test.ts +380 -0
- package/src/__tests__/fixtures/basic/auth.ts +32 -0
- package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
- package/src/__tests__/fixtures/basic/index.ts +6 -0
- package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
- package/src/__tests__/fixtures/basic/schema.ts +35 -0
- package/src/__tests__/fixtures/basic/tasks.ts +15 -0
- package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
- package/src/__tests__/helpers/pglite-migrations.ts +35 -0
- package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
- package/src/__tests__/helpers/seed-like-fill.ts +196 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
- package/src/__tests__/image-optimization.test.ts +652 -0
- package/src/__tests__/rls-crud-basic.test.ts +431 -0
- package/src/__tests__/storage.test.ts +113 -0
- package/src/__tests__/tsconfig.json +8 -0
- package/src/__tests__/validator.test.ts +35 -0
- package/src/crud.ts +270 -47
- package/src/index.ts +3 -2
- package/src/rls-db.ts +3 -5
- package/src/rls.ts +87 -3
- package/src/server.ts +1 -0
- package/src/storage.ts +481 -15
- package/src/v.ts +5 -1
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
package/src/crud.ts
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* export const { list, get, create, update, remove } = crud(tasks);
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
|
+
* ownerRls 연동 (2-Layer 방어):
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const tasks = pgTable("tasks", {
|
|
18
|
+
* id: serial("id").primaryKey(),
|
|
19
|
+
* userId: text("user_id").notNull(),
|
|
20
|
+
* }, (t) => ownerRls(t.userId));
|
|
21
|
+
*
|
|
22
|
+
* // crud()가 ownerRls를 자동 감지 → 모든 쿼리에 WHERE userId = auth.userId 주입
|
|
23
|
+
* export const { list, get, create, update, remove } = crud(tasks);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
15
26
|
* list 반환값: `{ data: T[], total: number }`
|
|
16
27
|
* → 프론트: `const result = useQuery(api.tasks.list); result?.data.map(...)`
|
|
17
28
|
* → total은 동일 필터 조건의 전체 레코드 수 (페이지네이션 UI 지원)
|
|
@@ -20,13 +31,26 @@
|
|
|
20
31
|
* 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
|
|
21
32
|
*
|
|
22
33
|
* 📄 Spec: docs/specs/spec-crud-advanced-filters.md (v3), docs/specs/spec-crud-v2-enhancements.md (v2)
|
|
34
|
+
* 📄 ownerRls: docs/specs/spec-ownerrls-complete.md
|
|
23
35
|
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
24
36
|
*/
|
|
25
37
|
|
|
26
38
|
import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
|
|
27
39
|
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
40
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
28
41
|
import { query, mutation } from "./reactive";
|
|
29
42
|
import { v } from "./v";
|
|
43
|
+
import { getOwnerRlsMeta, registerOwnerRls } from "./rls";
|
|
44
|
+
|
|
45
|
+
// ─── ownerRls 감지 추적 (부트 로그용) ──────────────────
|
|
46
|
+
|
|
47
|
+
/** crud()가 ownerRls를 자동 감지한 테이블 목록 — 서버 부트 로그에서 출력 */
|
|
48
|
+
const _ownerRlsTables: { tableName: string; columnName: string; readPublic: boolean }[] = [];
|
|
49
|
+
|
|
50
|
+
/** 부트 로그에서 ownerRls 감지 테이블 목록을 반환 */
|
|
51
|
+
export function getOwnerRlsTables(): readonly { tableName: string; columnName: string; readPublic: boolean }[] {
|
|
52
|
+
return _ownerRlsTables;
|
|
53
|
+
}
|
|
30
54
|
|
|
31
55
|
// ─── Types ──────────────────────────────────────────────
|
|
32
56
|
|
|
@@ -74,6 +98,93 @@ function detectIdType(column: AnyPgColumn) {
|
|
|
74
98
|
return v.number();
|
|
75
99
|
}
|
|
76
100
|
|
|
101
|
+
// ─── ownerRls 감지 ──────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
interface OwnerMetaResolved {
|
|
104
|
+
/** userId 역할을 하는 Drizzle 컬럼 참조 (WHERE 조건에 사용) */
|
|
105
|
+
column: AnyPgColumn;
|
|
106
|
+
/** userId 컬럼의 DB 이름 (e.g. "user_id") */
|
|
107
|
+
columnName: string;
|
|
108
|
+
/** userId 컬럼의 JS 프로퍼티 이름 (e.g. "userId" — INSERT/UPDATE 데이터 키에 사용) */
|
|
109
|
+
propertyName: string;
|
|
110
|
+
/** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
|
|
111
|
+
readPublic: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* DB 컬럼명에서 JS 프로퍼티명을 역매핑한다.
|
|
116
|
+
* getTableColumns()의 키(JS명) → 값(Column.name = DB명) 매핑을 탐색.
|
|
117
|
+
* 매칭 실패 시 columnName을 그대로 반환 (best-effort).
|
|
118
|
+
*/
|
|
119
|
+
function resolvePropertyName(table: PgTable, columnName: string): string {
|
|
120
|
+
try {
|
|
121
|
+
const columns = getTableColumns(table);
|
|
122
|
+
for (const [propName, col] of Object.entries(columns)) {
|
|
123
|
+
if ((col as any).name === columnName) return propName;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// 테스트 환경에서 getTableColumns 실패 가능 — fallback
|
|
127
|
+
}
|
|
128
|
+
return columnName;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 테이블에서 ownerRls 메타데이터를 감지한다.
|
|
133
|
+
*
|
|
134
|
+
* 감지 순서:
|
|
135
|
+
* 1. WeakMap 레지스트리 (registerOwnerRls로 명시 등록된 경우)
|
|
136
|
+
* 2. getTableConfig().policies에서 pgPolicy 존재 여부 + 컬럼 convention
|
|
137
|
+
*
|
|
138
|
+
* Fallback convention: 테이블에 userId/user_id 컬럼이 있고 pgPolicy가 존재하면
|
|
139
|
+
* ownerRls가 적용된 것으로 간주.
|
|
140
|
+
*/
|
|
141
|
+
function detectOwnerMeta(table: PgTable): OwnerMetaResolved | null {
|
|
142
|
+
const anyTable = table as any;
|
|
143
|
+
|
|
144
|
+
// 1. WeakMap 레지스트리에서 조회 (가장 정확)
|
|
145
|
+
const meta = getOwnerRlsMeta(table);
|
|
146
|
+
if (meta) {
|
|
147
|
+
// propertyName 역매핑: DB명 "user_id" → JS명 "userId"
|
|
148
|
+
const propName = resolvePropertyName(table, meta.columnName);
|
|
149
|
+
const col = anyTable[propName] as AnyPgColumn | undefined;
|
|
150
|
+
if (col) {
|
|
151
|
+
return { column: col, columnName: meta.columnName, propertyName: propName, readPublic: meta.readPublic };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. getTableConfig().policies로 pgPolicy 존재 감지 + convention 탐색
|
|
156
|
+
try {
|
|
157
|
+
const config = getTableConfig(table);
|
|
158
|
+
if (config.policies && config.policies.length > 0) {
|
|
159
|
+
// convention: userId 또는 user_id 컬럼 탐색 (JS 프로퍼티명)
|
|
160
|
+
const propName = anyTable["userId"] ? "userId" : anyTable["user_id"] ? "user_id" : null;
|
|
161
|
+
if (propName) {
|
|
162
|
+
const userIdCol = anyTable[propName] as AnyPgColumn;
|
|
163
|
+
const colName = (userIdCol as any).name || "user_id";
|
|
164
|
+
|
|
165
|
+
// 테이블에 메타 등록 (이후 호출에서 WeakMap으로 빠르게 조회)
|
|
166
|
+
registerOwnerRls(table, { columnName: colName, readPublic: false });
|
|
167
|
+
|
|
168
|
+
return { column: userIdCol, columnName: colName, propertyName: propName, readPublic: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// S2 방어: pgPolicy가 존재하지만 convention 컬럼(userId/user_id)이 없는 경우
|
|
172
|
+
// ownerRls()를 호출했으나 비표준 컬럼명(e.g. ownerId, authorId)을 사용한 것으로 의심
|
|
173
|
+
// → silent 무시 방지: 보안 경고 출력
|
|
174
|
+
const tblName = getTableName(table);
|
|
175
|
+
console.warn(
|
|
176
|
+
`[crud] ⚠️ Table "${tblName}" has ${config.policies.length} pgPolicy but no userId/user_id column found. ` +
|
|
177
|
+
`ownerRls auto-isolation will NOT be applied. ` +
|
|
178
|
+
`If you used ownerRls(), ensure the column is named 'userId' (JS) / 'user_id' (DB).`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// getTableConfig 실패 시 무시 (테스트 환경 등)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
77
188
|
// ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
|
|
78
189
|
|
|
79
190
|
/** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
|
|
@@ -231,9 +342,33 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
231
342
|
const createdAtCol = anyTable["createdAt"] as AnyPgColumn | undefined;
|
|
232
343
|
const defaultOrderCol = createdAtCol || pk;
|
|
233
344
|
|
|
234
|
-
// userId 컬럼 (자동 주입용)
|
|
345
|
+
// userId 컬럼 (자동 주입용 — ownerRls 미적용 시 기존 동작)
|
|
235
346
|
const userIdCol = anyTable["userId"] as AnyPgColumn | undefined;
|
|
236
347
|
|
|
348
|
+
// ── ownerRls 감지: 2-Layer 방어의 Layer 1 ──
|
|
349
|
+
// ownerRls() 메타데이터가 감지되면 모든 CRUD에 userId 필터 자동 주입
|
|
350
|
+
const ownerMeta = detectOwnerMeta(table);
|
|
351
|
+
|
|
352
|
+
// 부트 로그용 레지스트리 등록 (S1: 중복 방지 — hot-reload/methods 분할 대응)
|
|
353
|
+
if (ownerMeta && !_ownerRlsTables.some(t => t.tableName === tableName)) {
|
|
354
|
+
_ownerRlsTables.push({
|
|
355
|
+
tableName,
|
|
356
|
+
columnName: ownerMeta.columnName,
|
|
357
|
+
readPublic: ownerMeta.readPublic,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// B1: public + ownerRls 조합 방어
|
|
362
|
+
// ownerRls가 감지된 테이블에서 public: true + readPublic: false 조합은
|
|
363
|
+
// 보안 의도와 모순 → 경고 출력 + CUD는 ownerRls 보호 유지
|
|
364
|
+
if (ownerMeta && isPublic && !ownerMeta.readPublic) {
|
|
365
|
+
console.warn(
|
|
366
|
+
`[crud] ⚠️ Table "${tableName}": ownerRls detected but public=true. ` +
|
|
367
|
+
`CUD operations will still enforce ownerRls (auth required). ` +
|
|
368
|
+
`Consider removing { public: true } or using ownerRls(col, { read: "public" }).`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
237
372
|
// ── 내부 헬퍼: WHERE 조건 빌드 (list + count + realtime 공유) ──
|
|
238
373
|
function buildWhereConditions(args: any): SQL | undefined {
|
|
239
374
|
const conditions: SQL[] = [];
|
|
@@ -268,14 +403,31 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
268
403
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
269
404
|
}
|
|
270
405
|
|
|
406
|
+
/** Uses `db.transaction` when present (e.g. createRlsDb); else runs `fn(db)` for test mocks without `.transaction`. */
|
|
407
|
+
async function inRlsOrPlainTx<T>(db: any, fn: (tx: any) => Promise<T>): Promise<T> {
|
|
408
|
+
if (typeof db?.transaction === "function") {
|
|
409
|
+
return await db.transaction(fn);
|
|
410
|
+
}
|
|
411
|
+
return await fn(db);
|
|
412
|
+
}
|
|
413
|
+
|
|
271
414
|
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
272
|
-
//
|
|
415
|
+
// Runs inside db.transaction so createRlsDb() can SET LOCAL app.current_user_id for RLS.
|
|
273
416
|
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
274
417
|
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
275
|
-
async function fetchListWithTotal(db: any, whereClause?: SQL) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
418
|
+
async function fetchListWithTotal(db: any, whereClause?: SQL, userId?: string) {
|
|
419
|
+
// ownerRls 적용 시 realtime emit에도 userId 필터 추가
|
|
420
|
+
// → 다른 사용자에게 타인 데이터가 push되지 않도록
|
|
421
|
+
let effectiveWhere = whereClause;
|
|
422
|
+
if (ownerMeta && userId && !ownerMeta.readPublic) {
|
|
423
|
+
const ownerFilter = eq(ownerMeta.column, userId);
|
|
424
|
+
effectiveWhere = effectiveWhere ? and(effectiveWhere, ownerFilter) : ownerFilter;
|
|
425
|
+
}
|
|
426
|
+
return await inRlsOrPlainTx(db, async (tx: any) => {
|
|
427
|
+
const data = await tx.select().from(anyTable).where(effectiveWhere).orderBy(desc(defaultOrderCol));
|
|
428
|
+
const countResult = await tx.select({ count: drizzleCount() }).from(anyTable).where(effectiveWhere);
|
|
429
|
+
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
430
|
+
});
|
|
279
431
|
}
|
|
280
432
|
|
|
281
433
|
// ── methods 필터링: 지정된 메서드만 레지스트리 등록 ──
|
|
@@ -295,13 +447,21 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
295
447
|
filters: v.optional(v.any()),
|
|
296
448
|
},
|
|
297
449
|
handler: async (ctx: any, args: any) => {
|
|
298
|
-
|
|
450
|
+
// S2: requireAuth 통합 — ownerRls(readPublic=false)이면 인증 필수
|
|
451
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
452
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
299
453
|
|
|
300
454
|
const page = Math.max(1, args?.page || 1);
|
|
301
455
|
const limit = Math.min(Math.max(1, args?.limit || defaultLimit), maxLimit);
|
|
302
456
|
const offset = (page - 1) * limit;
|
|
303
457
|
|
|
304
|
-
|
|
458
|
+
let whereClause = buildWhereConditions(args);
|
|
459
|
+
|
|
460
|
+
// ── ownerRls Layer 1: list에 userId 필터 주입 ──
|
|
461
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
462
|
+
const ownerFilter = eq(ownerMeta.column, user.id);
|
|
463
|
+
whereClause = whereClause ? and(whereClause, ownerFilter) : ownerFilter;
|
|
464
|
+
}
|
|
305
465
|
|
|
306
466
|
// Order
|
|
307
467
|
let orderByClause;
|
|
@@ -312,21 +472,23 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
312
472
|
orderByClause = desc(defaultOrderCol);
|
|
313
473
|
}
|
|
314
474
|
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
.
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
475
|
+
// One transaction: SET LOCAL user id (createRlsDb) + list + count for RLS.
|
|
476
|
+
return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
477
|
+
const results = await tx.select()
|
|
478
|
+
.from(anyTable)
|
|
479
|
+
.where(whereClause)
|
|
480
|
+
.orderBy(orderByClause)
|
|
481
|
+
.limit(limit)
|
|
482
|
+
.offset(offset);
|
|
483
|
+
const countResult = await tx.select({ count: drizzleCount() })
|
|
484
|
+
.from(anyTable)
|
|
485
|
+
.where(whereClause);
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
data: results,
|
|
489
|
+
total: Number(countResult[0]?.count ?? 0),
|
|
490
|
+
};
|
|
491
|
+
});
|
|
330
492
|
}
|
|
331
493
|
});
|
|
332
494
|
|
|
@@ -336,17 +498,26 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
336
498
|
public: isPublic,
|
|
337
499
|
args: { id: idValidator },
|
|
338
500
|
handler: async (ctx: any, args: any) => {
|
|
339
|
-
|
|
501
|
+
// S2: requireAuth 통합
|
|
502
|
+
const needsAuth = !isPublic || (ownerMeta && !ownerMeta.readPublic);
|
|
503
|
+
const user = needsAuth ? ctx.auth.requireAuth() : null;
|
|
340
504
|
|
|
341
505
|
let whereCond: SQL = eq(pk, args.id);
|
|
342
506
|
|
|
507
|
+
// ── ownerRls Layer 1: get에 userId 필터 주입 ──
|
|
508
|
+
if (ownerMeta && !ownerMeta.readPublic && user) {
|
|
509
|
+
whereCond = and(whereCond, eq(ownerMeta.column, user.id))!;
|
|
510
|
+
}
|
|
511
|
+
|
|
343
512
|
if (options?.softDelete) {
|
|
344
513
|
const sdField = anyTable[options.softDelete.field as string] as AnyPgColumn;
|
|
345
514
|
whereCond = and(whereCond, eq(sdField, null as any))!;
|
|
346
515
|
}
|
|
347
516
|
|
|
348
|
-
|
|
349
|
-
|
|
517
|
+
return await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
518
|
+
const [result] = await tx.select().from(anyTable).where(whereCond).limit(1);
|
|
519
|
+
return result ?? null;
|
|
520
|
+
});
|
|
350
521
|
}
|
|
351
522
|
});
|
|
352
523
|
|
|
@@ -355,12 +526,29 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
355
526
|
const createDef = !enabledMethods.has('create') ? undefined : mutation(`${prefix}.create`, {
|
|
356
527
|
public: isPublic,
|
|
357
528
|
handler: async (ctx: any, args: any) => {
|
|
358
|
-
|
|
529
|
+
// ownerRls 테이블은 항상 인증 필수 (보안 우선)
|
|
530
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
359
531
|
|
|
360
532
|
let insertData = { ...args };
|
|
361
533
|
|
|
362
|
-
//
|
|
363
|
-
|
|
534
|
+
// ── ownerRls Layer 1: create 시 userId 강제 주입 ──
|
|
535
|
+
// 명시적으로 타인 userId를 보낸 경우는 즉시 차단 (fail-closed).
|
|
536
|
+
if (ownerMeta && user) {
|
|
537
|
+
const requestedOwner =
|
|
538
|
+
insertData[ownerMeta.propertyName] ??
|
|
539
|
+
insertData[ownerMeta.columnName] ??
|
|
540
|
+
insertData.userId;
|
|
541
|
+
if (requestedOwner != null && requestedOwner !== user.id) {
|
|
542
|
+
throw new Error("Forbidden: cannot create resource for another user");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// S1 수정: propertyName(JS명)을 키로 사용 — Drizzle insert 호환
|
|
547
|
+
// 사용자 입력을 덮어씀 (보안: 타인 ID로 데이터 생성 방지)
|
|
548
|
+
if (ownerMeta && user) {
|
|
549
|
+
insertData[ownerMeta.propertyName] = user.id;
|
|
550
|
+
} else if (userIdCol && user && !insertData.userId) {
|
|
551
|
+
// ownerRls 미적용 테이블의 기존 자동 주입 동작 유지
|
|
364
552
|
insertData.userId = user.id;
|
|
365
553
|
}
|
|
366
554
|
|
|
@@ -369,11 +557,16 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
369
557
|
insertData = await options.hooks.beforeCreate(insertData);
|
|
370
558
|
}
|
|
371
559
|
|
|
372
|
-
const [result] = await ctx.db
|
|
560
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
|
|
561
|
+
tx.insert(anyTable).values(insertData).returning()
|
|
562
|
+
);
|
|
373
563
|
|
|
374
564
|
// Realtime push — { data, total } 형태로 emit
|
|
565
|
+
// ownerRls 시 해당 사용자 데이터만 push (타 사용자에게 누출 방지)
|
|
566
|
+
// S5: requireAuth로 확실한 userId 획득 (getUserIdentity null 방지)
|
|
375
567
|
if (useRealtime && enabledMethods.has('list')) {
|
|
376
|
-
const
|
|
568
|
+
const currentUserId = user?.id;
|
|
569
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
377
570
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
378
571
|
}
|
|
379
572
|
|
|
@@ -386,12 +579,26 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
386
579
|
const updateDef = !enabledMethods.has('update') ? undefined : mutation(`${prefix}.update`, {
|
|
387
580
|
public: isPublic,
|
|
388
581
|
handler: async (ctx: any, args: any) => {
|
|
389
|
-
|
|
582
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
583
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
390
584
|
|
|
391
585
|
const { id, ...updates } = args;
|
|
392
586
|
|
|
393
587
|
let updateData = { ...updates };
|
|
394
588
|
|
|
589
|
+
// ── ownerRls Layer 1: update 시 userId 변경 차단 + 소유자 검증 ──
|
|
590
|
+
let updateWhere: SQL = eq(pk, id);
|
|
591
|
+
if (ownerMeta && user) {
|
|
592
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 수정 불가)
|
|
593
|
+
updateWhere = and(eq(pk, id), eq(ownerMeta.column, user.id))!;
|
|
594
|
+
// S1 수정: propertyName(JS명)으로 userId 변경 시도 차단
|
|
595
|
+
delete updateData[ownerMeta.propertyName];
|
|
596
|
+
// DB명도 방어적으로 삭제 (만약 클라이언트가 DB명으로 보낸 경우)
|
|
597
|
+
if (ownerMeta.propertyName !== ownerMeta.columnName) {
|
|
598
|
+
delete updateData[ownerMeta.columnName];
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
395
602
|
// updatedAt 자동 갱신
|
|
396
603
|
if (anyTable["updatedAt"]) {
|
|
397
604
|
updateData.updatedAt = new Date();
|
|
@@ -402,15 +609,19 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
402
609
|
updateData = await options.hooks.beforeUpdate(updateData);
|
|
403
610
|
}
|
|
404
611
|
|
|
405
|
-
const [result] = await ctx.db
|
|
406
|
-
.
|
|
407
|
-
|
|
408
|
-
|
|
612
|
+
const [result] = await inRlsOrPlainTx(ctx.db, async (tx: any) =>
|
|
613
|
+
tx.update(anyTable)
|
|
614
|
+
.set(updateData)
|
|
615
|
+
.where(updateWhere)
|
|
616
|
+
.returning()
|
|
617
|
+
);
|
|
409
618
|
|
|
410
619
|
// Realtime push (list + get 양쪽) — { data, total } 형태
|
|
620
|
+
// S5: ownerRls 시 user.id 사용 (getUserIdentity null 방지)
|
|
411
621
|
if (useRealtime) {
|
|
622
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
412
623
|
if (enabledMethods.has('list')) {
|
|
413
|
-
const listResult = await fetchListWithTotal(ctx.db);
|
|
624
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
414
625
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
415
626
|
}
|
|
416
627
|
if (enabledMethods.has('get')) {
|
|
@@ -427,20 +638,32 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
427
638
|
const removeDef = !enabledMethods.has('remove') ? undefined : mutation(`${prefix}.remove`, {
|
|
428
639
|
public: isPublic,
|
|
429
640
|
handler: async (ctx: any, args: any) => {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
await ctx.db.delete(anyTable).where(eq(pk, args.id));
|
|
641
|
+
// ownerRls 테이블은 항상 인증 필수
|
|
642
|
+
const user = (ownerMeta || !isPublic) ? ctx.auth.requireAuth() : null;
|
|
643
|
+
|
|
644
|
+
// ── ownerRls Layer 1: remove 시 소유자 검증 ──
|
|
645
|
+
let deleteWhere: SQL = eq(pk, args.id);
|
|
646
|
+
if (ownerMeta && user) {
|
|
647
|
+
// WHERE id = ? AND user_id = ? (타인 데이터 삭제 불가)
|
|
648
|
+
deleteWhere = and(eq(pk, args.id), eq(ownerMeta.column, user.id))!;
|
|
439
649
|
}
|
|
440
650
|
|
|
651
|
+
await inRlsOrPlainTx(ctx.db, async (tx: any) => {
|
|
652
|
+
if (options?.softDelete) {
|
|
653
|
+
const sdField = options.softDelete.field as string;
|
|
654
|
+
await tx.update(anyTable)
|
|
655
|
+
.set({ [sdField]: new Date() } as any)
|
|
656
|
+
.where(deleteWhere);
|
|
657
|
+
} else {
|
|
658
|
+
await tx.delete(anyTable).where(deleteWhere);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
441
662
|
// Realtime push — { data, total } 형태
|
|
663
|
+
// S5: ownerRls 시 user.id 사용
|
|
442
664
|
if (useRealtime && enabledMethods.has('list')) {
|
|
443
|
-
const
|
|
665
|
+
const currentUserId = ownerMeta ? user?.id : undefined;
|
|
666
|
+
const listResult = await fetchListWithTotal(ctx.db, undefined, currentUserId);
|
|
444
667
|
ctx.realtime.emit(`${prefix}.list`, listResult);
|
|
445
668
|
}
|
|
446
669
|
|
package/src/index.ts
CHANGED
|
@@ -20,9 +20,10 @@ export { defineAuth } from "./auth-config";
|
|
|
20
20
|
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
21
21
|
|
|
22
22
|
// ─── RLS + CRUD Factory ───────────
|
|
23
|
-
export { ownerRls } from "./rls";
|
|
23
|
+
export { ownerRls, getOwnerRlsMeta, registerOwnerRls } from "./rls";
|
|
24
|
+
export type { OwnerRlsMeta } from "./rls";
|
|
24
25
|
export { createRlsDb } from "./rls-db";
|
|
25
|
-
export { crud, parseFilterNode, applyFilterOp } from "./crud";
|
|
26
|
+
export { crud, parseFilterNode, applyFilterOp, getOwnerRlsTables } from "./crud";
|
|
26
27
|
|
|
27
28
|
// Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
|
|
28
29
|
export { crud as gencowCrud } from "./crud";
|
package/src/rls-db.ts
CHANGED
|
@@ -2,12 +2,10 @@ import { sql } from "drizzle-orm";
|
|
|
2
2
|
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* Supabase
|
|
5
|
+
* Wraps Drizzle so `transaction()` runs `set_config('app.current_user_id', ...)` first (RLS policies).
|
|
6
|
+
* Supabase-style session GUC injection.
|
|
7
7
|
*
|
|
8
|
-
* set_config(
|
|
9
|
-
* → is_local=true: 현재 트랜잭션에서만 유효
|
|
10
|
-
* → PgBouncer transaction 모드에서 안전
|
|
8
|
+
* `set_config(..., true)` is transaction-local (safe with PgBouncer transaction pooling).
|
|
11
9
|
*/
|
|
12
10
|
export function createRlsDb(db: PgDatabase<any, any, any>, userId: string) {
|
|
13
11
|
return new Proxy(db, {
|
package/src/rls.ts
CHANGED
|
@@ -1,15 +1,99 @@
|
|
|
1
1
|
import { sql, type SQL } from "drizzle-orm";
|
|
2
|
-
import { pgPolicy, type AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
+
import { pgPolicy, type AnyPgColumn, type PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
|
|
4
|
+
// ─── ownerRls 메타데이터 레지스트리 ─────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// getTableConfig()는 pgPolicy 배열을 복사하므로 부착 프로퍼티가 유실됨.
|
|
7
|
+
// 테이블 → 메타데이터 매핑을 WeakMap으로 관리하여 crud()가 접근.
|
|
8
|
+
// WeakMap이므로 테이블이 GC되면 메타데이터도 자동 해제.
|
|
9
|
+
|
|
10
|
+
export interface OwnerRlsMeta {
|
|
11
|
+
/** userId 컬럼의 DB 이름 (e.g. "user_id") */
|
|
12
|
+
columnName: string;
|
|
13
|
+
/** true면 SELECT에 userId 필터 생략 (누구나 읽기 가능) */
|
|
14
|
+
readPublic: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const _ownerRlsRegistry = new WeakMap<PgTable, OwnerRlsMeta>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 테이블에 등록된 ownerRls 메타데이터를 반환한다.
|
|
21
|
+
* ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
|
|
22
|
+
*/
|
|
23
|
+
export function getOwnerRlsMeta(table: PgTable): OwnerRlsMeta | undefined {
|
|
24
|
+
return _ownerRlsRegistry.get(table);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 내부용: ownerRls()가 호출될 때 메타데이터를 레지스트리에 등록.
|
|
29
|
+
* pgTable의 extraConfig 콜백에서 호출되므로, 테이블 참조가 아닌
|
|
30
|
+
* 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
|
|
31
|
+
*/
|
|
32
|
+
export function registerOwnerRls(table: PgTable, meta: OwnerRlsMeta): void {
|
|
33
|
+
_ownerRlsRegistry.set(table, meta);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── ownerRls — DB-level RLS 정책 선언 + 앱 레벨 메타데이터 등록 ──
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 사용자 소유권 기반 RLS 정책을 선언한다.
|
|
40
|
+
*
|
|
41
|
+
* 2-Layer 방어:
|
|
42
|
+
* - Layer 1 (앱 레벨): crud()가 이 메타데이터를 감지하여
|
|
43
|
+
* 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
|
|
44
|
+
* PGlite + PostgreSQL 양쪽에서 동작.
|
|
45
|
+
* - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
|
|
46
|
+
* createRlsDb()의 transaction() 내에서 set_config 주입.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* export const tasks = pgTable("tasks", {
|
|
51
|
+
* id: serial("id").primaryKey(),
|
|
52
|
+
* title: text("title").notNull(),
|
|
53
|
+
* userId: text("user_id").notNull(),
|
|
54
|
+
* }, (t) => ownerRls(t.userId));
|
|
55
|
+
*
|
|
56
|
+
* // read: "public" — 누구나 읽기 가능, CUD는 소유자만
|
|
57
|
+
* export const posts = pgTable("posts", {
|
|
58
|
+
* id: serial("id").primaryKey(),
|
|
59
|
+
* content: text("content").notNull(),
|
|
60
|
+
* userId: text("user_id").notNull(),
|
|
61
|
+
* }, (t) => ownerRls(t.userId, { read: "public" }));
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
4
64
|
export function ownerRls(
|
|
5
65
|
userIdColumn: AnyPgColumn,
|
|
6
66
|
options?: { read?: "public" },
|
|
7
67
|
) {
|
|
8
|
-
|
|
9
|
-
|
|
68
|
+
// S3 방어: userIdColumn.name 미존재 시 명확한 에러
|
|
69
|
+
const colName = (userIdColumn as any).name;
|
|
70
|
+
if (!colName) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"[ownerRls] userIdColumn must have a .name property. " +
|
|
73
|
+
"Ensure you pass a valid Drizzle column reference (e.g. t.userId)."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
|
|
78
|
+
const isOwner = sql`${userIdColumn} = current_setting('app.current_user_id', true)`;
|
|
79
|
+
|
|
80
|
+
// ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
|
|
81
|
+
const meta: OwnerRlsMeta = {
|
|
82
|
+
columnName: colName,
|
|
83
|
+
readPublic: options?.read === "public",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const policies = [
|
|
10
87
|
pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql`true` : isOwner }),
|
|
11
88
|
pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
|
|
12
89
|
pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
|
|
13
90
|
pgPolicy("rls-delete", { for: "delete", using: isOwner }),
|
|
14
91
|
];
|
|
92
|
+
|
|
93
|
+
// N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
|
|
94
|
+
// ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
|
|
95
|
+
// 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
|
|
96
|
+
// 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
|
|
97
|
+
|
|
98
|
+
return policies;
|
|
15
99
|
}
|
package/src/server.ts
CHANGED
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { createDb } from "./db";
|
|
9
9
|
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
+
export type { StorageImageTierConfig } from "./storage";
|
|
10
11
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
12
|
export { authMiddleware, authRoutes, getUsers } from "./auth";
|