@rebasepro/common 0.6.1 → 0.8.0
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/collections/CollectionRegistry.d.ts +30 -2
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/buildRoutedRebaseData.d.ts +53 -0
- package/dist/data/filter-dialect.d.ts +61 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +43 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.es.js +777 -178
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +793 -176
- package/dist/index.umd.js.map +1 -1
- package/dist/table-classification.d.ts +47 -0
- package/dist/util/builders.d.ts +48 -1
- package/dist/util/callbacks.d.ts +6 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/permissions.d.ts +26 -2
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +10 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/storage.d.ts +26 -1
- package/package.json +3 -3
- package/src/collections/CollectionRegistry.ts +80 -16
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +9 -120
- package/src/data/buildRoutedRebaseData.ts +97 -0
- package/src/data/filter-dialect.ts +318 -0
- package/src/data/query_builder.ts +10 -10
- package/src/data/resolveDataSource.ts +79 -0
- package/src/index.ts +4 -1
- package/src/table-classification.ts +109 -0
- package/src/util/builders.ts +78 -1
- package/src/util/callbacks.ts +8 -1
- package/src/util/index.ts +1 -0
- package/src/util/permissions.test.ts +5 -3
- package/src/util/permissions.ts +85 -158
- package/src/util/policy/evaluatePolicy.ts +146 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +85 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +1 -1
- package/src/util/relations.ts +8 -9
- package/src/util/resolutions.ts +6 -6
- package/src/util/storage.ts +34 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DataSourceDefinition,
|
|
3
|
+
ResolvedDataSource,
|
|
4
|
+
DEFAULT_DATA_SOURCE_KEY,
|
|
5
|
+
getDataSourceCapabilities
|
|
6
|
+
} from "@rebasepro/types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The subset of a collection needed to resolve its data source. Accepting a
|
|
10
|
+
* structural type (rather than the full `EntityCollection`) keeps this usable
|
|
11
|
+
* from anywhere — frontend router, backend registry, editor — without coupling
|
|
12
|
+
* to the collection union.
|
|
13
|
+
*/
|
|
14
|
+
export interface DataSourceResolvable {
|
|
15
|
+
/** Preferred routing key. */
|
|
16
|
+
dataSource?: string;
|
|
17
|
+
/** Engine type discriminant (set on variant collection types). */
|
|
18
|
+
engine?: string;
|
|
19
|
+
/** Within-engine instance. */
|
|
20
|
+
databaseId?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A lookup of data-source definitions by key. */
|
|
24
|
+
export type DataSourceRegistry = Record<string, DataSourceDefinition>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a keyed registry from a list of {@link DataSourceDefinition}s.
|
|
28
|
+
* Later entries win on key collision.
|
|
29
|
+
*/
|
|
30
|
+
export function createDataSourceRegistry(definitions?: DataSourceDefinition[]): DataSourceRegistry {
|
|
31
|
+
const registry: DataSourceRegistry = {};
|
|
32
|
+
for (const def of definitions ?? []) {
|
|
33
|
+
registry[def.key] = def;
|
|
34
|
+
}
|
|
35
|
+
return registry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the effective data source for a collection — the single source of
|
|
40
|
+
* truth shared by the frontend router, the backend driver registry, and the
|
|
41
|
+
* editor's capability lookups.
|
|
42
|
+
*
|
|
43
|
+
* Resolution order:
|
|
44
|
+
* 1. The routing **key** is `collection.dataSource`, else
|
|
45
|
+
* {@link DEFAULT_DATA_SOURCE_KEY}.
|
|
46
|
+
* 2. If a definition is registered for that key, it provides `engine`,
|
|
47
|
+
* `transport`, and `databaseId`.
|
|
48
|
+
* 3. Otherwise values are synthesized: `engine` from `collection.engine`
|
|
49
|
+
* (or the key, or `"postgres"`), `transport` defaults to `"server"`,
|
|
50
|
+
* and `databaseId` from the collection.
|
|
51
|
+
*
|
|
52
|
+
* `capabilities` are always derived from the resolved `engine`, so two
|
|
53
|
+
* data sources sharing an engine share capabilities.
|
|
54
|
+
*
|
|
55
|
+
* @param collection the collection (or any object carrying the routing fields)
|
|
56
|
+
* @param registry optional registry of declared data sources
|
|
57
|
+
*/
|
|
58
|
+
export function resolveDataSource(
|
|
59
|
+
collection: DataSourceResolvable | undefined,
|
|
60
|
+
registry?: DataSourceRegistry
|
|
61
|
+
): ResolvedDataSource {
|
|
62
|
+
const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
|
|
63
|
+
const def = registry?.[key];
|
|
64
|
+
|
|
65
|
+
const engine = def?.engine
|
|
66
|
+
?? collection?.engine
|
|
67
|
+
?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
|
|
68
|
+
|
|
69
|
+
const transport = def?.transport ?? "server";
|
|
70
|
+
const databaseId = collection?.databaseId ?? def?.databaseId;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
key,
|
|
74
|
+
engine,
|
|
75
|
+
transport,
|
|
76
|
+
databaseId,
|
|
77
|
+
capabilities: getDataSourceCapabilities(engine)
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export * from "./util";
|
|
2
2
|
export * from "./collections";
|
|
3
3
|
export * from "./data/buildRebaseData";
|
|
4
|
+
export * from "./data/buildRoutedRebaseData";
|
|
5
|
+
export * from "./data/resolveDataSource";
|
|
4
6
|
export * from "./data/query_builder";
|
|
5
|
-
|
|
7
|
+
export * from "./data/filter-dialect";
|
|
8
|
+
export * from "./table-classification";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table Classification
|
|
3
|
+
*
|
|
4
|
+
* Shared constants and pure functions for classifying database tables.
|
|
5
|
+
* Used by both the server-side PostgresBackendDriver and the Studio RLS editor.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Possible categories a database table can belong to. */
|
|
9
|
+
export type TableCategory = "rebase-internal" | "junction" | "user";
|
|
10
|
+
|
|
11
|
+
/** Schemas that are always considered Rebase-internal. */
|
|
12
|
+
export const REBASE_INTERNAL_SCHEMAS: readonly string[] = ["rebase", "auth"];
|
|
13
|
+
|
|
14
|
+
/** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
|
|
15
|
+
export const REBASE_INTERNAL_PREFIXES: readonly string[] = [
|
|
16
|
+
"_rebase_",
|
|
17
|
+
"_auth_",
|
|
18
|
+
"drizzle_",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Synchronously classify a table based on naming conventions.
|
|
23
|
+
*
|
|
24
|
+
* @param tableName - The unqualified name of the table.
|
|
25
|
+
* @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
|
|
26
|
+
* @returns `"rebase-internal"` when the table belongs to a reserved schema or
|
|
27
|
+
* carries a reserved prefix; `"user"` otherwise.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* Junction-table detection requires an async database query and is therefore
|
|
31
|
+
* **not** handled by this function. Use {@link detectJunctionTables} to obtain
|
|
32
|
+
* the set of junction tables, then reclassify as needed.
|
|
33
|
+
*/
|
|
34
|
+
export function classifyTable(
|
|
35
|
+
tableName: string,
|
|
36
|
+
schemaName: string,
|
|
37
|
+
): TableCategory {
|
|
38
|
+
if (
|
|
39
|
+
REBASE_INTERNAL_SCHEMAS.includes(schemaName) ||
|
|
40
|
+
REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))
|
|
41
|
+
) {
|
|
42
|
+
return "rebase-internal";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return "user";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convenience predicate that checks whether a table is Rebase-internal.
|
|
50
|
+
*
|
|
51
|
+
* @param tableName - The unqualified name of the table.
|
|
52
|
+
* @param schemaName - The schema the table belongs to.
|
|
53
|
+
* @returns `true` if the table is classified as `"rebase-internal"`.
|
|
54
|
+
*/
|
|
55
|
+
export function isRebaseInternalTable(
|
|
56
|
+
tableName: string,
|
|
57
|
+
schemaName: string,
|
|
58
|
+
): boolean {
|
|
59
|
+
return classifyTable(tableName, schemaName) === "rebase-internal";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** SQL query that detects junction tables in the `public` schema. */
|
|
63
|
+
export const JUNCTION_TABLES_SQL = `
|
|
64
|
+
SELECT t.table_name
|
|
65
|
+
FROM information_schema.tables t
|
|
66
|
+
WHERE t.table_schema = 'public'
|
|
67
|
+
AND t.table_type = 'BASE TABLE'
|
|
68
|
+
AND NOT EXISTS (
|
|
69
|
+
SELECT 1
|
|
70
|
+
FROM information_schema.columns c
|
|
71
|
+
WHERE c.table_schema = t.table_schema
|
|
72
|
+
AND c.table_name = t.table_name
|
|
73
|
+
AND c.column_name NOT IN (
|
|
74
|
+
SELECT kcu.column_name
|
|
75
|
+
FROM information_schema.key_column_usage kcu
|
|
76
|
+
JOIN information_schema.table_constraints tc
|
|
77
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
78
|
+
AND tc.table_schema = kcu.table_schema
|
|
79
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
80
|
+
AND kcu.table_schema = t.table_schema
|
|
81
|
+
AND kcu.table_name = t.table_name
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Asynchronously detect junction (link) tables in the `public` schema.
|
|
88
|
+
*
|
|
89
|
+
* A junction table is defined as a table where **every** column participates in
|
|
90
|
+
* at least one foreign-key constraint.
|
|
91
|
+
*
|
|
92
|
+
* @param executeSql - A callback that executes a raw SQL string and returns the
|
|
93
|
+
* resulting rows.
|
|
94
|
+
* @returns A `Set` containing the names of all detected junction tables.
|
|
95
|
+
*/
|
|
96
|
+
export async function detectJunctionTables(
|
|
97
|
+
executeSql: (sql: string) => Promise<Record<string, unknown>[]>,
|
|
98
|
+
): Promise<Set<string>> {
|
|
99
|
+
const rows = await executeSql(JUNCTION_TABLES_SQL);
|
|
100
|
+
const junctionTables = new Set<string>();
|
|
101
|
+
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
if (typeof row.table_name === "string") {
|
|
104
|
+
junctionTables.add(row.table_name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return junctionTables;
|
|
109
|
+
}
|
package/src/util/builders.ts
CHANGED
|
@@ -7,9 +7,17 @@ import {
|
|
|
7
7
|
EntityCollection,
|
|
8
8
|
EnumValueConfig,
|
|
9
9
|
EnumValues,
|
|
10
|
+
FirebaseCollection,
|
|
11
|
+
FirebaseProperties,
|
|
10
12
|
GeopointProperty,
|
|
13
|
+
InferEntityType,
|
|
11
14
|
MapProperty,
|
|
12
|
-
|
|
15
|
+
MongoDBCollection,
|
|
16
|
+
MongoProperties,
|
|
17
|
+
NumberProperty,
|
|
18
|
+
PostgresCollection,
|
|
19
|
+
PostgresProperties,
|
|
20
|
+
Properties,
|
|
13
21
|
Property,
|
|
14
22
|
ReferenceProperty,
|
|
15
23
|
StringProperty,
|
|
@@ -32,6 +40,75 @@ export function buildCollection<
|
|
|
32
40
|
return collection;
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
// ── defineCollection ─────────────────────────────────────────────────────
|
|
44
|
+
// A smarter builder that uses `const` type-parameter inference (TS 5.0+)
|
|
45
|
+
// to capture literal property types automatically. This gives you
|
|
46
|
+
// autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
|
|
47
|
+
// callbacks, etc. — without writing `as const` or passing manual generics.
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Define a PostgreSQL-backed collection with full type inference.
|
|
51
|
+
*
|
|
52
|
+
* The `const P` generic captures literal property types from your
|
|
53
|
+
* `properties` object, which enables autocomplete on `titleProperty`,
|
|
54
|
+
* `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const products = defineCollection({
|
|
59
|
+
* name: "Products",
|
|
60
|
+
* slug: "products",
|
|
61
|
+
* table: "products",
|
|
62
|
+
* properties: {
|
|
63
|
+
* name: { name: "Name", type: "string", validation: { required: true } },
|
|
64
|
+
* price: { name: "Price", type: "number" },
|
|
65
|
+
* },
|
|
66
|
+
* titleProperty: "name", // ✅ autocomplete: "name" | "price"
|
|
67
|
+
* sort: ["price", "asc"], // ✅ autocomplete on first element
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @group Builder
|
|
72
|
+
*/
|
|
73
|
+
export function defineCollection<
|
|
74
|
+
const P extends PostgresProperties,
|
|
75
|
+
USER extends User = User
|
|
76
|
+
>(
|
|
77
|
+
collection: Omit<PostgresCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
78
|
+
): PostgresCollection<InferEntityType<P>, USER> & { properties: P };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Define a Firestore-backed collection with full type inference.
|
|
82
|
+
* @group Builder
|
|
83
|
+
*/
|
|
84
|
+
export function defineCollection<
|
|
85
|
+
const P extends FirebaseProperties,
|
|
86
|
+
USER extends User = User
|
|
87
|
+
>(
|
|
88
|
+
collection: Omit<FirebaseCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
89
|
+
): FirebaseCollection<InferEntityType<P>, USER> & { properties: P };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Define a MongoDB-backed collection with full type inference.
|
|
93
|
+
* @group Builder
|
|
94
|
+
*/
|
|
95
|
+
export function defineCollection<
|
|
96
|
+
const P extends MongoProperties,
|
|
97
|
+
USER extends User = User
|
|
98
|
+
>(
|
|
99
|
+
collection: Omit<MongoDBCollection<InferEntityType<P>, USER>, "properties"> & { properties: P }
|
|
100
|
+
): MongoDBCollection<InferEntityType<P>, USER> & { properties: P };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Implementation — delegates to the correct overload at the type level.
|
|
104
|
+
* At runtime this is a plain identity function.
|
|
105
|
+
*/
|
|
106
|
+
export function defineCollection(
|
|
107
|
+
collection: EntityCollection
|
|
108
|
+
): EntityCollection {
|
|
109
|
+
return collection;
|
|
110
|
+
}
|
|
111
|
+
|
|
35
112
|
/**
|
|
36
113
|
* Identity function we use to defeat the type system of Typescript and preserve
|
|
37
114
|
* the property keys.
|
package/src/util/callbacks.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import { EntityCallbacks, Properties } from "@rebasepro/types";
|
|
1
|
+
import { EntityCallbacks, Properties, RebaseCallContext } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context passed to entity lifecycle callbacks.
|
|
5
|
+
* @group Models
|
|
6
|
+
*/
|
|
7
|
+
export type EntityCallbackContext = RebaseCallContext;
|
|
8
|
+
|
|
2
9
|
|
|
3
10
|
/**
|
|
4
11
|
* Helper function to recursively check if there are any callbacks in the properties.
|
package/src/util/index.ts
CHANGED
|
@@ -162,9 +162,10 @@ roles: ["author"] }
|
|
|
162
162
|
expect(canReadCollection(collection, mockAuthController)).toBe(true);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
test("11. Empty roles array []
|
|
165
|
+
test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
|
|
166
166
|
const collection = createMockCollection([
|
|
167
167
|
{ operation: "insert",
|
|
168
|
+
access: "public",
|
|
168
169
|
roles: [] }
|
|
169
170
|
]);
|
|
170
171
|
expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
|
|
@@ -172,9 +173,10 @@ roles: [] }
|
|
|
172
173
|
expect(canCreateEntity(collection, adminAuthController, "test", null)).toBe(true);
|
|
173
174
|
});
|
|
174
175
|
|
|
175
|
-
test("12. Undefined roles on rule grants access to everyone
|
|
176
|
+
test("12. Undefined roles on a public rule grants access to everyone", () => {
|
|
176
177
|
const collection = createMockCollection([
|
|
177
|
-
{ operation: "insert"
|
|
178
|
+
{ operation: "insert",
|
|
179
|
+
access: "public" }
|
|
178
180
|
]);
|
|
179
181
|
expect(canCreateEntity(collection, mockAuthController, "test", null)).toBe(true);
|
|
180
182
|
expect(canCreateEntity(collection, unauthenticatedController, "test", null)).toBe(true);
|
package/src/util/permissions.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Entity, EntityCollection, getDataSourceCapabilities, SecurityOperation, SecurityRule, User } from "@rebasepro/types";
|
|
2
|
+
import { securityRuleToConditions } from "./policy/securityRuleToConditions";
|
|
3
|
+
import { evaluatePolicy, PolicyEvalContext, TriState } from "./policy/evaluatePolicy";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Minimal auth context for permission checking.
|
|
@@ -9,195 +11,120 @@ export interface AuthContext<USER extends User = User> {
|
|
|
9
11
|
user: USER | null;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (cleanedSQL[i] === "(") openCount++;
|
|
24
|
-
else if (cleanedSQL[i] === ")") openCount--;
|
|
25
|
-
if (openCount === 0) {
|
|
26
|
-
isEnclosing = false;
|
|
27
|
-
break;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (isEnclosing) {
|
|
31
|
-
cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
|
|
32
|
-
} else {
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 2. Split top-level OR / AND
|
|
38
|
-
const splitByTopLevel = (str: string, delimiter: string) => {
|
|
39
|
-
const parts: string[] = [];
|
|
40
|
-
let current = "";
|
|
41
|
-
let openCount = 0;
|
|
42
|
-
let i = 0;
|
|
43
|
-
while (i < str.length) {
|
|
44
|
-
if (str[i] === "(") openCount++;
|
|
45
|
-
else if (str[i] === ")") openCount--;
|
|
46
|
-
|
|
47
|
-
if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
|
|
48
|
-
parts.push(current);
|
|
49
|
-
current = "";
|
|
50
|
-
i += delimiter.length;
|
|
51
|
-
} else {
|
|
52
|
-
current += str[i];
|
|
53
|
-
i++;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
parts.push(current);
|
|
57
|
-
return parts;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const orParts = splitByTopLevel(cleanedSQL, " OR ");
|
|
61
|
-
if (orParts.length > 1) {
|
|
62
|
-
return orParts.some(part => evaluateAST(part, auth, entity));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const andParts = splitByTopLevel(cleanedSQL, " AND ");
|
|
66
|
-
if (andParts.length > 1) {
|
|
67
|
-
return andParts.every(part => evaluateAST(part, auth, entity));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const upperSQL = cleanedSQL.toUpperCase();
|
|
71
|
-
|
|
72
|
-
// 3. Fallback for unparseable complex queries
|
|
73
|
-
if (upperSQL.includes(" IN ") || upperSQL.includes(" EXISTS ")) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 4. Role array checks
|
|
78
|
-
// Pattern: `string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']`
|
|
79
|
-
const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
|
|
80
|
-
if (roleIntersectMatch && roleIntersectMatch[1]) {
|
|
81
|
-
const requiredRoles = roleIntersectMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
82
|
-
const userRoles = auth.user?.roles || [];
|
|
83
|
-
return requiredRoles.some(r => userRoles.includes(r));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Pattern: `string_to_array(auth.roles(), ',') @> ARRAY['admin']`
|
|
87
|
-
const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
|
|
88
|
-
if (roleContainMatch && roleContainMatch[1]) {
|
|
89
|
-
const requiredRoles = roleContainMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
|
|
90
|
-
const userRoles = auth.user?.roles || [];
|
|
91
|
-
return requiredRoles.every(r => userRoles.includes(r));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 5. Existing ID patterns
|
|
95
|
-
const pattern1 = new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
|
|
96
|
-
const pattern2 = new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
|
|
97
|
-
|
|
98
|
-
const match1 = cleanedSQL.match(pattern1);
|
|
99
|
-
if (match1 && match1[1]) {
|
|
100
|
-
return entity.values[match1[1]] === auth.user?.uid;
|
|
101
|
-
}
|
|
14
|
+
/**
|
|
15
|
+
* How to resolve a policy result that cannot be decided client-side (a raw-SQL
|
|
16
|
+
* escape-hatch rule, or a row-column reference with no row in hand).
|
|
17
|
+
*
|
|
18
|
+
* - `"allow"` (default): optimistic — used for admin-UI gating, where Postgres
|
|
19
|
+
* remains the authoritative gate and hiding a working action is worse than
|
|
20
|
+
* showing one the server may reject.
|
|
21
|
+
* - `"deny"`: fail-closed — used by real enforcement callers (e.g. a driver
|
|
22
|
+
* applying policies in-process), so an undecidable rule never silently allows.
|
|
23
|
+
*/
|
|
24
|
+
export type UnknownResolution = "allow" | "deny";
|
|
102
25
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
26
|
+
export interface CheckOperationOptions {
|
|
27
|
+
onUnknown?: UnknownResolution;
|
|
28
|
+
}
|
|
107
29
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const value = simpleEqualityMatch[3];
|
|
115
|
-
const entityValue = entity.values[field];
|
|
116
|
-
if (operator === "=") return entityValue === value;
|
|
117
|
-
if (operator === "!=") return entityValue !== value;
|
|
118
|
-
}
|
|
30
|
+
/** Combine clause results with AND under three-valued (Kleene) logic. */
|
|
31
|
+
function kleeneAnd(values: TriState[]): TriState {
|
|
32
|
+
if (values.some(v => v === false)) return false;
|
|
33
|
+
if (values.some(v => v === "unknown")) return "unknown";
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
119
36
|
|
|
120
|
-
|
|
37
|
+
/** The operations a rule covers, mirroring the Postgres generator's resolution. */
|
|
38
|
+
function ruleOperations(rule: SecurityRule): SecurityOperation[] {
|
|
39
|
+
return rule.operations && rule.operations.length > 0
|
|
40
|
+
? rule.operations
|
|
41
|
+
: [rule.operation ?? "all"];
|
|
121
42
|
}
|
|
122
43
|
|
|
123
|
-
function
|
|
44
|
+
function ruleApplies(rule: SecurityRule, targetOperation: SecurityOperation): boolean {
|
|
45
|
+
const ops = ruleOperations(rule);
|
|
46
|
+
return ops.includes(targetOperation) || ops.includes("all");
|
|
47
|
+
}
|
|
124
48
|
|
|
125
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Evaluate a single rule for one operation, returning a tri-state.
|
|
51
|
+
*
|
|
52
|
+
* A `null` clause (the rule contributes no condition for a required clause)
|
|
53
|
+
* denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
|
|
54
|
+
* in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
|
|
55
|
+
* INSERT/UPDATE; both must pass for UPDATE.
|
|
56
|
+
*/
|
|
57
|
+
function evaluateRuleForOperation(rule: SecurityRule, ctx: PolicyEvalContext, targetOperation: SecurityOperation): TriState {
|
|
58
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
59
|
+
const clause = (expr: typeof usingExpr): TriState => expr === null ? false : evaluatePolicy(expr, ctx);
|
|
126
60
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// null entity: optimistic — we can't evaluate ownership without data
|
|
130
|
-
// Fall through to SQL checks below (if any). If none, will return true.
|
|
131
|
-
} else {
|
|
132
|
-
// Entity present: strictly check ownership. Fail immediately if mismatch.
|
|
133
|
-
if (entity.values[rule.ownerField] !== auth.user?.uid) return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
61
|
+
const needsUsing = targetOperation !== "insert";
|
|
62
|
+
const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
|
|
136
63
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
|
|
64
|
+
const results: TriState[] = [];
|
|
65
|
+
if (needsUsing) results.push(clause(usingExpr));
|
|
66
|
+
if (needsWithCheck) results.push(clause(withCheckExpr));
|
|
67
|
+
return kleeneAnd(results);
|
|
68
|
+
}
|
|
143
69
|
|
|
144
|
-
|
|
70
|
+
function resolveTriState(value: TriState, onUnknown: UnknownResolution): boolean {
|
|
71
|
+
if (value === "unknown") return onUnknown === "allow";
|
|
72
|
+
return value;
|
|
145
73
|
}
|
|
146
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Decide whether an operation is permitted for a user on a (possibly null) row,
|
|
77
|
+
* by evaluating the collection's security rules with the shared policy model —
|
|
78
|
+
* the same model compiled to Postgres RLS DDL, so the decision matches database
|
|
79
|
+
* enforcement for every non-raw rule.
|
|
80
|
+
*
|
|
81
|
+
* @param options.onUnknown how to treat rules that cannot be decided
|
|
82
|
+
* client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
|
|
83
|
+
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
84
|
+
*/
|
|
147
85
|
export function checkOperation<M extends Record<string, unknown>, USER extends User>(
|
|
148
86
|
collection: EntityCollection<M>,
|
|
149
87
|
authContext: AuthContext<USER>,
|
|
150
88
|
entity: Entity<M> | null,
|
|
151
|
-
targetOperation:
|
|
89
|
+
targetOperation: SecurityOperation,
|
|
90
|
+
options?: CheckOperationOptions
|
|
152
91
|
): boolean {
|
|
153
|
-
const
|
|
92
|
+
const onUnknown = options?.onUnknown ?? "allow";
|
|
93
|
+
const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : undefined;
|
|
154
94
|
if (!securityRules || securityRules.length === 0) {
|
|
155
95
|
return true;
|
|
156
96
|
}
|
|
157
97
|
|
|
158
|
-
const applicableRules = securityRules.filter((r: SecurityRule) =>
|
|
159
|
-
r.operation === targetOperation ||
|
|
160
|
-
r.operation === "all" ||
|
|
161
|
-
r.operations?.includes(targetOperation) ||
|
|
162
|
-
r.operations?.includes("all")
|
|
163
|
-
);
|
|
164
|
-
|
|
98
|
+
const applicableRules = securityRules.filter((r: SecurityRule) => ruleApplies(r, targetOperation));
|
|
165
99
|
if (applicableRules.length === 0) return false;
|
|
166
100
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if (roleApplicableRules.length === 0) return false;
|
|
101
|
+
const ctx: PolicyEvalContext = {
|
|
102
|
+
uid: authContext.user?.uid,
|
|
103
|
+
roles: authContext.user?.roles ?? [],
|
|
104
|
+
entity
|
|
105
|
+
};
|
|
175
106
|
|
|
176
107
|
let grantedByPermissive = false;
|
|
177
108
|
let deniedByRestrictive = false;
|
|
109
|
+
let hasPermissive = false;
|
|
178
110
|
|
|
179
|
-
for (const rule of
|
|
111
|
+
for (const rule of applicableRules) {
|
|
180
112
|
const mode = rule.mode || "permissive";
|
|
181
|
-
const passed =
|
|
113
|
+
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
|
|
182
114
|
|
|
183
|
-
if (mode === "restrictive"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
115
|
+
if (mode === "restrictive") {
|
|
116
|
+
if (!passed) {
|
|
117
|
+
deniedByRestrictive = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
hasPermissive = true;
|
|
122
|
+
if (passed) grantedByPermissive = true;
|
|
190
123
|
}
|
|
191
124
|
}
|
|
192
125
|
|
|
193
126
|
if (deniedByRestrictive) return false;
|
|
194
|
-
|
|
195
|
-
const hasPermissive = roleApplicableRules.some((r: SecurityRule) => (r.mode || "permissive") === "permissive");
|
|
196
|
-
if (hasPermissive) {
|
|
197
|
-
return grantedByPermissive;
|
|
198
|
-
} else {
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
127
|
+
return hasPermissive ? grantedByPermissive : false;
|
|
201
128
|
}
|
|
202
129
|
|
|
203
130
|
export function canReadCollection<M extends Record<string, unknown>, USER extends User>
|