@rebasepro/common 0.13.0 → 0.13.1-canary.g18cfeb7
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/data/buildRebaseData.d.ts +10 -1
- package/dist/data/filter-dialect.d.ts +15 -1
- package/dist/data/paginate.d.ts +20 -0
- package/dist/data/resolveDataSource.d.ts +36 -0
- package/dist/index.es.js +405 -43
- package/dist/index.es.js.map +1 -1
- package/dist/util/builders.d.ts +2 -2
- package/dist/util/conditions.d.ts +7 -3
- package/dist/util/entities.d.ts +8 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/internal-tables.d.ts +95 -0
- package/dist/util/policy/sqlToPolicy.d.ts +4 -4
- package/dist/util/relations.d.ts +18 -1
- package/dist/util/resolutions.d.ts +31 -0
- package/package.json +3 -3
- package/src/data/buildRebaseData.ts +100 -16
- package/src/data/filter-dialect.ts +28 -3
- package/src/data/paginate.ts +30 -0
- package/src/data/resolveDataSource.ts +56 -0
- package/src/util/builders.ts +3 -3
- package/src/util/conditions.ts +8 -3
- package/src/util/entities.ts +15 -1
- package/src/util/index.ts +1 -0
- package/src/util/internal-tables.ts +154 -0
- package/src/util/policy/policyToPostgres.ts +23 -10
- package/src/util/policy/sqlToPolicy.ts +40 -20
- package/src/util/relations.ts +31 -0
- package/src/util/resolutions.ts +77 -5
package/src/util/conditions.ts
CHANGED
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
ArrayProperty,
|
|
4
4
|
AuthState,
|
|
5
5
|
ConditionContext,
|
|
6
|
+
ConditionRule,
|
|
6
7
|
EnumValueConfig,
|
|
7
|
-
JsonLogicRule,
|
|
8
8
|
NumberProperty,
|
|
9
9
|
PropertyConditions,
|
|
10
10
|
Property,
|
|
@@ -66,9 +66,14 @@ export function registerConditionOperations(): void {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Evaluate a
|
|
69
|
+
* Evaluate a condition against the given context.
|
|
70
|
+
*
|
|
71
|
+
* A condition may be stated as a literal instead of a rule — `hidden: true`
|
|
72
|
+
* rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
|
|
73
|
+
* answer, so it is returned rather than handed to the evaluator.
|
|
70
74
|
*/
|
|
71
|
-
export function evaluateCondition(rule:
|
|
75
|
+
export function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown {
|
|
76
|
+
if (typeof rule === "boolean") return rule;
|
|
72
77
|
// Ensure operations are registered
|
|
73
78
|
registerConditionOperations();
|
|
74
79
|
return jsonLogic.apply(rule, context);
|
package/src/util/entities.ts
CHANGED
|
@@ -143,10 +143,24 @@ export function getRelationFrom<M extends Record<string, unknown>>(entity: Entit
|
|
|
143
143
|
* have `id` and `path` fields — these are relation-shaped objects from
|
|
144
144
|
* edge cases in the data pipeline (REST fallback, stale cache, custom data source).
|
|
145
145
|
*
|
|
146
|
+
* When `targetPath` is given, also accepts a bare id. A relation column is a
|
|
147
|
+
* foreign key, and the REST layer returns it as the scalar it is; only some
|
|
148
|
+
* fetch paths hydrate it into an object. Which form a caller sees therefore
|
|
149
|
+
* depends on how the row was loaded, and a caller that only accepted objects
|
|
150
|
+
* reported half of its own data as a type error. The declared target is the
|
|
151
|
+
* missing half: with it, an id is a relation that has not been fetched yet.
|
|
152
|
+
*
|
|
146
153
|
* Returns null if the value cannot be coerced.
|
|
147
154
|
*/
|
|
148
|
-
export function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null {
|
|
155
|
+
export function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {
|
|
149
156
|
if (value instanceof EntityRelation) return value;
|
|
157
|
+
|
|
158
|
+
if (targetPath && (typeof value === "string" || typeof value === "number")) {
|
|
159
|
+
// An empty string is an unset foreign key, not row "".
|
|
160
|
+
if (value === "") return null;
|
|
161
|
+
return new EntityRelation(value, targetPath);
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
151
165
|
|
|
152
166
|
const obj = value as Record<string, unknown>;
|
package/src/util/index.ts
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
|
|
3
|
+
* end-user role away from them.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
|
|
8
|
+
* provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
|
|
9
|
+
* in the schemas a project uses — including `rebase`, because a project's own
|
|
10
|
+
* collections are allowed to live there (the scaffold puts `users` there). It
|
|
11
|
+
* also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
|
|
12
|
+
* migrating role inherits the same grant.
|
|
13
|
+
*
|
|
14
|
+
* Every framework-internal table is created later: auth's tables come up during
|
|
15
|
+
* `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
|
|
16
|
+
* job registers, `idempotency_keys` on the first request that carries a key. So
|
|
17
|
+
* they all inherited full DML for the end-user role — and none of them enables
|
|
18
|
+
* row-level security, because none of them is a collection with
|
|
19
|
+
* `securityRules`. Measured on a freshly provisioned database, `SET ROLE
|
|
20
|
+
* rebase_user` could read `rebase.refresh_tokens` (session token hashes),
|
|
21
|
+
* `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
|
|
22
|
+
* `rebase.api_keys` (including its `admin` flag), and insert into
|
|
23
|
+
* `rebase.app_config`.
|
|
24
|
+
*
|
|
25
|
+
* Nothing routes a user-context query at those tables today, so this was not
|
|
26
|
+
* reachable over the API. That is the wrong thing to depend on: the documented
|
|
27
|
+
* model is that RLS is the authorization boundary, and these tables sat outside
|
|
28
|
+
* it. The boundary is now a privilege boundary instead — the role simply cannot
|
|
29
|
+
* address them.
|
|
30
|
+
*
|
|
31
|
+
* ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
|
|
32
|
+
*
|
|
33
|
+
* RLS with no policy denies every row, which is the same outcome, but it is the
|
|
34
|
+
* *weaker* statement: it leaves the grant in place, so a later policy — or a
|
|
35
|
+
* `FORCE` flag cleared by some future migration — reopens the table. There is no
|
|
36
|
+
* row of `refresh_tokens` any end user should ever reach, so the honest encoding
|
|
37
|
+
* is "this role has no privilege here at all". It also keeps the owner
|
|
38
|
+
* connection (which auth actually runs on) completely unaffected.
|
|
39
|
+
*
|
|
40
|
+
* ## Keeping it true
|
|
41
|
+
*
|
|
42
|
+
* `packages/rls-check` scans the `rebase` schema — it used to skip it as a
|
|
43
|
+
* "platform" schema — and its `rls-disabled` check fires on exactly the
|
|
44
|
+
* condition this module removes: RLS off *and* a DML grant to a reachable role.
|
|
45
|
+
* So a table added here without a revoke is caught by `pnpm rls:check`, not by
|
|
46
|
+
* someone re-reading this file.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The Postgres role authenticated requests run as.
|
|
51
|
+
*
|
|
52
|
+
* Defined here rather than in the Postgres driver because both the driver (which
|
|
53
|
+
* provisions the role) and this module (which revokes on its behalf) need it,
|
|
54
|
+
* and a second spelling of a role name is a silent no-op waiting to happen.
|
|
55
|
+
*/
|
|
56
|
+
export const REBASE_USER_ROLE = "rebase_user";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Framework-internal table names, unqualified.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT including `users`: the auth user table is also a collection,
|
|
62
|
+
* with `securityRules`, RLS enabled and policies applied. Users read their own
|
|
63
|
+
* row through it — revoking there would break sign-in.
|
|
64
|
+
*
|
|
65
|
+
* `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
|
|
66
|
+
* because `db migrate apply` passes `--revisions-schema rebase`.
|
|
67
|
+
*/
|
|
68
|
+
export const REBASE_INTERNAL_TABLES: readonly string[] = [
|
|
69
|
+
// auth
|
|
70
|
+
"user_identities",
|
|
71
|
+
"refresh_tokens",
|
|
72
|
+
"password_reset_tokens",
|
|
73
|
+
"magic_link_tokens",
|
|
74
|
+
"mfa_factors",
|
|
75
|
+
"mfa_challenges",
|
|
76
|
+
"recovery_codes",
|
|
77
|
+
"app_config",
|
|
78
|
+
"schema_meta",
|
|
79
|
+
// platform services
|
|
80
|
+
"api_keys",
|
|
81
|
+
"cron_logs",
|
|
82
|
+
"cron_claims",
|
|
83
|
+
"idempotency_keys",
|
|
84
|
+
"entity_history",
|
|
85
|
+
"branches",
|
|
86
|
+
// realtime channels — authorization for these lives in the channel rules the
|
|
87
|
+
// server evaluates before it reads or writes, never in a row policy
|
|
88
|
+
"channel_messages",
|
|
89
|
+
"channel_cursors",
|
|
90
|
+
"channel_presence",
|
|
91
|
+
// migration bookkeeping
|
|
92
|
+
"atlas_schema_revisions"
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Postgres identifiers this module is willing to interpolate. */
|
|
96
|
+
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A single statement that takes every privilege on `schema.table` away from the
|
|
100
|
+
* end-user role.
|
|
101
|
+
*
|
|
102
|
+
* Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
|
|
103
|
+
* happen in practice:
|
|
104
|
+
*
|
|
105
|
+
* - the role does not exist when the connection is unprivileged (Rebase then
|
|
106
|
+
* relies on native RLS rather than a role switch), and a bare `REVOKE` on a
|
|
107
|
+
* missing role is an error, not a no-op;
|
|
108
|
+
* - the table may not exist yet — `cron_logs` never appears in a project with
|
|
109
|
+
* no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
|
|
110
|
+
*
|
|
111
|
+
* One command, so it is safe on handles that speak the extended query protocol
|
|
112
|
+
* and reject multi-statement strings.
|
|
113
|
+
*/
|
|
114
|
+
export function revokeInternalTableSql(schema: string, table: string): string {
|
|
115
|
+
if (!SAFE_IDENTIFIER.test(schema)) {
|
|
116
|
+
throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!SAFE_IDENTIFIER.test(table)) {
|
|
119
|
+
throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
|
|
120
|
+
}
|
|
121
|
+
const qualified = `"${schema}"."${table}"`;
|
|
122
|
+
return `
|
|
123
|
+
DO $rebase_revoke$
|
|
124
|
+
BEGIN
|
|
125
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
|
|
126
|
+
AND to_regclass('${qualified}') IS NOT NULL THEN
|
|
127
|
+
EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
|
|
128
|
+
END IF;
|
|
129
|
+
END
|
|
130
|
+
$rebase_revoke$;
|
|
131
|
+
`.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Revoke on every internal table in `schema`, one statement at a time.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort per table: a connection that does not own one of them (a
|
|
138
|
+
* pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
|
|
139
|
+
* that must not take down a boot. The caller decides how loud to be — `onError`
|
|
140
|
+
* exists so the driver can warn without this module importing a logger.
|
|
141
|
+
*/
|
|
142
|
+
export async function revokeInternalTableAccess(
|
|
143
|
+
execute: (sql: string) => Promise<unknown>,
|
|
144
|
+
schema: string,
|
|
145
|
+
options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {
|
|
148
|
+
try {
|
|
149
|
+
await execute(revokeInternalTableSql(schema, table));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
options?.onError?.(table, error);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
|
|
1
|
+
import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } from "@rebasepro/types";
|
|
2
2
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import { getTableName } from "../relations";
|
|
4
4
|
|
|
@@ -70,7 +70,7 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
|
70
70
|
case "not":
|
|
71
71
|
return `NOT (${compile(expr.operand, scope)})`;
|
|
72
72
|
case "compare": {
|
|
73
|
-
// `
|
|
73
|
+
// `rebase.uid()` returns text; cast the column side so uuid / integer
|
|
74
74
|
// id columns compare cleanly instead of failing with
|
|
75
75
|
// "operator does not exist: uuid = text" at CREATE POLICY time.
|
|
76
76
|
const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
|
|
@@ -82,9 +82,9 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
|
82
82
|
return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
|
|
83
83
|
}
|
|
84
84
|
case "rolesOverlap":
|
|
85
|
-
return `string_to_array(
|
|
85
|
+
return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
|
|
86
86
|
case "rolesContain":
|
|
87
|
-
return `string_to_array(
|
|
87
|
+
return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
|
|
88
88
|
case "authenticated":
|
|
89
89
|
// `IS NOT NULL` alone is a tautology on the user path: every
|
|
90
90
|
// user-context request sets `app.uid`, and an anonymous one sets
|
|
@@ -96,19 +96,32 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
|
|
|
96
96
|
// policy compiled here may be enforced against an older server that
|
|
97
97
|
// still reports `'anon'`, which is exactly how excluding one
|
|
98
98
|
// spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
|
|
99
|
-
return
|
|
99
|
+
return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
|
|
100
100
|
case "serverContext":
|
|
101
101
|
// Only the built-in server flows leave `app.uid` unset.
|
|
102
|
-
return
|
|
102
|
+
return `${RLS_UID_SQL} IS NULL`;
|
|
103
103
|
case "existsIn":
|
|
104
104
|
return compileExistsIn(expr, scope);
|
|
105
|
-
case "raw":
|
|
105
|
+
case "raw": {
|
|
106
|
+
// A project written against a pre-1.0 release may still spell the
|
|
107
|
+
// helpers `auth.uid()`. Rewritten rather than rejected: the rule
|
|
108
|
+
// means exactly the same thing, the developer cannot be expected to
|
|
109
|
+
// have read a changelog mid-deploy, and the alternative is a policy
|
|
110
|
+
// that compiles cleanly and then denies every row at runtime because
|
|
111
|
+
// it calls a function that no longer exists.
|
|
112
|
+
//
|
|
113
|
+
// The counterpart is `warnOnLegacyRlsFunctions`, which says so once
|
|
114
|
+
// at boot with the file to edit — silence here would leave the old
|
|
115
|
+
// spelling working forever and make the migration permanent.
|
|
116
|
+
const sqlText = rewriteLegacyRlsFunctions(expr.sql);
|
|
117
|
+
|
|
106
118
|
// Full-power escape hatch: `{column}` denotes a column of the outer
|
|
107
119
|
// RLS row. It must be table-qualified, not bare: raw SQL may open its
|
|
108
120
|
// own subquery over the same table, and there a bare name binds to the
|
|
109
121
|
// inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
|
|
110
|
-
return
|
|
122
|
+
return sqlText.replace(/\{(\w+)\}/g, (_, col) =>
|
|
111
123
|
`${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
|
|
124
|
+
}
|
|
112
125
|
}
|
|
113
126
|
}
|
|
114
127
|
|
|
@@ -156,9 +169,9 @@ function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
|
|
|
156
169
|
case "literal":
|
|
157
170
|
return quoteLiteral(operand.value);
|
|
158
171
|
case "authUid":
|
|
159
|
-
return
|
|
172
|
+
return RLS_UID_SQL;
|
|
160
173
|
case "authRoles":
|
|
161
|
-
return
|
|
174
|
+
return `string_to_array(${RLS_ROLES_SQL}, ',')`;
|
|
162
175
|
}
|
|
163
176
|
}
|
|
164
177
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
|
|
1
|
+
import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A tiny, regex-based SQL "parser" for security rules.
|
|
@@ -38,9 +38,9 @@ function isKeywordAt(upper: string, i: number, keyword: string): boolean {
|
|
|
38
38
|
*
|
|
39
39
|
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
40
40
|
* `AND` inside
|
|
41
|
-
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id =
|
|
41
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
|
|
42
42
|
* split the expression, and re-emitting the halves produced
|
|
43
|
-
* `(EXISTS (...) AND m.user_id =
|
|
43
|
+
* `(EXISTS (...) AND m.user_id = rebase.uid())`
|
|
44
44
|
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
45
45
|
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
46
46
|
* clause as a `raw` expression, which round-trips verbatim.
|
|
@@ -107,22 +107,32 @@ function stripOuterParens(sql: string): string {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
export function sqlToPolicy(sql: string): PolicyExpression {
|
|
110
|
-
|
|
110
|
+
// Normalised before anything else looks at it, so every pattern below only
|
|
111
|
+
// has to know the current spelling. A database migrated by a pre-1.0 release
|
|
112
|
+
// still holds `auth.uid()` in its policy bodies until the next push or boot
|
|
113
|
+
// recompiles them — and until then the admin UI reads those bodies back
|
|
114
|
+
// through here. Without this they parse as opaque `raw`, and the framework's
|
|
115
|
+
// own policies get badged as hand-written drift.
|
|
116
|
+
//
|
|
117
|
+
// Normalising rather than accepting both spellings throughout is deliberate:
|
|
118
|
+
// it also means a legacy policy that falls through to `raw` is stored in the
|
|
119
|
+
// new spelling, so editing and saving one in the Studio migrates it.
|
|
120
|
+
const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
|
|
111
121
|
|
|
112
122
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
113
123
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
114
124
|
|
|
115
125
|
// Handle roles overlap (&&)
|
|
116
|
-
// Matches: string_to_array(
|
|
117
|
-
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*
|
|
126
|
+
// Matches: string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']
|
|
127
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
118
128
|
if (overlapMatch) {
|
|
119
129
|
const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
120
130
|
return policy.rolesOverlap(roles);
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
// Handle roles containment (@>)
|
|
124
|
-
// Matches: string_to_array(
|
|
125
|
-
const containMatch = trimmed.match(/^string_to_array\s*\(\s*
|
|
134
|
+
// Matches: string_to_array(rebase.roles(), ',') @> ARRAY['admin']
|
|
135
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
126
136
|
if (containMatch) {
|
|
127
137
|
const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
|
|
128
138
|
return policy.rolesContain(roles);
|
|
@@ -146,12 +156,15 @@ export function sqlToPolicy(sql: string): PolicyExpression {
|
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
// Fallback to raw
|
|
150
|
-
|
|
159
|
+
// Fallback to raw — the NORMALISED text, not the input. Storing the input
|
|
160
|
+
// verbatim would mean a legacy policy read out of a database, edited in the
|
|
161
|
+
// Studio and saved, writes `auth.uid()` back into the project's config: a
|
|
162
|
+
// call to a function 1.0 no longer creates.
|
|
163
|
+
return policy.raw(trimmed);
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
/**
|
|
154
|
-
* Literals from other BaaS platforms that people compare `
|
|
167
|
+
* Literals from other BaaS platforms that people compare `rebase.uid()` against
|
|
155
168
|
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
156
169
|
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
157
170
|
* is the more dangerous spelling, because it inverts a rule instead of
|
|
@@ -173,19 +186,26 @@ export interface AnonymousGrantRisk {
|
|
|
173
186
|
explanation: string;
|
|
174
187
|
}
|
|
175
188
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
189
|
+
/**
|
|
190
|
+
* `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
|
|
191
|
+
*
|
|
192
|
+
* Both schema spellings, because this runs over policy bodies read back from a
|
|
193
|
+
* database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
|
|
194
|
+
* A security check that stops recognising a dangerous clause because the
|
|
195
|
+
* framework renamed a function is a check that silently turns off.
|
|
196
|
+
*/
|
|
197
|
+
const UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
|
|
178
198
|
|
|
179
199
|
/**
|
|
180
200
|
* Find clauses that read as "signed-in users only" but admit anonymous callers.
|
|
181
201
|
*
|
|
182
|
-
* Both spellings come from the same place — Supabase, where `auth.uid()`
|
|
183
|
-
* is NULL for an anonymous request. Rebase substitutes
|
|
202
|
+
* Both spellings come from the same place — Supabase, where its own `auth.uid()`
|
|
203
|
+
* really is NULL for an anonymous request. Rebase substitutes
|
|
184
204
|
* {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
|
|
185
205
|
* is how the trusted *server* context is recognised), so:
|
|
186
206
|
*
|
|
187
|
-
* - `
|
|
188
|
-
* - `
|
|
207
|
+
* - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
|
|
208
|
+
* - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
|
|
189
209
|
* other. This one is not hypothetical and was not only a foreign habit:
|
|
190
210
|
* rebase's own request path reported `'anon'` while everything that compiled
|
|
191
211
|
* or checked a policy used `'anonymous'`, so whichever literal an author
|
|
@@ -219,7 +239,7 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
|
|
|
219
239
|
found.push({
|
|
220
240
|
pattern: "uid-not-null",
|
|
221
241
|
detail: e.sql,
|
|
222
|
-
explanation: "`
|
|
242
|
+
explanation: "`rebase.uid() IS NOT NULL` is true for every request that came from a client, " +
|
|
223
243
|
`including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
|
|
224
244
|
"Use `condition: policy.authenticated()` to mean \"signed in\"."
|
|
225
245
|
});
|
|
@@ -252,11 +272,11 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
|
|
|
252
272
|
}
|
|
253
273
|
|
|
254
274
|
function parseOperand(str: string) {
|
|
255
|
-
// current_setting('app.uid') or
|
|
275
|
+
// current_setting('app.uid') or rebase.uid(). `app.user_id` is the
|
|
256
276
|
// pre-rename spelling and stays parseable: policies are data, so a
|
|
257
277
|
// database provisioned before the rename still holds rules written
|
|
258
278
|
// against it, and round-tripping one must not silently drop the operand.
|
|
259
|
-
if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /
|
|
279
|
+
if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /rebase\.uid\(\)/i.test(str)) {
|
|
260
280
|
return policy.authUid();
|
|
261
281
|
}
|
|
262
282
|
|
package/src/util/relations.ts
CHANGED
|
@@ -66,6 +66,37 @@ export function resolveCollectionRelations(
|
|
|
66
66
|
return relations;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The path of the collection a relation property points at, derived from the
|
|
71
|
+
* property alone.
|
|
72
|
+
*
|
|
73
|
+
* A preview holds a property and a value and no collection, so it cannot call
|
|
74
|
+
* `resolveRelationProperty`. It does not need to: both forms that carry a
|
|
75
|
+
* target — the stamped `resolvedRelation` and the inline `relation` — name it
|
|
76
|
+
* directly. Only the third form, a relation declared by name in the
|
|
77
|
+
* collection's `relations` array, is out of reach, and that one has no target
|
|
78
|
+
* to read without the collection anyway.
|
|
79
|
+
*
|
|
80
|
+
* This is what lets a preview render a relation column that arrived as a bare
|
|
81
|
+
* foreign key: the id says *which* row, the declared target says *which
|
|
82
|
+
* collection*, and `RelationPreview` fetches the rest. Without it a scalar id
|
|
83
|
+
* is indistinguishable from a value of the wrong type.
|
|
84
|
+
*/
|
|
85
|
+
export function getRelationTargetPath(property: RelationProperty): string | undefined {
|
|
86
|
+
const stamped = property.resolvedRelation?.targetSlug;
|
|
87
|
+
if (stamped) return stamped;
|
|
88
|
+
|
|
89
|
+
const target = property.relation?.target;
|
|
90
|
+
if (typeof target !== "function") return undefined;
|
|
91
|
+
try {
|
|
92
|
+
return target()?.slug;
|
|
93
|
+
} catch (_e) {
|
|
94
|
+
// A thunk reaching into a module that has not finished initialising:
|
|
95
|
+
// there is no target to name yet, and a preview is not worth throwing over.
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
export function getTableName(collection: CollectionConfig): string {
|
|
70
101
|
if (isRelationalCollectionConfig(collection)) {
|
|
71
102
|
return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
package/src/util/resolutions.ts
CHANGED
|
@@ -261,11 +261,11 @@ export function resolveArrayProperties<M>({
|
|
|
261
261
|
ignoreMissingFields,
|
|
262
262
|
...props
|
|
263
263
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
} = props;
|
|
264
|
+
// Destructured to be *excluded* from `...rest`, not to be used —
|
|
265
|
+
// see the comment below. Said explicitly so the discarded-value
|
|
266
|
+
// ratchet does not carry a finding that is working as intended.
|
|
267
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
268
|
+
const { values, previousValues, ...rest } = props;
|
|
269
269
|
const ofProperty = resolveProperty({ // we don't want to pass the values of the parent entity
|
|
270
270
|
property: of,
|
|
271
271
|
ignoreMissingFields,
|
|
@@ -449,6 +449,78 @@ singularName: customName } : {})
|
|
|
449
449
|
return views;
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Each of `collection`'s tabs paired with the property that declared it, when a
|
|
454
|
+
* property declared it: child view key → property key.
|
|
455
|
+
*
|
|
456
|
+
* A many-relation can only be declared as a property — that is the documented
|
|
457
|
+
* and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
|
|
458
|
+
* one declaration reaches the panel twice, and neither surface knew about the
|
|
459
|
+
* other. The form rendered a relation picker beside the tab, and the collection
|
|
460
|
+
* table rendered *two* columns under one heading: the relation's own column,
|
|
461
|
+
* showing the child rows, and a jump-to-tab button carrying the same name.
|
|
462
|
+
*
|
|
463
|
+
* The pairing is what lets each surface decide which half is redundant, and it
|
|
464
|
+
* has to be a pairing rather than two sets because the two keys differ whenever
|
|
465
|
+
* a relation is named. The match is on the resolved `relationName` — the
|
|
466
|
+
* identity `getEntityChildViews` itself dedupes on — so a relation declared in
|
|
467
|
+
* `relations` and pointed at by a differently-named property is recognised too.
|
|
468
|
+
*
|
|
469
|
+
* A relation with no property of its own is absent here, which is the point: it
|
|
470
|
+
* has exactly one surface already, and nothing to weigh it against.
|
|
471
|
+
*
|
|
472
|
+
* Only top-level properties: a relation nested inside a `map` gets no tab.
|
|
473
|
+
*/
|
|
474
|
+
export function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(
|
|
475
|
+
collection: CollectionConfig<M>
|
|
476
|
+
): Map<string, string> {
|
|
477
|
+
const pairs = new Map<string, string>();
|
|
478
|
+
|
|
479
|
+
const relationProperties = Object.entries((collection.properties ?? {}) as Record<string, Property>)
|
|
480
|
+
.filter(([, property]) => property?.type === "relation");
|
|
481
|
+
if (relationProperties.length === 0) return pairs;
|
|
482
|
+
|
|
483
|
+
const relationViews = getEntityChildViews(collection)
|
|
484
|
+
.filter(view => view.source.kind === "relation");
|
|
485
|
+
if (relationViews.length === 0) return pairs;
|
|
486
|
+
|
|
487
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
488
|
+
const identityOf = (relationKey: string): string =>
|
|
489
|
+
resolvedRelations[relationKey]?.relationName ?? relationKey;
|
|
490
|
+
|
|
491
|
+
const declaringPropertyByIdentity = new Map<string, string>();
|
|
492
|
+
for (const [propertyKey, property] of relationProperties) {
|
|
493
|
+
const relation = (property as RelationProperty).resolvedRelation ?? resolvedRelations[propertyKey];
|
|
494
|
+
// A to-one relation is a foreign key the author edits, never a tab. No
|
|
495
|
+
// view will match it — the views here are many-relations only — but
|
|
496
|
+
// reading the cardinality says so where someone is looking.
|
|
497
|
+
if (relation?.cardinality !== "many") continue;
|
|
498
|
+
const identity = relation.relationName ?? propertyKey;
|
|
499
|
+
if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const view of relationViews) {
|
|
503
|
+
const propertyKey = declaringPropertyByIdentity.get(
|
|
504
|
+
identityOf((view.source as { relationKey: string }).relationKey));
|
|
505
|
+
if (propertyKey) pairs.set(view.key, propertyKey);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return pairs;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The property keys of `collection` whose relation is already one of its tabs.
|
|
513
|
+
*
|
|
514
|
+
* What a form asks: the tab is the treatment for a list of child rows, so the
|
|
515
|
+
* picker beside it is the redundant half. See
|
|
516
|
+
* {@link getChildViewDeclaringProperties}.
|
|
517
|
+
*/
|
|
518
|
+
export function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(
|
|
519
|
+
collection: CollectionConfig<M>
|
|
520
|
+
): Set<string> {
|
|
521
|
+
return new Set(getChildViewDeclaringProperties(collection).values());
|
|
522
|
+
}
|
|
523
|
+
|
|
452
524
|
/**
|
|
453
525
|
* The child views of `collection` as bare collections.
|
|
454
526
|
*
|