@rebasepro/common 0.29.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.es.js +3 -3
- package/dist/util/auth-default-policies.d.ts +6 -0
- package/dist/util/auth-default-policies.es.js +14 -4
- package/dist/util/auth-default-policies.es.js.map +1 -1
- package/dist/util/internal-tables.d.ts +31 -0
- package/dist/util/internal-tables.es.js +74 -14
- package/dist/util/internal-tables.es.js.map +1 -1
- package/dist/util/junction-policies.es.js +2 -1
- package/dist/util/junction-policies.es.js.map +1 -1
- package/dist/util/policy/compileRulePolicies.d.ts +1 -1
- package/dist/util/policy/compileRulePolicies.es.js +7 -3
- package/dist/util/policy/compileRulePolicies.es.js.map +1 -1
- package/package.json +3 -3
package/dist/index.es.js
CHANGED
|
@@ -25,12 +25,12 @@ import { toFilterTuples } from "./data/filter-conditions.es.js";
|
|
|
25
25
|
import { composeCallbacks } from "./util/compose-callbacks.es.js";
|
|
26
26
|
import { CALLBACK_REJECTED, callbackRefusal, requireCallbackClient, requireCallbackCollection, toCallbackError } from "./util/callback-errors.es.js";
|
|
27
27
|
import { TENANT_INDEX_REASON, buildTenantSecurityRule, getTenantConfig, resolveTenantWrite, tenantBypassRoles, tenantPolicyName, tenantScopeExpression } from "./util/tenant.es.js";
|
|
28
|
-
import { getEffectiveSecurityRules, getGeneratedPolicyNames, getInjectedSecurityRules } from "./util/auth-default-policies.es.js";
|
|
28
|
+
import { defaultPoliciesDisabled, getEffectiveSecurityRules, getGeneratedPolicyNames, getInjectedSecurityRules } from "./util/auth-default-policies.es.js";
|
|
29
29
|
import { embedParentExpression, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, resolveJunctionSpecs } from "./util/junction-policies.es.js";
|
|
30
30
|
import { buildConditionContext, evaluateCondition, registerConditionOperations } from "./util/conditions.es.js";
|
|
31
31
|
import { buildCollectionFromTableMetadata } from "./util/pg-column-to-property.es.js";
|
|
32
32
|
import { DEFAULT_STRING_COLUMN_LENGTH, resolveStringColumnLength } from "./util/string-column-length.es.js";
|
|
33
|
-
import { REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, revokeInternalTableAccess, revokeInternalTableSql } from "./util/internal-tables.es.js";
|
|
33
|
+
import { LOAD_REQUEST_ROLES_PLPGSQL, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, REQUEST_ROLE_CANDIDATES_SQL, grantExecuteToRequestRolesSql, revokeInternalTableAccess, revokeInternalTableSql } from "./util/internal-tables.es.js";
|
|
34
34
|
import { firstSqlRow, sqlRows } from "./util/sql-rows.es.js";
|
|
35
35
|
import { redactSqlLiterals } from "./util/sql-redaction.es.js";
|
|
36
36
|
import { JUNCTION_TABLES_SQL, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, classifyTable, detectJunctionTables, isRebaseInternalTable } from "./table-classification.es.js";
|
|
@@ -57,4 +57,4 @@ import { aggregateAlias, buildRebaseData, buildSdkData, rowToViewModel, wrapAsEn
|
|
|
57
57
|
import { createCallContext } from "./data/call-context.es.js";
|
|
58
58
|
import { createAccessorSlugResolver, toCamelCase, toCollectionAccessor, toPascalCase, toSafeIdentifier } from "./data/collection-accessor.es.js";
|
|
59
59
|
import { buildRoutedRebaseData } from "./data/buildRoutedRebaseData.es.js";
|
|
60
|
-
export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, findTableDdl, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, hasEntityId, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, rowToViewModel, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tableDdlMessage, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, warnOnTableDdl, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
|
|
60
|
+
export { ADMIN_ROLE, CALLBACK_REJECTED, COLLECTION_PATH_SEPARATOR, COMPOSITE_ID_SEPARATOR, CollectionRegistry, CursorError, CursorMismatchError, DEFAULT_DOWNLOAD_URL_CACHE_ENTRIES, DEFAULT_FIND_ALL_MAX_ROWS, DEFAULT_MAX_PAGES, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, DEFAULT_PAGE_SIZE, DEFAULT_SOFT_DELETE_FIELD, DEFAULT_STRING_COLUMN_LENGTH, DOWNLOAD_METADATA_BATCH_MAX, DOWNLOAD_URL_CONCURRENCY, ETAG_KEY, IncludeSpecError, JUNCTION_TABLES_SQL, LOAD_REQUEST_ROLES_PLPGSQL, MAX_LOGICAL_NESTING_DEPTH, OrderBySpecError, PAGE_WITH_OFFSET_MESSAGE, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, REQUEST_ROLE_CANDIDATES_SQL, RESERVED_QUERY_KEYS, RESUMABLE_UPLOAD_THRESHOLD, RebasePaginationError, TENANT_INDEX_REASON, UnknownFilterOperatorError, aggregateAlias, and, apiHiddenFieldNames, applyDefaultValuesOnCreate, assertBeforeQueryIsPostgresOnly, authSecretsMissingExclusion, buildCollectionFromTableMetadata, buildCompositeId, buildConditionContext, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, buildTenantSecurityRule, callbackRefusal, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, canReadField, canWriteField, checkOperation, classifyTable, collectAllPages, compileRulePolicies, composeCallbacks, cond, copyCarriesRelation, createAccessorSlugResolver, createBatchLoader, createCallContext, createConcurrencyLimit, createDataSourceRegistry, createDownloadUrlCache, createPaginationHelpers, createRelationRef, createRelationRefWithData, cursorToStartAfter, decodeCursor, defaultPoliciesDisabled, defaultUsersCollection, defineCollection, denormalizeInclude, deserializeFilter, deserializeInclude, deserializeLogicalCondition, deserializeOrderBy, deserializeOrderByList, detectJunctionTables, effectiveAccess, embedParentExpression, encodeCursor, enumToObjectEntries, etagOf, evaluateCondition, evaluatePolicy, fieldKeyForColumn, findAnonymousGrants, findRelation, findTableDdl, firstSqlRow, fullPathToCollectionSegments, getArrayResolvedProperties, getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getColumnName, getCopyValues, getDeclaredPrimaryKeys, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEffectiveSecurityRules, getEntityChildViews, getEnumVarName, getGeneratedPolicyNames, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionConfigForRelation, getJunctionSecurityRules, getLabelOrConfigFrom, getPrimaryKeys, getReferenceFrom, getRelationFrom, getRelationTargetPath, getSubcollections, getTableName, getTableVarName, getTenantConfig, grantExecuteToRequestRolesSql, hasEntityId, hasFieldAccessRules, includableRelationNames, includePaths, isAddressableId, isJunctionBackedRelation, isPropertyBuilder, isRebaseInternalTable, isRelationRequired, isRelationalCollection, mergeIncludeSpecs, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeOrderBy, normalizeToEntityRelation, not, or, paginateFind, parseIdValues, parseOrderBySpecStrict, policyToPostgres, policyToSecurityRule, primaryOrderBy, queryableFieldNames, reconcileCursorOrder, redactSqlLiterals, registerConditionOperations, relationDeclaringProperty, relationalCollections, requireCallbackClient, requireCallbackCollection, resetDeprecationWarningsForTests, resolveArrayProperties, resolveCollectionRelations, resolveDataSource, resolveEnumValues, resolveFindWindow, resolveJunctionSpecs, resolvePrimaryKeys, resolveProperties, resolveProperty, resolvePropertyEnum, resolveRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, resolveStringColumnLength, resolveTenantWrite, restoresSoftDeletedRow, restrictedFieldNames, revokeInternalTableAccess, revokeInternalTableSql, rowToViewModel, sanitizeData, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy, softDeleteFieldOf, sortCollectionsBySlug, sortProperties, sqlRows, sqlToPolicy, stripCollectionPath, tableDdlMessage, tenantBypassRoles, tenantPolicyName, tenantScopeExpression, toCallbackError, toCamelCase, toCollectionAccessor, toFilterTuples, toPascalCase, toSafeIdentifier, topLevelIncludeNames, traverseValueProperty, traverseValuesProperties, unavailableStorageSource, unknownAggregateField, unknownFilterField, unknownIncludeRelation, unknownOrderByField, unknownProjectionField, unknownRelationTargetField, unknownVectorProperty, untrustedEnvelope, updateDateAutoValues, updateUserAutoValues, uploadResumable, vectorPropertyNames, warnDeprecated, warnOnTableDdl, withETag, withRelationReferencesOnly, wrapAsEntityData, wrapAsSdkData };
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { CollectionConfig, SecurityRule } from "@rebasepro/types";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a collection goes without the injected baseline: its own
|
|
4
|
+
* `disableDefaultPolicies` when it says, otherwise the database's
|
|
5
|
+
* `rls.defaultPolicies` (`database({ rls: { defaultPolicies: false } })`).
|
|
6
|
+
*/
|
|
7
|
+
export declare function defaultPoliciesDisabled(collection: CollectionConfig): boolean;
|
|
2
8
|
export declare function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[];
|
|
3
9
|
/**
|
|
4
10
|
* The framework defaults that {@link getEffectiveSecurityRules} would add to a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getTableName } from "./relations.es.js";
|
|
2
2
|
import { buildTenantSecurityRule } from "./tenant.es.js";
|
|
3
|
-
import { isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
3
|
+
import { DEFAULT_DATA_SOURCE_KEY, declaredRlsPolicyShape, isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
4
4
|
import { getPolicyNamesForRules } from "@rebasepro/utils";
|
|
5
5
|
//#region src/util/auth-default-policies.ts
|
|
6
6
|
/**
|
|
@@ -111,11 +111,21 @@ function tenantRule(collection) {
|
|
|
111
111
|
const rule = buildTenantSecurityRule(collection);
|
|
112
112
|
return rule ? [rule] : [];
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Whether a collection goes without the injected baseline: its own
|
|
116
|
+
* `disableDefaultPolicies` when it says, otherwise the database's
|
|
117
|
+
* `rls.defaultPolicies` (`database({ rls: { defaultPolicies: false } })`).
|
|
118
|
+
*/
|
|
119
|
+
function defaultPoliciesDisabled(collection) {
|
|
120
|
+
if (!isPostgresCollectionConfig(collection)) return false;
|
|
121
|
+
if (collection.disableDefaultPolicies !== void 0) return collection.disableDefaultPolicies;
|
|
122
|
+
return !declaredRlsPolicyShape(collection.dataSource ?? DEFAULT_DATA_SOURCE_KEY).defaultPolicies;
|
|
123
|
+
}
|
|
114
124
|
function getEffectiveSecurityRules(collection) {
|
|
115
125
|
const explicit = [...collection.securityRules ?? []];
|
|
116
126
|
const tableName = getTableName(collection);
|
|
117
127
|
const injected = [];
|
|
118
|
-
if (
|
|
128
|
+
if (defaultPoliciesDisabled(collection)) return [
|
|
119
129
|
...explicit,
|
|
120
130
|
...tenantRule(collection),
|
|
121
131
|
...isAuthCollection(collection) ? [adminWriteGate(tableName)] : []
|
|
@@ -154,7 +164,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
154
164
|
* DDL, which policies are injected and how to take them off.
|
|
155
165
|
*/
|
|
156
166
|
function getInjectedSecurityRules(collection) {
|
|
157
|
-
if (
|
|
167
|
+
if (defaultPoliciesDisabled(collection)) return [...tenantRule(collection), ...isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : []];
|
|
158
168
|
const explicitCount = (collection.securityRules ?? []).length;
|
|
159
169
|
return getEffectiveSecurityRules(collection).slice(explicitCount);
|
|
160
170
|
}
|
|
@@ -179,6 +189,6 @@ function getGeneratedPolicyNames(collection) {
|
|
|
179
189
|
return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
|
|
180
190
|
}
|
|
181
191
|
//#endregion
|
|
182
|
-
export { getEffectiveSecurityRules, getGeneratedPolicyNames, getInjectedSecurityRules };
|
|
192
|
+
export { defaultPoliciesDisabled, getEffectiveSecurityRules, getGeneratedPolicyNames, getInjectedSecurityRules };
|
|
183
193
|
|
|
184
194
|
//# sourceMappingURL=auth-default-policies.es.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-default-policies.es.js","names":[],"sources":["../../src/util/auth-default-policies.ts"],"sourcesContent":["import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from \"@rebasepro/types\";\nimport { getTableName } from \"./relations\";\nimport { buildTenantSecurityRule } from \"./tenant\";\nimport { getPolicyNamesForRules } from \"@rebasepro/utils\";\n\n/**\n * Default RLS policies injected by the schema generator.\n *\n * Rebase's enforcement model is unified: authenticated (user-context) requests\n * run under the restricted `rebase_user` role, so Postgres RLS binds *every*\n * statement — reads and writes. A collection's `securityRules` are the whole\n * authorization model. The server context (auth flows, migrations, raw\n * `rebase.sql`) runs as the owner and bypasses RLS.\n *\n * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as\n * `{ uid: \"service\", roles: [\"admin\"] }`, so it runs as `rebase_user` like any\n * other caller and clears the baseline below through the *admin* arm, not the\n * server arm. Which is why `disableDefaultPolicies` plus a lone\n * `policy.serverContext()` rule locks it out too.\n *\n * Because RLS default-denies, every collection is **locked by default**: with\n * no rules, only the server context and admins can touch it. The generator\n * injects that safe baseline:\n *\n * **For every collection**\n * 1. A permissive **server-or-admin SELECT** grant.\n * 2. A permissive **server-or-admin write** grant (insert/update/delete).\n *\n * Author `securityRules` are permissive and OR together, so explicit rules only\n * *broaden* access from this locked baseline (e.g. \"users read/write their own\n * rows\").\n *\n * **For auth collections additionally**\n * 3. A permissive **self SELECT** grant (`id = rebase.uid()`), so users can read\n * their own row (profile, session bootstrap) without every app re-declaring\n * it.\n * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with\n * every other policy, so a write is rejected unless the caller is an admin\n * (or the server context) — even if the author also wrote a permissive rule\n * such as \"a user may edit their own row\". Without this, a permissive owner\n * rule would let a user change their own `roles`.\n *\n * The server context is recognised as `rebase.uid() IS NULL` (`policy.serverContext()`)\n * — the built-in flows that run without a user (signup, migrations) set no user\n * GUC — which also lets the owner connection satisfy these policies even under\n * FORCE RLS. A *user* request never reaches that state: an anonymous one carries\n * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.\n *\n * **For a collection declaring `tenant`, additionally**\n * 5. A **restrictive** tenancy gate for every operation. Same kind of thing as\n * the admin write gate and injected for the same reason: it is ANDed with\n * every other policy, so it narrows what the author's permissive rules\n * grant and can never widen them. See `./tenant.ts`.\n *\n * Opt out with `disableDefaultPolicies: true` to take full responsibility for\n * the collection's RLS. The *restrictive* rules are not part of that opt-out:\n * dropping a rule that can only remove access could express nothing but \"let\n * more people in\", which is what the flag already does by removing the grants.\n */\n// Expressed structurally (not as raw SQL) so the admin UI can evaluate it\n// exactly — the framework's most security-critical policies must be reflected\n// precisely, not left as un-evaluable raw clauses. Compiles to\n// `rebase.uid() IS NULL OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])`.\n//\n// `serverContext()`, emphatically not `not(authenticated())`: the server arm of\n// this grant must match the server context and nothing else. Anonymous visitors\n// are not signed in either, so a negated `authenticated()` would hand them the\n// server-or-admin grant on every collection's default policy.\nconst SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(\n policy.serverContext(),\n policy.rolesOverlap([\"admin\"])\n);\n\n/** Write operations that must be admin-gated by default on auth collections. */\nconst DEFAULT_GUARDED_OPS: SecurityOperation[] = [\"insert\", \"update\", \"delete\"];\n\n/** Whether a collection is flagged as an authentication collection. */\nfunction isAuthCollection(collection: CollectionConfig): boolean {\n const auth = collection.auth;\n return auth === true || (typeof auth === \"object\" && (auth as AuthCollectionConfig)?.enabled === true);\n}\n\n/** The property marked as the row id (falls back to `id`). */\nfunction getIdPropertyName(collection: CollectionConfig): string {\n for (const [name, prop] of Object.entries(collection.properties ?? {})) {\n if (prop && typeof prop === \"object\" && \"isId\" in prop && (prop as { isId?: unknown }).isId) {\n return name;\n }\n }\n return \"id\";\n}\n\n/**\n * Returns the security rules that should be applied to a collection: the\n * author's explicit `securityRules` plus the framework defaults described in\n * the module doc (baseline server/admin read for all collections; self-read\n * and the admin write gate for auth collections).\n *\n * Collections that opt out via `disableDefaultPolicies` are returned unchanged.\n */\n/**\n * The restrictive write gate for an auth collection.\n *\n * Restrictive, so it is ANDed with everything else: whatever an author's\n * permissive rules allow, a write to this table still has to satisfy this too.\n * It is the only thing standing between \"users may edit their own row\" and\n * \"users may grant themselves any role\".\n */\nfunction adminWriteGate(tableName: string): SecurityRule {\n return {\n name: `${tableName}_require_admin_write`,\n mode: \"restrictive\",\n operations: [...DEFAULT_GUARDED_OPS],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n };\n}\n\n/**\n * The restrictive tenancy policy, as a list of zero or one.\n *\n * A list so the two call sites can splice it in without a conditional, and a\n * separate function so it is obvious that it is injected on *both* paths —\n * including the `disableDefaultPolicies` one, where it is the only permissive-\n * looking thing that stays. See `./tenant.ts`.\n */\nfunction tenantRule(collection: CollectionConfig): SecurityRule[] {\n const rule = buildTenantSecurityRule(collection);\n return rule ? [rule] : [];\n}\n\nexport function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {\n const explicit = [...(collection.securityRules ?? [])];\n\n const tableName = getTableName(collection);\n const injected: SecurityRule[] = [];\n\n if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) {\n // The opt-out drops the *permissive* defaults — the ones that grant.\n // The restrictive admin-write gate on an auth collection is not among\n // them, because it is different in kind: a restrictive policy is ANDed\n // with every other policy and can only ever remove access, so opting\n // out of it cannot express anything except \"let more people write\".\n //\n // Dropping it did exactly that. `{ disableDefaultPolicies: true,\n // securityRules: [{ operation: \"all\", ownerField: \"id\" }] }` — an\n // ordinary \"users may edit their own row\" configuration — let any\n // signed-in user set their own `roles` to `[\"admin\"]`, with no warning\n // from any boot guard, doctor check or validator.\n //\n // An author who needs a different gate can add their own restrictive\n // rule; they cannot end up with none by accident.\n // Tenancy survives the opt-out for exactly the reason the write gate\n // does: it is restrictive, so it can only ever remove access. Dropping\n // it could express nothing except \"let every tenant read every other\n // tenant's rows\", which is not a thing `disableDefaultPolicies` is for\n // — that flag is about taking over the *grants*.\n return [...explicit, ...tenantRule(collection), ...(isAuthCollection(collection)\n ? [adminWriteGate(tableName)]\n : [])];\n }\n\n // Baseline read + write: the server context and admins can always operate.\n // RLS default-denies under the user role, so without these a rule-less\n // collection would be locked to everyone — including the admin studio.\n // Author rules are permissive and broaden access from here.\n injected.push({\n name: `${tableName}_default_admin_read`,\n operations: [\"select\"],\n condition: SERVER_OR_ADMIN_EXPR\n });\n injected.push({\n name: `${tableName}_default_admin_write`,\n operations: [...DEFAULT_GUARDED_OPS],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n });\n\n if (isAuthCollection(collection)) {\n // Self-read: a user can always read their own row.\n injected.push({\n name: `${tableName}_default_self_read`,\n operations: [\"select\"],\n condition: policy.compare(policy.field(getIdPropertyName(collection)), \"eq\", policy.authUid())\n });\n\n // Restrictive gate: AND'd with all other policies, so no permissive rule\n // (e.g. an owner \"edit your own row\" rule) can let a non-admin change\n // privileged columns like `roles`. Survives `disableDefaultPolicies` —\n // see the note above the opt-out.\n injected.push(adminWriteGate(tableName));\n }\n\n // Last, so it reads as what it is: a restriction ANDed over everything\n // above it, author rules included.\n injected.push(...tenantRule(collection));\n\n return [...explicit, ...injected];\n}\n\n/**\n * The framework defaults that {@link getEffectiveSecurityRules} would add to a\n * collection, without the author's own rules.\n *\n * These policies appear in the database under names the author never wrote, and\n * a permissive policy ORs with every other permissive policy — so someone\n * reading their `securityRules` and then the real ACL sees more access than they\n * declared. Dropping them by hand does nothing either: `db push` is declarative,\n * so the next push asserts them again. Callers use this to say, in the generated\n * DDL, which policies are injected and how to take them off.\n */\nexport function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[] {\n if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) {\n // Not empty for an auth collection, nor for a tenant-scoped one: both\n // restrictive rules are still injected, and the generated DDL has to\n // say so — a policy in the database that the author never wrote and\n // cannot find in this list is exactly the surprise this function exists\n // to prevent.\n return [...tenantRule(collection), ...(isAuthCollection(collection)\n ? [adminWriteGate(getTableName(collection))]\n : [])];\n }\n\n const explicitCount = (collection.securityRules ?? []).length;\n // getEffectiveSecurityRules appends the defaults after the author's rules,\n // so everything past the author's count is injected.\n return getEffectiveSecurityRules(collection).slice(explicitCount);\n}\n\n/**\n * Every policy name `rebase db push` would write for a collection.\n *\n * This is the answer to \"did the codebase produce this live policy?\", and it is\n * more than `securityRules.map(r => r.name)` for two reasons:\n *\n * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one\n * per operation, so comparing `rule.name` to `policyname` never matches it;\n * - the generator also injects the safe-by-default baseline\n * (`<table>_default_admin_*`), which is in no collection's `securityRules`.\n *\n * Every UI that flags drift has to get both right, and each one that derived it\n * by hand got a different subset — which is how four policies *Rebase itself\n * wrote* came to be badged as hand-written drift on every table in a project,\n * with a button offering to import them back into the codebase that produced\n * them. There is one derivation now, and this is it.\n */\nexport function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {\n return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoEA,IAAM,uBAAyC,OAAO,GAClD,OAAO,cAAc,GACrB,OAAO,aAAa,CAAC,OAAO,CAAC,CACjC;;AAGA,IAAM,sBAA2C;CAAC;CAAU;CAAU;AAAQ;;AAG9E,SAAS,iBAAiB,YAAuC;CAC7D,MAAM,OAAO,WAAW;CACxB,OAAO,SAAS,QAAS,OAAO,SAAS,YAAa,MAA+B,YAAY;AACrG;;AAGA,SAAS,kBAAkB,YAAsC;CAC7D,KAAK,MAAM,CAAC,MAAM,SAAS,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GACjE,IAAI,QAAQ,OAAO,SAAS,YAAY,UAAU,QAAS,KAA4B,MACnF,OAAO;CAGf,OAAO;AACX;;;;;;;;;;;;;;;;;AAkBA,SAAS,eAAe,WAAiC;CACrD,OAAO;EACH,MAAM,GAAG,UAAU;EACnB,MAAM;EACN,YAAY,CAAC,GAAG,mBAAmB;EACnC,WAAW;EACX,OAAO;CACX;AACJ;;;;;;;;;AAUA,SAAS,WAAW,YAA8C;CAC9D,MAAM,OAAO,wBAAwB,UAAU;CAC/C,OAAO,OAAO,CAAC,IAAI,IAAI,CAAC;AAC5B;AAEA,SAAgB,0BAA0B,YAA8C;CACpF,MAAM,WAAW,CAAC,GAAI,WAAW,iBAAiB,CAAC,CAAE;CAErD,MAAM,YAAY,aAAa,UAAU;CACzC,MAAM,WAA2B,CAAC;CAElC,IAAI,2BAA2B,UAAU,KAAK,WAAW,wBAoBrD,OAAO;EAAC,GAAG;EAAU,GAAG,WAAW,UAAU;EAAG,GAAI,iBAAiB,UAAU,IACzE,CAAC,eAAe,SAAS,CAAC,IAC1B,CAAC;CAAE;CAOb,SAAS,KAAK;EACV,MAAM,GAAG,UAAU;EACnB,YAAY,CAAC,QAAQ;EACrB,WAAW;CACf,CAAC;CACD,SAAS,KAAK;EACV,MAAM,GAAG,UAAU;EACnB,YAAY,CAAC,GAAG,mBAAmB;EACnC,WAAW;EACX,OAAO;CACX,CAAC;CAED,IAAI,iBAAiB,UAAU,GAAG;EAE9B,SAAS,KAAK;GACV,MAAM,GAAG,UAAU;GACnB,YAAY,CAAC,QAAQ;GACrB,WAAW,OAAO,QAAQ,OAAO,MAAM,kBAAkB,UAAU,CAAC,GAAG,MAAM,OAAO,QAAQ,CAAC;EACjG,CAAC;EAMD,SAAS,KAAK,eAAe,SAAS,CAAC;CAC3C;CAIA,SAAS,KAAK,GAAG,WAAW,UAAU,CAAC;CAEvC,OAAO,CAAC,GAAG,UAAU,GAAG,QAAQ;AACpC;;;;;;;;;;;;AAaA,SAAgB,yBAAyB,YAA8C;CACnF,IAAI,2BAA2B,UAAU,KAAK,WAAW,wBAMrD,OAAO,CAAC,GAAG,WAAW,UAAU,GAAG,GAAI,iBAAiB,UAAU,IAC5D,CAAC,eAAe,aAAa,UAAU,CAAC,CAAC,IACzC,CAAC,CAAE;CAGb,MAAM,iBAAiB,WAAW,iBAAiB,CAAC,EAAA,CAAG;CAGvD,OAAO,0BAA0B,UAAU,CAAC,CAAC,MAAM,aAAa;AACpE;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,wBAAwB,YAA2C;CAC/E,OAAO,uBAAuB,0BAA0B,UAAU,GAAG,aAAa,UAAU,CAAC;AACjG"}
|
|
1
|
+
{"version":3,"file":"auth-default-policies.es.js","names":[],"sources":["../../src/util/auth-default-policies.ts"],"sourcesContent":["import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy, declaredRlsPolicyShape, DEFAULT_DATA_SOURCE_KEY } from \"@rebasepro/types\";\nimport { getTableName } from \"./relations\";\nimport { buildTenantSecurityRule } from \"./tenant\";\nimport { getPolicyNamesForRules } from \"@rebasepro/utils\";\n\n/**\n * Default RLS policies injected by the schema generator.\n *\n * Rebase's enforcement model is unified: authenticated (user-context) requests\n * run under the restricted `rebase_user` role, so Postgres RLS binds *every*\n * statement — reads and writes. A collection's `securityRules` are the whole\n * authorization model. The server context (auth flows, migrations, raw\n * `rebase.sql`) runs as the owner and bypasses RLS.\n *\n * `rebase.dataAsAdmin` is **not** in that set, despite the name: it is scoped as\n * `{ uid: \"service\", roles: [\"admin\"] }`, so it runs as `rebase_user` like any\n * other caller and clears the baseline below through the *admin* arm, not the\n * server arm. Which is why `disableDefaultPolicies` plus a lone\n * `policy.serverContext()` rule locks it out too.\n *\n * Because RLS default-denies, every collection is **locked by default**: with\n * no rules, only the server context and admins can touch it. The generator\n * injects that safe baseline:\n *\n * **For every collection**\n * 1. A permissive **server-or-admin SELECT** grant.\n * 2. A permissive **server-or-admin write** grant (insert/update/delete).\n *\n * Author `securityRules` are permissive and OR together, so explicit rules only\n * *broaden* access from this locked baseline (e.g. \"users read/write their own\n * rows\").\n *\n * **For auth collections additionally**\n * 3. A permissive **self SELECT** grant (`id = rebase.uid()`), so users can read\n * their own row (profile, session bootstrap) without every app re-declaring\n * it.\n * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with\n * every other policy, so a write is rejected unless the caller is an admin\n * (or the server context) — even if the author also wrote a permissive rule\n * such as \"a user may edit their own row\". Without this, a permissive owner\n * rule would let a user change their own `roles`.\n *\n * The server context is recognised as `rebase.uid() IS NULL` (`policy.serverContext()`)\n * — the built-in flows that run without a user (signup, migrations) set no user\n * GUC — which also lets the owner connection satisfy these policies even under\n * FORCE RLS. A *user* request never reaches that state: an anonymous one carries\n * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.\n *\n * **For a collection declaring `tenant`, additionally**\n * 5. A **restrictive** tenancy gate for every operation. Same kind of thing as\n * the admin write gate and injected for the same reason: it is ANDed with\n * every other policy, so it narrows what the author's permissive rules\n * grant and can never widen them. See `./tenant.ts`.\n *\n * Opt out with `disableDefaultPolicies: true` to take full responsibility for\n * the collection's RLS. The *restrictive* rules are not part of that opt-out:\n * dropping a rule that can only remove access could express nothing but \"let\n * more people in\", which is what the flag already does by removing the grants.\n */\n// Expressed structurally (not as raw SQL) so the admin UI can evaluate it\n// exactly — the framework's most security-critical policies must be reflected\n// precisely, not left as un-evaluable raw clauses. Compiles to\n// `rebase.uid() IS NULL OR (string_to_array(rebase.roles(), ',') && ARRAY['admin'])`.\n//\n// `serverContext()`, emphatically not `not(authenticated())`: the server arm of\n// this grant must match the server context and nothing else. Anonymous visitors\n// are not signed in either, so a negated `authenticated()` would hand them the\n// server-or-admin grant on every collection's default policy.\nconst SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(\n policy.serverContext(),\n policy.rolesOverlap([\"admin\"])\n);\n\n/** Write operations that must be admin-gated by default on auth collections. */\nconst DEFAULT_GUARDED_OPS: SecurityOperation[] = [\"insert\", \"update\", \"delete\"];\n\n/** Whether a collection is flagged as an authentication collection. */\nfunction isAuthCollection(collection: CollectionConfig): boolean {\n const auth = collection.auth;\n return auth === true || (typeof auth === \"object\" && (auth as AuthCollectionConfig)?.enabled === true);\n}\n\n/** The property marked as the row id (falls back to `id`). */\nfunction getIdPropertyName(collection: CollectionConfig): string {\n for (const [name, prop] of Object.entries(collection.properties ?? {})) {\n if (prop && typeof prop === \"object\" && \"isId\" in prop && (prop as { isId?: unknown }).isId) {\n return name;\n }\n }\n return \"id\";\n}\n\n/**\n * Returns the security rules that should be applied to a collection: the\n * author's explicit `securityRules` plus the framework defaults described in\n * the module doc (baseline server/admin read for all collections; self-read\n * and the admin write gate for auth collections).\n *\n * Collections that opt out via `disableDefaultPolicies` are returned unchanged.\n */\n/**\n * The restrictive write gate for an auth collection.\n *\n * Restrictive, so it is ANDed with everything else: whatever an author's\n * permissive rules allow, a write to this table still has to satisfy this too.\n * It is the only thing standing between \"users may edit their own row\" and\n * \"users may grant themselves any role\".\n */\nfunction adminWriteGate(tableName: string): SecurityRule {\n return {\n name: `${tableName}_require_admin_write`,\n mode: \"restrictive\",\n operations: [...DEFAULT_GUARDED_OPS],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n };\n}\n\n/**\n * The restrictive tenancy policy, as a list of zero or one.\n *\n * A list so the two call sites can splice it in without a conditional, and a\n * separate function so it is obvious that it is injected on *both* paths —\n * including the `disableDefaultPolicies` one, where it is the only permissive-\n * looking thing that stays. See `./tenant.ts`.\n */\nfunction tenantRule(collection: CollectionConfig): SecurityRule[] {\n const rule = buildTenantSecurityRule(collection);\n return rule ? [rule] : [];\n}\n\n/**\n * Whether a collection goes without the injected baseline: its own\n * `disableDefaultPolicies` when it says, otherwise the database's\n * `rls.defaultPolicies` (`database({ rls: { defaultPolicies: false } })`).\n */\nexport function defaultPoliciesDisabled(collection: CollectionConfig): boolean {\n if (!isPostgresCollectionConfig(collection)) return false;\n if (collection.disableDefaultPolicies !== undefined) return collection.disableDefaultPolicies;\n return !declaredRlsPolicyShape(collection.dataSource ?? DEFAULT_DATA_SOURCE_KEY).defaultPolicies;\n}\n\nexport function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {\n const explicit = [...(collection.securityRules ?? [])];\n\n const tableName = getTableName(collection);\n const injected: SecurityRule[] = [];\n\n if (defaultPoliciesDisabled(collection)) {\n // The opt-out drops the *permissive* defaults — the ones that grant.\n // The restrictive admin-write gate on an auth collection is not among\n // them, because it is different in kind: a restrictive policy is ANDed\n // with every other policy and can only ever remove access, so opting\n // out of it cannot express anything except \"let more people write\".\n //\n // Dropping it did exactly that. `{ disableDefaultPolicies: true,\n // securityRules: [{ operation: \"all\", ownerField: \"id\" }] }` — an\n // ordinary \"users may edit their own row\" configuration — let any\n // signed-in user set their own `roles` to `[\"admin\"]`, with no warning\n // from any boot guard, doctor check or validator.\n //\n // An author who needs a different gate can add their own restrictive\n // rule; they cannot end up with none by accident.\n // Tenancy survives the opt-out for exactly the reason the write gate\n // does: it is restrictive, so it can only ever remove access. Dropping\n // it could express nothing except \"let every tenant read every other\n // tenant's rows\", which is not a thing `disableDefaultPolicies` is for\n // — that flag is about taking over the *grants*.\n return [...explicit, ...tenantRule(collection), ...(isAuthCollection(collection)\n ? [adminWriteGate(tableName)]\n : [])];\n }\n\n // Baseline read + write: the server context and admins can always operate.\n // RLS default-denies under the user role, so without these a rule-less\n // collection would be locked to everyone — including the admin studio.\n // Author rules are permissive and broaden access from here.\n injected.push({\n name: `${tableName}_default_admin_read`,\n operations: [\"select\"],\n condition: SERVER_OR_ADMIN_EXPR\n });\n injected.push({\n name: `${tableName}_default_admin_write`,\n operations: [...DEFAULT_GUARDED_OPS],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n });\n\n if (isAuthCollection(collection)) {\n // Self-read: a user can always read their own row.\n injected.push({\n name: `${tableName}_default_self_read`,\n operations: [\"select\"],\n condition: policy.compare(policy.field(getIdPropertyName(collection)), \"eq\", policy.authUid())\n });\n\n // Restrictive gate: AND'd with all other policies, so no permissive rule\n // (e.g. an owner \"edit your own row\" rule) can let a non-admin change\n // privileged columns like `roles`. Survives `disableDefaultPolicies` —\n // see the note above the opt-out.\n injected.push(adminWriteGate(tableName));\n }\n\n // Last, so it reads as what it is: a restriction ANDed over everything\n // above it, author rules included.\n injected.push(...tenantRule(collection));\n\n return [...explicit, ...injected];\n}\n\n/**\n * The framework defaults that {@link getEffectiveSecurityRules} would add to a\n * collection, without the author's own rules.\n *\n * These policies appear in the database under names the author never wrote, and\n * a permissive policy ORs with every other permissive policy — so someone\n * reading their `securityRules` and then the real ACL sees more access than they\n * declared. Dropping them by hand does nothing either: `db push` is declarative,\n * so the next push asserts them again. Callers use this to say, in the generated\n * DDL, which policies are injected and how to take them off.\n */\nexport function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[] {\n if (defaultPoliciesDisabled(collection)) {\n // Not empty for an auth collection, nor for a tenant-scoped one: both\n // restrictive rules are still injected, and the generated DDL has to\n // say so — a policy in the database that the author never wrote and\n // cannot find in this list is exactly the surprise this function exists\n // to prevent.\n return [...tenantRule(collection), ...(isAuthCollection(collection)\n ? [adminWriteGate(getTableName(collection))]\n : [])];\n }\n\n const explicitCount = (collection.securityRules ?? []).length;\n // getEffectiveSecurityRules appends the defaults after the author's rules,\n // so everything past the author's count is injected.\n return getEffectiveSecurityRules(collection).slice(explicitCount);\n}\n\n/**\n * Every policy name `rebase db push` would write for a collection.\n *\n * This is the answer to \"did the codebase produce this live policy?\", and it is\n * more than `securityRules.map(r => r.name)` for two reasons:\n *\n * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one\n * per operation, so comparing `rule.name` to `policyname` never matches it;\n * - the generator also injects the safe-by-default baseline\n * (`<table>_default_admin_*`), which is in no collection's `securityRules`.\n *\n * Every UI that flags drift has to get both right, and each one that derived it\n * by hand got a different subset — which is how four policies *Rebase itself\n * wrote* came to be badged as hand-written drift on every table in a project,\n * with a button offering to import them back into the codebase that produced\n * them. There is one derivation now, and this is it.\n */\nexport function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {\n return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoEA,IAAM,uBAAyC,OAAO,GAClD,OAAO,cAAc,GACrB,OAAO,aAAa,CAAC,OAAO,CAAC,CACjC;;AAGA,IAAM,sBAA2C;CAAC;CAAU;CAAU;AAAQ;;AAG9E,SAAS,iBAAiB,YAAuC;CAC7D,MAAM,OAAO,WAAW;CACxB,OAAO,SAAS,QAAS,OAAO,SAAS,YAAa,MAA+B,YAAY;AACrG;;AAGA,SAAS,kBAAkB,YAAsC;CAC7D,KAAK,MAAM,CAAC,MAAM,SAAS,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GACjE,IAAI,QAAQ,OAAO,SAAS,YAAY,UAAU,QAAS,KAA4B,MACnF,OAAO;CAGf,OAAO;AACX;;;;;;;;;;;;;;;;;AAkBA,SAAS,eAAe,WAAiC;CACrD,OAAO;EACH,MAAM,GAAG,UAAU;EACnB,MAAM;EACN,YAAY,CAAC,GAAG,mBAAmB;EACnC,WAAW;EACX,OAAO;CACX;AACJ;;;;;;;;;AAUA,SAAS,WAAW,YAA8C;CAC9D,MAAM,OAAO,wBAAwB,UAAU;CAC/C,OAAO,OAAO,CAAC,IAAI,IAAI,CAAC;AAC5B;;;;;;AAOA,SAAgB,wBAAwB,YAAuC;CAC3E,IAAI,CAAC,2BAA2B,UAAU,GAAG,OAAO;CACpD,IAAI,WAAW,2BAA2B,KAAA,GAAW,OAAO,WAAW;CACvE,OAAO,CAAC,uBAAuB,WAAW,cAAc,uBAAuB,CAAC,CAAC;AACrF;AAEA,SAAgB,0BAA0B,YAA8C;CACpF,MAAM,WAAW,CAAC,GAAI,WAAW,iBAAiB,CAAC,CAAE;CAErD,MAAM,YAAY,aAAa,UAAU;CACzC,MAAM,WAA2B,CAAC;CAElC,IAAI,wBAAwB,UAAU,GAoBlC,OAAO;EAAC,GAAG;EAAU,GAAG,WAAW,UAAU;EAAG,GAAI,iBAAiB,UAAU,IACzE,CAAC,eAAe,SAAS,CAAC,IAC1B,CAAC;CAAE;CAOb,SAAS,KAAK;EACV,MAAM,GAAG,UAAU;EACnB,YAAY,CAAC,QAAQ;EACrB,WAAW;CACf,CAAC;CACD,SAAS,KAAK;EACV,MAAM,GAAG,UAAU;EACnB,YAAY,CAAC,GAAG,mBAAmB;EACnC,WAAW;EACX,OAAO;CACX,CAAC;CAED,IAAI,iBAAiB,UAAU,GAAG;EAE9B,SAAS,KAAK;GACV,MAAM,GAAG,UAAU;GACnB,YAAY,CAAC,QAAQ;GACrB,WAAW,OAAO,QAAQ,OAAO,MAAM,kBAAkB,UAAU,CAAC,GAAG,MAAM,OAAO,QAAQ,CAAC;EACjG,CAAC;EAMD,SAAS,KAAK,eAAe,SAAS,CAAC;CAC3C;CAIA,SAAS,KAAK,GAAG,WAAW,UAAU,CAAC;CAEvC,OAAO,CAAC,GAAG,UAAU,GAAG,QAAQ;AACpC;;;;;;;;;;;;AAaA,SAAgB,yBAAyB,YAA8C;CACnF,IAAI,wBAAwB,UAAU,GAMlC,OAAO,CAAC,GAAG,WAAW,UAAU,GAAG,GAAI,iBAAiB,UAAU,IAC5D,CAAC,eAAe,aAAa,UAAU,CAAC,CAAC,IACzC,CAAC,CAAE;CAGb,MAAM,iBAAiB,WAAW,iBAAiB,CAAC,EAAA,CAAG;CAGvD,OAAO,0BAA0B,UAAU,CAAC,CAAC,MAAM,aAAa;AACpE;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,wBAAwB,YAA2C;CAC/E,OAAO,uBAAuB,0BAA0B,UAAU,GAAG,aAAa,UAAU,CAAC;AACjG"}
|
|
@@ -84,6 +84,30 @@ export declare const REBASE_USER_ROLE = "rebase_user";
|
|
|
84
84
|
* licence to claim more of them.
|
|
85
85
|
*/
|
|
86
86
|
export declare const REBASE_INTERNAL_TABLES: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* PL/pgSQL that fills a `request_roles text[]` variable with the roles this
|
|
89
|
+
* database's requests run as: what a provisioning boot recorded in
|
|
90
|
+
* `rebase.schema_meta` (`request_roles`), or `rebase_user` where nothing was
|
|
91
|
+
* recorded — every database from before the role became a setting.
|
|
92
|
+
*
|
|
93
|
+
* Read from the database, not passed in, so every store that revokes or grants
|
|
94
|
+
* on its own objects agrees with the server about the role without being handed
|
|
95
|
+
* it, whatever order the boot runs them in. Dynamic, because the table may not
|
|
96
|
+
* exist yet and a static reference to it would fail the whole block.
|
|
97
|
+
*/
|
|
98
|
+
export declare const LOAD_REQUEST_ROLES_PLPGSQL: string;
|
|
99
|
+
/**
|
|
100
|
+
* SQL: the oids of every role that could be a request role from this
|
|
101
|
+
* connection — roles it may become that cannot log in, Postgres's own `pg_*`
|
|
102
|
+
* roles aside. For a plain catalogue read that decides whether a store has a
|
|
103
|
+
* revoke to run, where the recorded list in `rebase.schema_meta` may not exist
|
|
104
|
+
* yet and so cannot be named.
|
|
105
|
+
*
|
|
106
|
+
* Always a superset of the real request roles, since the connection must be
|
|
107
|
+
* able to switch to them. A role in it that is not one only makes the store run
|
|
108
|
+
* {@link revokeInternalTableSql}, whose own test is exact and finds nothing to do.
|
|
109
|
+
*/
|
|
110
|
+
export declare const REQUEST_ROLE_CANDIDATES_SQL: string;
|
|
87
111
|
/**
|
|
88
112
|
* A single statement that takes every privilege on `schema.table` away from the
|
|
89
113
|
* end-user role.
|
|
@@ -130,6 +154,13 @@ export declare const REBASE_INTERNAL_TABLES: readonly string[];
|
|
|
130
154
|
* and reject multi-statement strings.
|
|
131
155
|
*/
|
|
132
156
|
export declare function revokeInternalTableSql(schema: string, table: string): string;
|
|
157
|
+
/**
|
|
158
|
+
* One statement granting `EXECUTE` on a function to every request role, for
|
|
159
|
+
* the `SECURITY DEFINER` functions a request calls (the job queue's enqueue,
|
|
160
|
+
* the history writer). Guarded per role, so a database whose request role does
|
|
161
|
+
* not exist (an unprivileged connection) finds it a no-op.
|
|
162
|
+
*/
|
|
163
|
+
export declare function grantExecuteToRequestRolesSql(signature: string): string;
|
|
133
164
|
/**
|
|
134
165
|
* Revoke on every internal table in `schema`, one statement at a time.
|
|
135
166
|
*
|
|
@@ -109,6 +109,35 @@ var REBASE_INTERNAL_TABLES = [
|
|
|
109
109
|
"channel_presence",
|
|
110
110
|
"atlas_schema_revisions"
|
|
111
111
|
];
|
|
112
|
+
/**
|
|
113
|
+
* PL/pgSQL that fills a `request_roles text[]` variable with the roles this
|
|
114
|
+
* database's requests run as: what a provisioning boot recorded in
|
|
115
|
+
* `rebase.schema_meta` (`request_roles`), or `rebase_user` where nothing was
|
|
116
|
+
* recorded — every database from before the role became a setting.
|
|
117
|
+
*
|
|
118
|
+
* Read from the database, not passed in, so every store that revokes or grants
|
|
119
|
+
* on its own objects agrees with the server about the role without being handed
|
|
120
|
+
* it, whatever order the boot runs them in. Dynamic, because the table may not
|
|
121
|
+
* exist yet and a static reference to it would fail the whole block.
|
|
122
|
+
*/
|
|
123
|
+
var LOAD_REQUEST_ROLES_PLPGSQL = `
|
|
124
|
+
IF to_regclass('"rebase"."schema_meta"') IS NOT NULL THEN
|
|
125
|
+
EXECUTE $request_roles$SELECT string_to_array(value, ',') FROM "rebase"."schema_meta" WHERE key = 'request_roles'$request_roles$
|
|
126
|
+
INTO request_roles;
|
|
127
|
+
END IF;
|
|
128
|
+
request_roles := COALESCE(request_roles, ARRAY['${REBASE_USER_ROLE}']);`.trim();
|
|
129
|
+
/**
|
|
130
|
+
* SQL: the oids of every role that could be a request role from this
|
|
131
|
+
* connection — roles it may become that cannot log in, Postgres's own `pg_*`
|
|
132
|
+
* roles aside. For a plain catalogue read that decides whether a store has a
|
|
133
|
+
* revoke to run, where the recorded list in `rebase.schema_meta` may not exist
|
|
134
|
+
* yet and so cannot be named.
|
|
135
|
+
*
|
|
136
|
+
* Always a superset of the real request roles, since the connection must be
|
|
137
|
+
* able to switch to them. A role in it that is not one only makes the store run
|
|
138
|
+
* {@link revokeInternalTableSql}, whose own test is exact and finds nothing to do.
|
|
139
|
+
*/
|
|
140
|
+
var REQUEST_ROLE_CANDIDATES_SQL = "ARRAY(SELECT r.oid FROM pg_roles r WHERE NOT r.rolcanlogin AND r.rolname NOT LIKE 'pg\\_%' AND r.rolname <> current_user AND pg_has_role(current_user, r.oid, 'MEMBER'))";
|
|
112
141
|
/** Postgres identifiers this module is willing to interpolate. */
|
|
113
142
|
var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
114
143
|
/**
|
|
@@ -160,28 +189,59 @@ function revokeInternalTableSql(schema, table) {
|
|
|
160
189
|
if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
|
|
161
190
|
if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
|
|
162
191
|
const qualified = `"${schema}"."${table}"`;
|
|
163
|
-
const role =
|
|
192
|
+
const role = "(SELECT oid FROM pg_roles WHERE rolname = request_role)";
|
|
164
193
|
return `
|
|
165
194
|
DO $rebase_revoke$
|
|
195
|
+
DECLARE
|
|
196
|
+
request_roles text[];
|
|
197
|
+
request_role text;
|
|
166
198
|
BEGIN
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
(SELECT relacl IS NULL FROM pg_class WHERE oid = to_regclass('${qualified}'))
|
|
172
|
-
OR EXISTS (SELECT 1 FROM pg_class c, aclexplode(c.relacl) a
|
|
173
|
-
WHERE c.oid = to_regclass('${qualified}') AND a.grantee = ${role})
|
|
174
|
-
OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
|
|
175
|
-
WHERE att.attrelid = to_regclass('${qualified}') AND att.attacl IS NOT NULL
|
|
176
|
-
AND a.grantee = ${role})
|
|
177
|
-
) THEN
|
|
178
|
-
EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
|
|
199
|
+
${LOAD_REQUEST_ROLES_PLPGSQL}
|
|
200
|
+
IF to_regclass('${qualified}') IS NULL
|
|
201
|
+
OR (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}')) THEN
|
|
202
|
+
RETURN;
|
|
179
203
|
END IF;
|
|
204
|
+
FOREACH request_role IN ARRAY request_roles LOOP
|
|
205
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = request_role)
|
|
206
|
+
AND (
|
|
207
|
+
(SELECT relacl IS NULL FROM pg_class WHERE oid = to_regclass('${qualified}'))
|
|
208
|
+
OR EXISTS (SELECT 1 FROM pg_class c, aclexplode(c.relacl) a
|
|
209
|
+
WHERE c.oid = to_regclass('${qualified}') AND a.grantee = ${role})
|
|
210
|
+
OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
|
|
211
|
+
WHERE att.attrelid = to_regclass('${qualified}') AND att.attacl IS NOT NULL
|
|
212
|
+
AND a.grantee = ${role})
|
|
213
|
+
) THEN
|
|
214
|
+
EXECUTE format('REVOKE ALL ON ${qualified} FROM %I', request_role);
|
|
215
|
+
END IF;
|
|
216
|
+
END LOOP;
|
|
180
217
|
END
|
|
181
218
|
$rebase_revoke$;
|
|
182
219
|
`.trim();
|
|
183
220
|
}
|
|
184
221
|
/**
|
|
222
|
+
* One statement granting `EXECUTE` on a function to every request role, for
|
|
223
|
+
* the `SECURITY DEFINER` functions a request calls (the job queue's enqueue,
|
|
224
|
+
* the history writer). Guarded per role, so a database whose request role does
|
|
225
|
+
* not exist (an unprivileged connection) finds it a no-op.
|
|
226
|
+
*/
|
|
227
|
+
function grantExecuteToRequestRolesSql(signature) {
|
|
228
|
+
return `
|
|
229
|
+
DO $rebase_grant_execute$
|
|
230
|
+
DECLARE
|
|
231
|
+
request_roles text[];
|
|
232
|
+
request_role text;
|
|
233
|
+
BEGIN
|
|
234
|
+
${LOAD_REQUEST_ROLES_PLPGSQL}
|
|
235
|
+
FOREACH request_role IN ARRAY request_roles LOOP
|
|
236
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = request_role) THEN
|
|
237
|
+
EXECUTE format('GRANT EXECUTE ON FUNCTION ${signature.replace(/'/g, "''")} TO %I', request_role);
|
|
238
|
+
END IF;
|
|
239
|
+
END LOOP;
|
|
240
|
+
END
|
|
241
|
+
$rebase_grant_execute$
|
|
242
|
+
`.trim();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
185
245
|
* Revoke on every internal table in `schema`, one statement at a time.
|
|
186
246
|
*
|
|
187
247
|
* Best-effort per table: a connection that does not own one of them (a
|
|
@@ -197,6 +257,6 @@ async function revokeInternalTableAccess(execute, schema, options) {
|
|
|
197
257
|
}
|
|
198
258
|
}
|
|
199
259
|
//#endregion
|
|
200
|
-
export { REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, revokeInternalTableAccess, revokeInternalTableSql };
|
|
260
|
+
export { LOAD_REQUEST_ROLES_PLPGSQL, REBASE_INTERNAL_TABLES, REBASE_USER_ROLE, REQUEST_ROLE_CANDIDATES_SQL, grantExecuteToRequestRolesSql, revokeInternalTableAccess, revokeInternalTableSql };
|
|
201
261
|
|
|
202
262
|
//# sourceMappingURL=internal-tables.es.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal-tables.es.js","names":[],"sources":["../../src/util/internal-tables.ts"],"sourcesContent":["/**\n * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the\n * end-user role away from them.\n *\n * ## Why this exists\n *\n * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role\n * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table\n * in the schemas a project uses — including `rebase`, because a project's own\n * collections are allowed to live there (the scaffold puts `users` there). It\n * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the\n * migrating role inherits the same grant.\n *\n * Every framework-internal table is created later: auth's tables come up during\n * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first\n * job registers, `idempotency_keys` on the first request that carries a key. So\n * they all inherited full DML for the end-user role — and none of them enables\n * row-level security, because none of them is a collection with\n * `securityRules`. Measured on a freshly provisioned database, `SET ROLE\n * rebase_user` could read `rebase.refresh_tokens` (session token hashes),\n * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and\n * `rebase.api_keys` (including its `admin` flag), and insert into\n * `rebase.app_config`.\n *\n * Nothing routes a user-context query at those tables today, so this was not\n * reachable over the API. That is the wrong thing to depend on: the documented\n * model is that RLS is the authorization boundary, and these tables sat outside\n * it. The boundary is now a privilege boundary instead — the role simply cannot\n * address them.\n *\n * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY\n *\n * RLS with no policy denies every row, which is the same outcome, but it is the\n * *weaker* statement: it leaves the grant in place, so a later policy — or a\n * `FORCE` flag cleared by some future migration — reopens the table. There is no\n * row of `refresh_tokens` any end user should ever reach, so the honest encoding\n * is \"this role has no privilege here at all\". It also keeps the owner\n * connection (which auth actually runs on) completely unaffected.\n *\n * ## Keeping it true\n *\n * `packages/rls-check` scans the `rebase` schema — it used to skip it as a\n * \"platform\" schema — and its `rls-disabled` check fires on exactly the\n * condition this module removes: RLS off *and* a DML grant to a reachable role.\n * So a table added here without a revoke is caught by `pnpm rls:check`, not by\n * someone re-reading this file.\n */\n\n/**\n * The Postgres role authenticated requests run as.\n *\n * Defined here rather than in the Postgres driver because both the driver (which\n * provisions the role) and this module (which revokes on its behalf) need it,\n * and a second spelling of a role name is a silent no-op waiting to happen.\n */\nexport const REBASE_USER_ROLE = \"rebase_user\";\n\n/**\n * Framework-internal table names, unqualified.\n *\n * Deliberately NOT including `users`: the auth user table is also a collection,\n * with `securityRules`, RLS enabled and policies applied. Users read their own\n * row through it — revoking there would break sign-in. `revokeInternalTableSql`\n * now skips any table with RLS enabled, so that exception is enforced rather\n * than merely remembered — and so is the same hazard for every other name here,\n * any of which a project may legitimately use for a collection of its own.\n *\n * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`\n * because `db migrate apply` passes `--revisions-schema rebase`.\n *\n * Every entry here must also be revoked by whatever creates it, and vice versa:\n * the creation-time revoke fires once, on the boot that first makes the table,\n * so it cannot help a database provisioned before that revoke existed. This\n * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep\n * is the only thing that can repair an already-granted table. A table revoked\n * at creation but missing here is therefore permanently stranded on any\n * database that predates its revoke.\n *\n * These names are unqualified, and the boot-time sweep applies them to every\n * schema a project uses — so an entry here is a claim on that name in `public`\n * as much as in `rebase`. `jobs` is Rebase's queue at `rebase.jobs` AND a\n * perfectly ordinary collection name, and revoking `public.jobs` from a project\n * that owns it leaves every read failing 42501 with correct policies applied\n * and nothing in the RLS logs to explain it. The `relrowsecurity` guard in\n * `revokeInternalTableSql` is what makes a common noun safe here — it is not a\n * licence to claim more of them.\n */\nexport const REBASE_INTERNAL_TABLES: readonly string[] = [\n // auth\n \"user_identities\",\n \"refresh_tokens\",\n \"password_reset_tokens\",\n \"magic_link_tokens\",\n \"mfa_factors\",\n \"mfa_challenges\",\n \"recovery_codes\",\n \"app_config\",\n \"schema_meta\",\n // platform services\n \"api_keys\",\n \"cron_logs\",\n \"cron_claims\",\n \"cron_job_state\",\n \"jobs\",\n \"rate_limit_hits\",\n \"idempotency_keys\",\n \"entity_history\",\n \"branches\",\n \"metric_samples\",\n // realtime channels — authorization for these lives in the channel rules the\n // server evaluates before it reads or writes, never in a row policy\n \"channel_messages\",\n \"channel_cursors\",\n \"channel_presence\",\n // migration bookkeeping\n \"atlas_schema_revisions\"\n];\n\n/** Postgres identifiers this module is willing to interpolate. */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;\n\n/**\n * A single statement that takes every privilege on `schema.table` away from the\n * end-user role.\n *\n * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which\n * happen in practice:\n *\n * - the role does not exist when the connection is unprivileged (Rebase then\n * relies on native RLS rather than a role switch), and a bare `REVOKE` on a\n * missing role is an error, not a no-op;\n * - the table may not exist yet — `cron_logs` never appears in a project with\n * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.\n *\n * The third guard is the one that decides whether the *right* table is being\n * revoked. The names in {@link REBASE_INTERNAL_TABLES} are unqualified, and the\n * boot-time sweep in `ensureAppRole` applies all of them to every schema a\n * project uses — including the schema its own collections live in. A project is\n * free to call a collection `jobs`, `branches` or `api_keys`, and when it does,\n * the sweep was revoking `rebase_user`'s DML on the project's table on every\n * single boot. That is not a subtle degradation: the collection's whole API\n * answers 500 `permission denied for table …` from then on, which is what\n * happened to a public job board whose vacancies live in `public.jobs`.\n *\n * `relrowsecurity` separates the two cleanly, and it is the same fact this\n * module already relies on. Framework-internal tables carry no RLS — that is the\n * premise stated at the top of this file, and the reason a revoke is needed at\n * all. Every collection table has it enabled, because that is how Rebase\n * enforces `securityRules`. So \"RLS is off\" is exactly \"this is not somebody's\n * collection\", and the guard also subsumes the hand-carved `users` exception:\n * the auth user table is a collection, has RLS, and would now be skipped on its\n * own merits rather than by being kept off a list.\n *\n * The fourth guard is about cost, and leaves the end state exactly as it was:\n * the `REVOKE` runs only when it would change something. A `REVOKE` rewrites the\n * table's `pg_class` row whether or not anything was granted, which invalidates\n * every cached plan that touches the table on every connection. Every store\n * that owns an internal table runs this on every boot. So it is skipped when the\n * end-user role holds nothing on the table — no entry in its ACL or in any\n * column's ACL — and the ACL has been written. A table whose ACL has never been\n * written (`relacl` NULL) is still revoked once: the `REVOKE` writes the owner's\n * default ACL out, exactly as before, and from then on it is skipped.\n *\n * One command, so it is safe on handles that speak the extended query protocol\n * and reject multi-statement strings.\n */\nexport function revokeInternalTableSql(schema: string, table: string): string {\n if (!SAFE_IDENTIFIER.test(schema)) {\n throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);\n }\n if (!SAFE_IDENTIFIER.test(table)) {\n throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);\n }\n const qualified = `\"${schema}\".\"${table}\"`;\n const role = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;\n return `\n DO $rebase_revoke$\n BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')\n AND to_regclass('${qualified}') IS NOT NULL\n AND NOT (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}'))\n AND (\n (SELECT relacl IS NULL FROM pg_class WHERE oid = to_regclass('${qualified}'))\n OR EXISTS (SELECT 1 FROM pg_class c, aclexplode(c.relacl) a\n WHERE c.oid = to_regclass('${qualified}') AND a.grantee = ${role})\n OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a\n WHERE att.attrelid = to_regclass('${qualified}') AND att.attacl IS NOT NULL\n AND a.grantee = ${role})\n ) THEN\n EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';\n END IF;\n END\n $rebase_revoke$;\n `.trim();\n}\n\n/**\n * Revoke on every internal table in `schema`, one statement at a time.\n *\n * Best-effort per table: a connection that does not own one of them (a\n * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and\n * that must not take down a boot. The caller decides how loud to be — `onError`\n * exists so the driver can warn without this module importing a logger.\n */\nexport async function revokeInternalTableAccess(\n execute: (sql: string) => Promise<unknown>,\n schema: string,\n options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }\n): Promise<void> {\n for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {\n try {\n await execute(revokeInternalTableSql(schema, table));\n } catch (error) {\n options?.onError?.(table, error);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,IAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgChC,IAAa,yBAA4C;CAErD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAGA;CACA;CACA;CAEA;AACJ;;AAGA,IAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CxB,SAAgB,uBAAuB,QAAgB,OAAuB;CAC1E,IAAI,CAAC,gBAAgB,KAAK,MAAM,GAC5B,MAAM,IAAI,MAAM,qDAAqD,KAAK,UAAU,MAAM,GAAG;CAEjG,IAAI,CAAC,gBAAgB,KAAK,KAAK,GAC3B,MAAM,IAAI,MAAM,oDAAoD,KAAK,UAAU,KAAK,GAAG;CAE/F,MAAM,YAAY,IAAI,OAAO,KAAK,MAAM;CACxC,MAAM,OAAO,8CAA8C,iBAAiB;CAC5E,OAAO;;;iEAGsD,iBAAiB;kCAChD,UAAU;uFAC2C,UAAU;;mFAEd,UAAU;;2DAElC,UAAU,qBAAqB,KAAK;;kEAE7B,UAAU;kDAC1B,KAAK;;yCAEd,UAAU,QAAQ,iBAAiB;;;;MAItE,KAAK;AACX;;;;;;;;;AAUA,eAAsB,0BAClB,SACA,QACA,SACa;CACb,KAAK,MAAM,SAAS,SAAS,UAAU,wBACnC,IAAI;EACA,MAAM,QAAQ,uBAAuB,QAAQ,KAAK,CAAC;CACvD,SAAS,OAAO;EACZ,SAAS,UAAU,OAAO,KAAK;CACnC;AAER"}
|
|
1
|
+
{"version":3,"file":"internal-tables.es.js","names":[],"sources":["../../src/util/internal-tables.ts"],"sourcesContent":["/**\n * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the\n * end-user role away from them.\n *\n * ## Why this exists\n *\n * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role\n * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table\n * in the schemas a project uses — including `rebase`, because a project's own\n * collections are allowed to live there (the scaffold puts `users` there). It\n * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the\n * migrating role inherits the same grant.\n *\n * Every framework-internal table is created later: auth's tables come up during\n * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first\n * job registers, `idempotency_keys` on the first request that carries a key. So\n * they all inherited full DML for the end-user role — and none of them enables\n * row-level security, because none of them is a collection with\n * `securityRules`. Measured on a freshly provisioned database, `SET ROLE\n * rebase_user` could read `rebase.refresh_tokens` (session token hashes),\n * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and\n * `rebase.api_keys` (including its `admin` flag), and insert into\n * `rebase.app_config`.\n *\n * Nothing routes a user-context query at those tables today, so this was not\n * reachable over the API. That is the wrong thing to depend on: the documented\n * model is that RLS is the authorization boundary, and these tables sat outside\n * it. The boundary is now a privilege boundary instead — the role simply cannot\n * address them.\n *\n * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY\n *\n * RLS with no policy denies every row, which is the same outcome, but it is the\n * *weaker* statement: it leaves the grant in place, so a later policy — or a\n * `FORCE` flag cleared by some future migration — reopens the table. There is no\n * row of `refresh_tokens` any end user should ever reach, so the honest encoding\n * is \"this role has no privilege here at all\". It also keeps the owner\n * connection (which auth actually runs on) completely unaffected.\n *\n * ## Keeping it true\n *\n * `packages/rls-check` scans the `rebase` schema — it used to skip it as a\n * \"platform\" schema — and its `rls-disabled` check fires on exactly the\n * condition this module removes: RLS off *and* a DML grant to a reachable role.\n * So a table added here without a revoke is caught by `pnpm rls:check`, not by\n * someone re-reading this file.\n */\n\n/**\n * The Postgres role authenticated requests run as.\n *\n * Defined here rather than in the Postgres driver because both the driver (which\n * provisions the role) and this module (which revokes on its behalf) need it,\n * and a second spelling of a role name is a silent no-op waiting to happen.\n */\nexport const REBASE_USER_ROLE = \"rebase_user\";\n\n/**\n * Framework-internal table names, unqualified.\n *\n * Deliberately NOT including `users`: the auth user table is also a collection,\n * with `securityRules`, RLS enabled and policies applied. Users read their own\n * row through it — revoking there would break sign-in. `revokeInternalTableSql`\n * now skips any table with RLS enabled, so that exception is enforced rather\n * than merely remembered — and so is the same hazard for every other name here,\n * any of which a project may legitimately use for a collection of its own.\n *\n * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`\n * because `db migrate apply` passes `--revisions-schema rebase`.\n *\n * Every entry here must also be revoked by whatever creates it, and vice versa:\n * the creation-time revoke fires once, on the boot that first makes the table,\n * so it cannot help a database provisioned before that revoke existed. This\n * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep\n * is the only thing that can repair an already-granted table. A table revoked\n * at creation but missing here is therefore permanently stranded on any\n * database that predates its revoke.\n *\n * These names are unqualified, and the boot-time sweep applies them to every\n * schema a project uses — so an entry here is a claim on that name in `public`\n * as much as in `rebase`. `jobs` is Rebase's queue at `rebase.jobs` AND a\n * perfectly ordinary collection name, and revoking `public.jobs` from a project\n * that owns it leaves every read failing 42501 with correct policies applied\n * and nothing in the RLS logs to explain it. The `relrowsecurity` guard in\n * `revokeInternalTableSql` is what makes a common noun safe here — it is not a\n * licence to claim more of them.\n */\nexport const REBASE_INTERNAL_TABLES: readonly string[] = [\n // auth\n \"user_identities\",\n \"refresh_tokens\",\n \"password_reset_tokens\",\n \"magic_link_tokens\",\n \"mfa_factors\",\n \"mfa_challenges\",\n \"recovery_codes\",\n \"app_config\",\n \"schema_meta\",\n // platform services\n \"api_keys\",\n \"cron_logs\",\n \"cron_claims\",\n \"cron_job_state\",\n \"jobs\",\n \"rate_limit_hits\",\n \"idempotency_keys\",\n \"entity_history\",\n \"branches\",\n \"metric_samples\",\n // realtime channels — authorization for these lives in the channel rules the\n // server evaluates before it reads or writes, never in a row policy\n \"channel_messages\",\n \"channel_cursors\",\n \"channel_presence\",\n // migration bookkeeping\n \"atlas_schema_revisions\"\n];\n\n/**\n * PL/pgSQL that fills a `request_roles text[]` variable with the roles this\n * database's requests run as: what a provisioning boot recorded in\n * `rebase.schema_meta` (`request_roles`), or `rebase_user` where nothing was\n * recorded — every database from before the role became a setting.\n *\n * Read from the database, not passed in, so every store that revokes or grants\n * on its own objects agrees with the server about the role without being handed\n * it, whatever order the boot runs them in. Dynamic, because the table may not\n * exist yet and a static reference to it would fail the whole block.\n */\nexport const LOAD_REQUEST_ROLES_PLPGSQL = `\n IF to_regclass('\"rebase\".\"schema_meta\"') IS NOT NULL THEN\n EXECUTE $request_roles$SELECT string_to_array(value, ',') FROM \"rebase\".\"schema_meta\" WHERE key = 'request_roles'$request_roles$\n INTO request_roles;\n END IF;\n request_roles := COALESCE(request_roles, ARRAY['${REBASE_USER_ROLE}']);`.trim();\n\n/**\n * SQL: the oids of every role that could be a request role from this\n * connection — roles it may become that cannot log in, Postgres's own `pg_*`\n * roles aside. For a plain catalogue read that decides whether a store has a\n * revoke to run, where the recorded list in `rebase.schema_meta` may not exist\n * yet and so cannot be named.\n *\n * Always a superset of the real request roles, since the connection must be\n * able to switch to them. A role in it that is not one only makes the store run\n * {@link revokeInternalTableSql}, whose own test is exact and finds nothing to do.\n */\nexport const REQUEST_ROLE_CANDIDATES_SQL =\n \"ARRAY(SELECT r.oid FROM pg_roles r WHERE NOT r.rolcanlogin AND r.rolname NOT LIKE 'pg\\\\_%' \" +\n \"AND r.rolname <> current_user AND pg_has_role(current_user, r.oid, 'MEMBER'))\";\n\n/** Postgres identifiers this module is willing to interpolate. */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;\n\n/**\n * A single statement that takes every privilege on `schema.table` away from the\n * end-user role.\n *\n * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which\n * happen in practice:\n *\n * - the role does not exist when the connection is unprivileged (Rebase then\n * relies on native RLS rather than a role switch), and a bare `REVOKE` on a\n * missing role is an error, not a no-op;\n * - the table may not exist yet — `cron_logs` never appears in a project with\n * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.\n *\n * The third guard is the one that decides whether the *right* table is being\n * revoked. The names in {@link REBASE_INTERNAL_TABLES} are unqualified, and the\n * boot-time sweep in `ensureAppRole` applies all of them to every schema a\n * project uses — including the schema its own collections live in. A project is\n * free to call a collection `jobs`, `branches` or `api_keys`, and when it does,\n * the sweep was revoking `rebase_user`'s DML on the project's table on every\n * single boot. That is not a subtle degradation: the collection's whole API\n * answers 500 `permission denied for table …` from then on, which is what\n * happened to a public job board whose vacancies live in `public.jobs`.\n *\n * `relrowsecurity` separates the two cleanly, and it is the same fact this\n * module already relies on. Framework-internal tables carry no RLS — that is the\n * premise stated at the top of this file, and the reason a revoke is needed at\n * all. Every collection table has it enabled, because that is how Rebase\n * enforces `securityRules`. So \"RLS is off\" is exactly \"this is not somebody's\n * collection\", and the guard also subsumes the hand-carved `users` exception:\n * the auth user table is a collection, has RLS, and would now be skipped on its\n * own merits rather than by being kept off a list.\n *\n * The fourth guard is about cost, and leaves the end state exactly as it was:\n * the `REVOKE` runs only when it would change something. A `REVOKE` rewrites the\n * table's `pg_class` row whether or not anything was granted, which invalidates\n * every cached plan that touches the table on every connection. Every store\n * that owns an internal table runs this on every boot. So it is skipped when the\n * end-user role holds nothing on the table — no entry in its ACL or in any\n * column's ACL — and the ACL has been written. A table whose ACL has never been\n * written (`relacl` NULL) is still revoked once: the `REVOKE` writes the owner's\n * default ACL out, exactly as before, and from then on it is skipped.\n *\n * One command, so it is safe on handles that speak the extended query protocol\n * and reject multi-statement strings.\n */\nexport function revokeInternalTableSql(schema: string, table: string): string {\n if (!SAFE_IDENTIFIER.test(schema)) {\n throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);\n }\n if (!SAFE_IDENTIFIER.test(table)) {\n throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);\n }\n const qualified = `\"${schema}\".\"${table}\"`;\n const role = \"(SELECT oid FROM pg_roles WHERE rolname = request_role)\";\n return `\n DO $rebase_revoke$\n DECLARE\n request_roles text[];\n request_role text;\n BEGIN\n ${LOAD_REQUEST_ROLES_PLPGSQL}\n IF to_regclass('${qualified}') IS NULL\n OR (SELECT relrowsecurity FROM pg_class WHERE oid = to_regclass('${qualified}')) THEN\n RETURN;\n END IF;\n FOREACH request_role IN ARRAY request_roles LOOP\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = request_role)\n AND (\n (SELECT relacl IS NULL FROM pg_class WHERE oid = to_regclass('${qualified}'))\n OR EXISTS (SELECT 1 FROM pg_class c, aclexplode(c.relacl) a\n WHERE c.oid = to_regclass('${qualified}') AND a.grantee = ${role})\n OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a\n WHERE att.attrelid = to_regclass('${qualified}') AND att.attacl IS NOT NULL\n AND a.grantee = ${role})\n ) THEN\n EXECUTE format('REVOKE ALL ON ${qualified} FROM %I', request_role);\n END IF;\n END LOOP;\n END\n $rebase_revoke$;\n `.trim();\n}\n\n/**\n * One statement granting `EXECUTE` on a function to every request role, for\n * the `SECURITY DEFINER` functions a request calls (the job queue's enqueue,\n * the history writer). Guarded per role, so a database whose request role does\n * not exist (an unprivileged connection) finds it a no-op.\n */\nexport function grantExecuteToRequestRolesSql(signature: string): string {\n return `\n DO $rebase_grant_execute$\n DECLARE\n request_roles text[];\n request_role text;\n BEGIN\n ${LOAD_REQUEST_ROLES_PLPGSQL}\n FOREACH request_role IN ARRAY request_roles LOOP\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = request_role) THEN\n EXECUTE format('GRANT EXECUTE ON FUNCTION ${signature.replace(/'/g, \"''\")} TO %I', request_role);\n END IF;\n END LOOP;\n END\n $rebase_grant_execute$\n `.trim();\n}\n\n/**\n * Revoke on every internal table in `schema`, one statement at a time.\n *\n * Best-effort per table: a connection that does not own one of them (a\n * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and\n * that must not take down a boot. The caller decides how loud to be — `onError`\n * exists so the driver can warn without this module importing a logger.\n */\nexport async function revokeInternalTableAccess(\n execute: (sql: string) => Promise<unknown>,\n schema: string,\n options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }\n): Promise<void> {\n for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {\n try {\n await execute(revokeInternalTableSql(schema, table));\n } catch (error) {\n options?.onError?.(table, error);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuDA,IAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgChC,IAAa,yBAA4C;CAErD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAGA;CACA;CACA;CAEA;AACJ;;;;;;;;;;;;AAaA,IAAa,6BAA6B;;;;;8DAKoB,iBAAiB,MAAM,KAAK;;;;;;;;;;;;AAa1F,IAAa,8BACT;;AAIJ,IAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CxB,SAAgB,uBAAuB,QAAgB,OAAuB;CAC1E,IAAI,CAAC,gBAAgB,KAAK,MAAM,GAC5B,MAAM,IAAI,MAAM,qDAAqD,KAAK,UAAU,MAAM,GAAG;CAEjG,IAAI,CAAC,gBAAgB,KAAK,KAAK,GAC3B,MAAM,IAAI,MAAM,oDAAoD,KAAK,UAAU,KAAK,GAAG;CAE/F,MAAM,YAAY,IAAI,OAAO,KAAK,MAAM;CACxC,MAAM,OAAO;CACb,OAAO;;;;;;cAMG,2BAA2B;8BACX,UAAU;kFAC0C,UAAU;;;;;;uFAML,UAAU;;+DAElC,UAAU,qBAAqB,KAAK;;sEAE7B,UAAU;sDAC1B,KAAK;;oDAEP,UAAU;;;;;MAKxD,KAAK;AACX;;;;;;;AAQA,SAAgB,8BAA8B,WAA2B;CACrE,OAAO;;;;;;cAMG,2BAA2B;;;gEAGuB,UAAU,QAAQ,MAAM,IAAI,EAAE;;;;;MAKxF,KAAK;AACX;;;;;;;;;AAUA,eAAsB,0BAClB,SACA,QACA,SACa;CACb,KAAK,MAAM,SAAS,SAAS,UAAU,wBACnC,IAAI;EACA,MAAM,QAAQ,uBAAuB,QAAQ,KAAK,CAAC;CACvD,SAAS,OAAO;EACZ,SAAS,UAAU,OAAO,KAAK;CACnC;AAER"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveCollectionRelations } from "./relations.es.js";
|
|
2
2
|
import { securityRuleToConditions } from "./policy/securityRuleToConditions.es.js";
|
|
3
|
+
import { defaultPoliciesDisabled } from "./auth-default-policies.es.js";
|
|
3
4
|
import { JUNCTION_PIVOT_KEY, isManyToMany, isPostgresCollectionConfig, policy } from "@rebasepro/types";
|
|
4
5
|
import { getPolicyOperations } from "@rebasepro/utils";
|
|
5
6
|
//#region src/util/junction-policies.ts
|
|
@@ -206,7 +207,7 @@ function coversUpdate(rule) {
|
|
|
206
207
|
* stays locked (RLS is still enabled) until they write policies for it.
|
|
207
208
|
*/
|
|
208
209
|
function getJunctionSecurityRules(spec) {
|
|
209
|
-
if (spec.declaringSides.every((side) =>
|
|
210
|
+
if (spec.declaringSides.every((side) => defaultPoliciesDisabled(side.collection))) return [];
|
|
210
211
|
const rules = [];
|
|
211
212
|
rules.push({
|
|
212
213
|
name: `${spec.table}_default_admin_read`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"junction-policies.es.js","names":[],"sources":["../../src/util/junction-policies.ts"],"sourcesContent":["import {\n CollectionConfig,\n JUNCTION_PIVOT_KEY,\n PolicyExpression,\n PolicyOperand,\n Properties,\n Property,\n Relation,\n SecurityRule,\n isPostgresCollectionConfig,\n policy\n} from \"@rebasepro/types\";\nimport { getPolicyOperations } from \"@rebasepro/utils\";\nimport { getTableName } from \"./relations\";\nimport { resolveCollectionRelations } from \"./relations\";\nimport { isManyToMany } from \"@rebasepro/types\";\nimport { securityRuleToConditions } from \"./policy/securityRuleToConditions\";\n\n/**\n * RLS derivation for many-to-many junction tables.\n *\n * A `through` relation makes the generator create a table nobody declared as a\n * collection — `posts_tags`, `user_roles`. Those tables used to be the one kind\n * of generated table with **no** RLS at all: `rebase_user` holds full DML grants,\n * so with the endpoints locked down, any signed-up user could still read or wipe\n * every edge between them. There is also nowhere in the config to write rules\n * for a junction, so the author could not even fix it by hand.\n *\n * The architecture here is that a junction's security is *derived*, never\n * hand-written:\n *\n * 1. **Locked baseline.** The same server-or-admin `default_admin` grants every\n * collection gets, so the invariant holds again: every table the generator\n * creates is default-deny, and rules only broaden.\n *\n * 2. **Reads follow the endpoints.** An edge is visible iff *both* endpoint\n * rows are visible — two correlated `EXISTS` subqueries. The subqueries run\n * under the caller's role, so each endpoint's own RLS filters them: junction\n * visibility delegates to the endpoints' policies, whatever they become,\n * with nothing duplicated. A public blog keeps rendering its tags; a private\n * CRM's edges are exactly as hidden as its rows.\n *\n * 3. **Writes follow the owning side's update rules.** Linking or unlinking an\n * edge *is* an edit of the owning row — tagging a post is editing the post —\n * so edge writes inherit the declaring collection's explicit permissive\n * `update` rules, each wrapped in an `EXISTS` against the owning row. Where\n * a rule cannot be embedded faithfully (see below) it is dropped, so the\n * failure mode is always *too locked*, never open. Explicit **restrictive**\n * update rules are inherited as restrictive junction rules; if one of them\n * cannot be embedded, the whole derived write grant for that side is\n * suppressed — granting without the author's gate would be looser than the\n * parent itself.\n *\n * **Embeddability.** A parent rule is embedded by moving its condition inside\n * `EXISTS (SELECT 1 FROM parent WHERE parent.pk = junction.fk AND <condition>)`.\n * In that scope, `field` operands bind to the parent — which is what the author\n * meant. But `outerField` operands and `{column}` placeholders in `raw` SQL bind\n * to the RLS row, which is now the junction, not the parent the author wrote\n * them against. So: `raw` anywhere disqualifies a rule; a top-level `outerField`\n * (equivalent to `field` outside a subquery) is rewritten to `field`; an\n * `outerField` inside a nested `existsIn` cannot be re-scoped and disqualifies\n * the rule.\n *\n * Injected parent defaults are never inherited — the junction's own baseline\n * already covers the server/admin plane, and an auth collection's restrictive\n * `require_admin_write` gate exists to protect privileged parent *columns*,\n * which an edge write cannot touch. Inheriting it would stop users managing\n * e.g. their own interests through a `users_interests` junction for no gain.\n *\n * Everything flows through the shared naming machinery, so the Studio\n * recognises these policies as generated instead of offering to \"import\" them.\n */\n\n/** One side of a junction: the collection and the FK column pointing at it. */\nexport interface JunctionEndpoint {\n collection: CollectionConfig;\n /** Junction column holding this endpoint's key. */\n junctionColumn: string;\n}\n\n/** A collection that declares the `through` relation (owns the edge semantics). */\nexport interface JunctionDeclaringSide extends JunctionEndpoint {\n relation: Relation;\n}\n\nexport interface JunctionSpec {\n /** Bare table name (schema stripped). */\n table: string;\n /** Schema the junction is created in — mirrors the CREATE TABLE path. */\n schema: string;\n /** The two endpoints, in [source, target] order of the first declaring relation. */\n endpoints: [JunctionEndpoint, JunctionEndpoint];\n /** Every collection that declares a relation through this table. */\n declaringSides: JunctionDeclaringSide[];\n /**\n * The junction's own columns beyond the two keys — `through.properties`,\n * merged across every declaring side. `{}` when there are none.\n *\n * See {@link ManyToManyRelation.through} for what they are; every side that\n * names a key has to describe the same column, which is checked when the\n * specs are resolved rather than left for `CREATE TABLE` to discover.\n */\n properties: Properties;\n}\n\n// Mirrors auth-default-policies: the server context or an admin.\nconst SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(\n policy.serverContext(),\n policy.rolesOverlap([\"admin\"])\n);\n\n/**\n * Walk every collection's resolved relations and aggregate the junction tables\n * they declare. Two collections may declare the same junction from opposite\n * sides (posts→tags and tags→posts through `posts_tags`); both become\n * `declaringSides` of one spec, so derived write grants consider both.\n */\nexport function resolveJunctionSpecs(collections: CollectionConfig[]): Map<string, JunctionSpec> {\n const specs = new Map<string, JunctionSpec>();\n\n for (const collection of collections) {\n const resolved = resolveCollectionRelations(collection);\n for (const relation of Object.values(resolved)) {\n // Narrowed rather than probed: only a many-to-many has a junction,\n // and only after narrowing is `through` guaranteed complete.\n if (!isManyToMany(relation)) continue;\n\n const targetCollection: CollectionConfig | undefined = relation.target();\n if (!targetCollection) continue;\n\n const rawName = relation.through.table;\n // The CREATE TABLE path strips a schema prefix from the name but\n // still creates in \"public\"; the policies must target the same\n // table, so mirror that behaviour exactly.\n const table = rawName.includes(\".\") ? rawName.split(\".\").pop()! : rawName;\n const schema = \"public\";\n\n const source: JunctionDeclaringSide = {\n collection,\n junctionColumn: relation.through.sourceColumn,\n relation\n };\n const target: JunctionEndpoint = {\n collection: targetCollection,\n junctionColumn: relation.through.targetColumn\n };\n\n const existing = specs.get(table);\n if (!existing) {\n specs.set(table, {\n table,\n schema,\n endpoints: [source, target],\n declaringSides: [source],\n properties: mergeJunctionPayload({}, relation.through.properties, table, collection)\n });\n } else {\n // Merged whether or not this side is new: the same collection\n // can reach one junction under two relation names, and the\n // columns each of them asks for all have to exist.\n existing.properties = mergeJunctionPayload(\n existing.properties, relation.through.properties, table, collection);\n if (!existing.declaringSides.some(s => s.collection === collection)) {\n existing.declaringSides.push(source);\n }\n }\n }\n }\n\n return specs;\n}\n\n/**\n * Fold one side's `through.properties` into the junction's, refusing a\n * disagreement rather than picking a winner.\n *\n * Both ends of a link may declare it — `posts.tags` and `tags.posts` are one\n * junction — and each end may name the payload. Only one table gets created, so\n * two descriptions of `role` that are not the same description are a question\n * with no correct answer: whichever won, one of the two collections would be\n * writing through a column it does not think it has. Compared structurally, so\n * two sides that spell the same property twice (the normal case, and the one\n * the docs recommend) are fine.\n */\nfunction mergeJunctionPayload(\n into: Properties,\n incoming: Properties | undefined,\n table: string,\n collection: CollectionConfig\n): Properties {\n if (!incoming || Object.keys(incoming).length === 0) return into;\n const merged: Properties = { ...into };\n for (const [key, property] of Object.entries(incoming)) {\n const already = merged[key as keyof Properties] as Property | undefined;\n if (already && JSON.stringify(already) !== JSON.stringify(property)) {\n throw new Error(\n `The junction table \"${table}\" is declared from more than one side, and they disagree ` +\n `about the payload column \"${key}\": \"${collection.slug ?? collection.name}\" describes it ` +\n \"differently than another declaring collection does. One table is created, so both \" +\n \"`through.properties` blocks have to describe the same column — or only one side should \" +\n \"declare it.\"\n );\n }\n (merged as Record<string, Property>)[key] = property as Property;\n }\n return merged;\n}\n\n/**\n * A synthetic CollectionConfig standing in for the junction during policy\n * compilation and naming. Its two FK columns carry explicit `columnName`s so\n * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,\n * whatever their casing.\n *\n * The payload columns are here too, exactly as authored. That is what lets one\n * reading of a `Property` serve the junction as well as a collection: the\n * schema planner plans these columns with the same function it plans a\n * collection's with, and the write path validates a `_pivot` against them with\n * the same validator a row's values go through. A second description of a\n * payload column anywhere is a second description that can disagree.\n */\nexport function getJunctionCollectionConfig(spec: JunctionSpec): CollectionConfig {\n return buildJunctionCollectionConfig({\n table: spec.table,\n schema: spec.schema,\n keyColumns: spec.endpoints.map(endpoint => endpoint.junctionColumn),\n properties: spec.properties\n });\n}\n\n/**\n * The same synthetic collection, reached from a resolved relation rather than\n * from a spec.\n *\n * The spec is built by walking every collection, which the schema planner does\n * once at boot and no request path can afford. A read or a write already holds\n * the relation, and a relation's `through` carries the table, both key columns\n * and the payload — everything the config is made of. One builder underneath\n * both, so the shape the planner emitted columns from is the shape the write\n * path validates a `_pivot` against and the read path strips it with.\n */\nexport function getJunctionConfigForRelation(\n through: { table: string; sourceColumn: string; targetColumn: string; properties: Properties }\n): CollectionConfig {\n return buildJunctionCollectionConfig({\n // Junctions live in `public`; the CREATE TABLE path strips any schema\n // prefix from the name, so mirror that here rather than carrying it.\n table: through.table.includes(\".\") ? through.table.split(\".\").pop()! : through.table,\n schema: \"public\",\n keyColumns: [through.sourceColumn, through.targetColumn],\n properties: through.properties\n });\n}\n\nfunction buildJunctionCollectionConfig(args: {\n table: string;\n schema: string;\n keyColumns: string[];\n properties: Properties;\n}): CollectionConfig {\n const properties: Record<string, unknown> = {};\n for (const column of args.keyColumns) {\n properties[column] = { type: \"string\", columnName: column };\n }\n // After the keys, so a payload property that collides with a key column\n // cannot quietly replace it — `checkJunctionPayload` refuses that config at\n // boot, and this ordering means the key column survives if one gets past.\n for (const [key, property] of Object.entries(args.properties)) {\n if (key === JUNCTION_PIVOT_KEY || key in properties) continue;\n properties[key] = property;\n }\n return {\n slug: args.table,\n name: args.table,\n table: args.table,\n schema: args.schema,\n properties\n } as CollectionConfig;\n}\n\n/** The property marked as the row id (falls back to `id`). */\nfunction getIdPropertyName(collection: CollectionConfig): string {\n for (const [name, prop] of Object.entries(collection.properties ?? {})) {\n if (prop && typeof prop === \"object\" && \"isId\" in prop && (prop as { isId?: unknown }).isId) {\n return name;\n }\n }\n return \"id\";\n}\n\n/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */\nfunction existsEndpoint(endpoint: JunctionEndpoint, extra?: PolicyExpression): PolicyExpression {\n const correlation = policy.compare(\n policy.field(getIdPropertyName(endpoint.collection)),\n \"eq\",\n policy.outerField(endpoint.junctionColumn)\n );\n return policy.existsIn({\n collection: endpoint.collection.slug,\n where: extra ? policy.and(correlation, extra) : correlation\n });\n}\n\n/**\n * Whether a parent-rule expression keeps its meaning when moved inside the\n * junction's `EXISTS` subquery — and the re-scoped copy if it does.\n *\n * Returns `null` when the rule cannot be embedded faithfully: `raw` SQL\n * anywhere (its `{column}` placeholders would bind to the junction), or an\n * `outerField` inside a nested `existsIn` (it would bind to the junction while\n * the author meant the parent, and no operand can express \"the middle scope\").\n * Top-level `outerField`s are rewritten to `field`, which is what they meant.\n */\nexport function embedParentExpression(expr: PolicyExpression, depth = 0): PolicyExpression | null {\n switch (expr.kind) {\n case \"raw\":\n return null;\n case \"and\":\n case \"or\": {\n const parts: PolicyExpression[] = [];\n for (const child of expr.operands) {\n const embedded = embedParentExpression(child, depth);\n if (!embedded) return null;\n parts.push(embedded);\n }\n return expr.kind === \"and\" ? policy.and(...parts) : policy.or(...parts);\n }\n case \"not\": {\n const embedded = embedParentExpression(expr.operand, depth);\n return embedded ? policy.not(embedded) : null;\n }\n case \"existsIn\": {\n const where = embedParentExpression(expr.where, depth + 1);\n return where ? policy.existsIn({ collection: expr.collection, where }) : null;\n }\n case \"compare\": {\n const left = embedOperand(expr.left, depth);\n const right = embedOperand(expr.right, depth);\n if (!left || !right) return null;\n return { ...expr, left, right };\n }\n default:\n // Leaf expressions with no field references (true, false,\n // serverContext, authenticated, rolesOverlap, rolesContain) are\n // position-independent.\n return expr;\n }\n}\n\n/** Re-scope an operand, or return `null` if its binding cannot be preserved. */\nfunction embedOperand(operand: PolicyOperand, depth: number): PolicyOperand | null {\n if (operand.kind === \"outerField\") {\n // Outside a subquery, outerField ≡ field: the author meant their own\n // row, which after embedding is the EXISTS's joined table → field.\n if (depth === 0) return policy.field(operand.name);\n // Inside the author's own existsIn it meant the parent row; after\n // embedding it would bind to the junction. Not expressible.\n return null;\n }\n return operand;\n}\n\n/** Does the rule cover the `update` operation? */\nfunction coversUpdate(rule: SecurityRule): boolean {\n return getPolicyOperations(rule).some(op => op === \"update\" || op === \"all\");\n}\n\n/**\n * The full derived policy set for a junction table: the locked server/admin\n * baseline, the endpoint-visibility read grant, inherited write grants, and\n * inherited restrictive gates. Returns `[]` when every declaring collection set\n * `disableDefaultPolicies` — the junction is then the author's to police, and\n * stays locked (RLS is still enabled) until they write policies for it.\n */\nexport function getJunctionSecurityRules(spec: JunctionSpec): SecurityRule[] {\n if (spec.declaringSides.every(side => isPostgresCollectionConfig(side.collection) && side.collection.disableDefaultPolicies)) {\n return [];\n }\n\n const rules: SecurityRule[] = [];\n\n // 1. Locked baseline — same shape and naming as every collection's.\n rules.push({\n name: `${spec.table}_default_admin_read`,\n operations: [\"select\"],\n condition: SERVER_OR_ADMIN_EXPR\n });\n rules.push({\n name: `${spec.table}_default_admin_write`,\n operations: [\"insert\", \"update\", \"delete\"],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n });\n\n // 2. Reads follow the endpoints: the edge is visible iff both rows are.\n // The EXISTS subqueries run under the caller's role, so each endpoint's\n // own RLS applies inside them — visibility is delegated, not copied.\n rules.push({\n name: `${spec.table}_default_edge_read`,\n operations: [\"select\"],\n condition: policy.and(\n existsEndpoint(spec.endpoints[0]),\n existsEndpoint(spec.endpoints[1])\n )\n });\n\n // 3. Writes follow the owning side's explicit update rules.\n const writeGrants: PolicyExpression[] = [];\n for (const side of spec.declaringSides) {\n const explicitRules = (isPostgresCollectionConfig(side.collection)\n ? side.collection.securityRules\n : undefined) ?? [];\n const updateRules = explicitRules.filter(coversUpdate);\n\n const permissive = updateRules.filter(r => r.mode !== \"restrictive\");\n const restrictive = updateRules.filter(r => r.mode === \"restrictive\");\n\n // Embed the restrictive gates first: if any of them cannot be carried\n // over, granting writes from this side would be looser than the parent\n // itself allows — so the whole side's grant is suppressed.\n const embeddedGates: PolicyExpression[] = [];\n let gatesEmbeddable = true;\n for (const gate of restrictive) {\n const using = securityRuleToConditions(gate).usingExpr;\n const embedded = using ? embedParentExpression(using) : null;\n if (!embedded) {\n gatesEmbeddable = false;\n break;\n }\n embeddedGates.push(embedded);\n }\n if (!gatesEmbeddable) continue;\n\n const grants: PolicyExpression[] = [];\n for (const rule of permissive) {\n const using = securityRuleToConditions(rule).usingExpr;\n const embedded = using ? embedParentExpression(using) : null;\n if (embedded) grants.push(embedded);\n }\n if (grants.length === 0) continue;\n\n // \"May update the owning row\": any permissive grant, AND every gate.\n const condition = embeddedGates.length > 0\n ? policy.and(policy.or(...grants), ...embeddedGates)\n : policy.or(...grants);\n\n writeGrants.push(existsEndpoint(side, condition));\n }\n\n if (writeGrants.length > 0) {\n rules.push({\n name: `${spec.table}_default_edge_write`,\n operations: [\"insert\", \"update\", \"delete\"],\n condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),\n check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)\n });\n }\n\n return rules;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"junction-policies.es.js","names":[],"sources":["../../src/util/junction-policies.ts"],"sourcesContent":["import {\n CollectionConfig,\n JUNCTION_PIVOT_KEY,\n PolicyExpression,\n PolicyOperand,\n Properties,\n Property,\n Relation,\n SecurityRule,\n isPostgresCollectionConfig,\n policy\n} from \"@rebasepro/types\";\nimport { getPolicyOperations } from \"@rebasepro/utils\";\nimport { getTableName } from \"./relations\";\nimport { resolveCollectionRelations } from \"./relations\";\nimport { isManyToMany } from \"@rebasepro/types\";\nimport { securityRuleToConditions } from \"./policy/securityRuleToConditions\";\nimport { defaultPoliciesDisabled } from \"./auth-default-policies\";\n\n/**\n * RLS derivation for many-to-many junction tables.\n *\n * A `through` relation makes the generator create a table nobody declared as a\n * collection — `posts_tags`, `user_roles`. Those tables used to be the one kind\n * of generated table with **no** RLS at all: `rebase_user` holds full DML grants,\n * so with the endpoints locked down, any signed-up user could still read or wipe\n * every edge between them. There is also nowhere in the config to write rules\n * for a junction, so the author could not even fix it by hand.\n *\n * The architecture here is that a junction's security is *derived*, never\n * hand-written:\n *\n * 1. **Locked baseline.** The same server-or-admin `default_admin` grants every\n * collection gets, so the invariant holds again: every table the generator\n * creates is default-deny, and rules only broaden.\n *\n * 2. **Reads follow the endpoints.** An edge is visible iff *both* endpoint\n * rows are visible — two correlated `EXISTS` subqueries. The subqueries run\n * under the caller's role, so each endpoint's own RLS filters them: junction\n * visibility delegates to the endpoints' policies, whatever they become,\n * with nothing duplicated. A public blog keeps rendering its tags; a private\n * CRM's edges are exactly as hidden as its rows.\n *\n * 3. **Writes follow the owning side's update rules.** Linking or unlinking an\n * edge *is* an edit of the owning row — tagging a post is editing the post —\n * so edge writes inherit the declaring collection's explicit permissive\n * `update` rules, each wrapped in an `EXISTS` against the owning row. Where\n * a rule cannot be embedded faithfully (see below) it is dropped, so the\n * failure mode is always *too locked*, never open. Explicit **restrictive**\n * update rules are inherited as restrictive junction rules; if one of them\n * cannot be embedded, the whole derived write grant for that side is\n * suppressed — granting without the author's gate would be looser than the\n * parent itself.\n *\n * **Embeddability.** A parent rule is embedded by moving its condition inside\n * `EXISTS (SELECT 1 FROM parent WHERE parent.pk = junction.fk AND <condition>)`.\n * In that scope, `field` operands bind to the parent — which is what the author\n * meant. But `outerField` operands and `{column}` placeholders in `raw` SQL bind\n * to the RLS row, which is now the junction, not the parent the author wrote\n * them against. So: `raw` anywhere disqualifies a rule; a top-level `outerField`\n * (equivalent to `field` outside a subquery) is rewritten to `field`; an\n * `outerField` inside a nested `existsIn` cannot be re-scoped and disqualifies\n * the rule.\n *\n * Injected parent defaults are never inherited — the junction's own baseline\n * already covers the server/admin plane, and an auth collection's restrictive\n * `require_admin_write` gate exists to protect privileged parent *columns*,\n * which an edge write cannot touch. Inheriting it would stop users managing\n * e.g. their own interests through a `users_interests` junction for no gain.\n *\n * Everything flows through the shared naming machinery, so the Studio\n * recognises these policies as generated instead of offering to \"import\" them.\n */\n\n/** One side of a junction: the collection and the FK column pointing at it. */\nexport interface JunctionEndpoint {\n collection: CollectionConfig;\n /** Junction column holding this endpoint's key. */\n junctionColumn: string;\n}\n\n/** A collection that declares the `through` relation (owns the edge semantics). */\nexport interface JunctionDeclaringSide extends JunctionEndpoint {\n relation: Relation;\n}\n\nexport interface JunctionSpec {\n /** Bare table name (schema stripped). */\n table: string;\n /** Schema the junction is created in — mirrors the CREATE TABLE path. */\n schema: string;\n /** The two endpoints, in [source, target] order of the first declaring relation. */\n endpoints: [JunctionEndpoint, JunctionEndpoint];\n /** Every collection that declares a relation through this table. */\n declaringSides: JunctionDeclaringSide[];\n /**\n * The junction's own columns beyond the two keys — `through.properties`,\n * merged across every declaring side. `{}` when there are none.\n *\n * See {@link ManyToManyRelation.through} for what they are; every side that\n * names a key has to describe the same column, which is checked when the\n * specs are resolved rather than left for `CREATE TABLE` to discover.\n */\n properties: Properties;\n}\n\n// Mirrors auth-default-policies: the server context or an admin.\nconst SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(\n policy.serverContext(),\n policy.rolesOverlap([\"admin\"])\n);\n\n/**\n * Walk every collection's resolved relations and aggregate the junction tables\n * they declare. Two collections may declare the same junction from opposite\n * sides (posts→tags and tags→posts through `posts_tags`); both become\n * `declaringSides` of one spec, so derived write grants consider both.\n */\nexport function resolveJunctionSpecs(collections: CollectionConfig[]): Map<string, JunctionSpec> {\n const specs = new Map<string, JunctionSpec>();\n\n for (const collection of collections) {\n const resolved = resolveCollectionRelations(collection);\n for (const relation of Object.values(resolved)) {\n // Narrowed rather than probed: only a many-to-many has a junction,\n // and only after narrowing is `through` guaranteed complete.\n if (!isManyToMany(relation)) continue;\n\n const targetCollection: CollectionConfig | undefined = relation.target();\n if (!targetCollection) continue;\n\n const rawName = relation.through.table;\n // The CREATE TABLE path strips a schema prefix from the name but\n // still creates in \"public\"; the policies must target the same\n // table, so mirror that behaviour exactly.\n const table = rawName.includes(\".\") ? rawName.split(\".\").pop()! : rawName;\n const schema = \"public\";\n\n const source: JunctionDeclaringSide = {\n collection,\n junctionColumn: relation.through.sourceColumn,\n relation\n };\n const target: JunctionEndpoint = {\n collection: targetCollection,\n junctionColumn: relation.through.targetColumn\n };\n\n const existing = specs.get(table);\n if (!existing) {\n specs.set(table, {\n table,\n schema,\n endpoints: [source, target],\n declaringSides: [source],\n properties: mergeJunctionPayload({}, relation.through.properties, table, collection)\n });\n } else {\n // Merged whether or not this side is new: the same collection\n // can reach one junction under two relation names, and the\n // columns each of them asks for all have to exist.\n existing.properties = mergeJunctionPayload(\n existing.properties, relation.through.properties, table, collection);\n if (!existing.declaringSides.some(s => s.collection === collection)) {\n existing.declaringSides.push(source);\n }\n }\n }\n }\n\n return specs;\n}\n\n/**\n * Fold one side's `through.properties` into the junction's, refusing a\n * disagreement rather than picking a winner.\n *\n * Both ends of a link may declare it — `posts.tags` and `tags.posts` are one\n * junction — and each end may name the payload. Only one table gets created, so\n * two descriptions of `role` that are not the same description are a question\n * with no correct answer: whichever won, one of the two collections would be\n * writing through a column it does not think it has. Compared structurally, so\n * two sides that spell the same property twice (the normal case, and the one\n * the docs recommend) are fine.\n */\nfunction mergeJunctionPayload(\n into: Properties,\n incoming: Properties | undefined,\n table: string,\n collection: CollectionConfig\n): Properties {\n if (!incoming || Object.keys(incoming).length === 0) return into;\n const merged: Properties = { ...into };\n for (const [key, property] of Object.entries(incoming)) {\n const already = merged[key as keyof Properties] as Property | undefined;\n if (already && JSON.stringify(already) !== JSON.stringify(property)) {\n throw new Error(\n `The junction table \"${table}\" is declared from more than one side, and they disagree ` +\n `about the payload column \"${key}\": \"${collection.slug ?? collection.name}\" describes it ` +\n \"differently than another declaring collection does. One table is created, so both \" +\n \"`through.properties` blocks have to describe the same column — or only one side should \" +\n \"declare it.\"\n );\n }\n (merged as Record<string, Property>)[key] = property as Property;\n }\n return merged;\n}\n\n/**\n * A synthetic CollectionConfig standing in for the junction during policy\n * compilation and naming. Its two FK columns carry explicit `columnName`s so\n * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,\n * whatever their casing.\n *\n * The payload columns are here too, exactly as authored. That is what lets one\n * reading of a `Property` serve the junction as well as a collection: the\n * schema planner plans these columns with the same function it plans a\n * collection's with, and the write path validates a `_pivot` against them with\n * the same validator a row's values go through. A second description of a\n * payload column anywhere is a second description that can disagree.\n */\nexport function getJunctionCollectionConfig(spec: JunctionSpec): CollectionConfig {\n return buildJunctionCollectionConfig({\n table: spec.table,\n schema: spec.schema,\n keyColumns: spec.endpoints.map(endpoint => endpoint.junctionColumn),\n properties: spec.properties\n });\n}\n\n/**\n * The same synthetic collection, reached from a resolved relation rather than\n * from a spec.\n *\n * The spec is built by walking every collection, which the schema planner does\n * once at boot and no request path can afford. A read or a write already holds\n * the relation, and a relation's `through` carries the table, both key columns\n * and the payload — everything the config is made of. One builder underneath\n * both, so the shape the planner emitted columns from is the shape the write\n * path validates a `_pivot` against and the read path strips it with.\n */\nexport function getJunctionConfigForRelation(\n through: { table: string; sourceColumn: string; targetColumn: string; properties: Properties }\n): CollectionConfig {\n return buildJunctionCollectionConfig({\n // Junctions live in `public`; the CREATE TABLE path strips any schema\n // prefix from the name, so mirror that here rather than carrying it.\n table: through.table.includes(\".\") ? through.table.split(\".\").pop()! : through.table,\n schema: \"public\",\n keyColumns: [through.sourceColumn, through.targetColumn],\n properties: through.properties\n });\n}\n\nfunction buildJunctionCollectionConfig(args: {\n table: string;\n schema: string;\n keyColumns: string[];\n properties: Properties;\n}): CollectionConfig {\n const properties: Record<string, unknown> = {};\n for (const column of args.keyColumns) {\n properties[column] = { type: \"string\", columnName: column };\n }\n // After the keys, so a payload property that collides with a key column\n // cannot quietly replace it — `checkJunctionPayload` refuses that config at\n // boot, and this ordering means the key column survives if one gets past.\n for (const [key, property] of Object.entries(args.properties)) {\n if (key === JUNCTION_PIVOT_KEY || key in properties) continue;\n properties[key] = property;\n }\n return {\n slug: args.table,\n name: args.table,\n table: args.table,\n schema: args.schema,\n properties\n } as CollectionConfig;\n}\n\n/** The property marked as the row id (falls back to `id`). */\nfunction getIdPropertyName(collection: CollectionConfig): string {\n for (const [name, prop] of Object.entries(collection.properties ?? {})) {\n if (prop && typeof prop === \"object\" && \"isId\" in prop && (prop as { isId?: unknown }).isId) {\n return name;\n }\n }\n return \"id\";\n}\n\n/** `EXISTS (SELECT 1 FROM endpoint WHERE endpoint.pk = junction.fk [AND extra])`. */\nfunction existsEndpoint(endpoint: JunctionEndpoint, extra?: PolicyExpression): PolicyExpression {\n const correlation = policy.compare(\n policy.field(getIdPropertyName(endpoint.collection)),\n \"eq\",\n policy.outerField(endpoint.junctionColumn)\n );\n return policy.existsIn({\n collection: endpoint.collection.slug,\n where: extra ? policy.and(correlation, extra) : correlation\n });\n}\n\n/**\n * Whether a parent-rule expression keeps its meaning when moved inside the\n * junction's `EXISTS` subquery — and the re-scoped copy if it does.\n *\n * Returns `null` when the rule cannot be embedded faithfully: `raw` SQL\n * anywhere (its `{column}` placeholders would bind to the junction), or an\n * `outerField` inside a nested `existsIn` (it would bind to the junction while\n * the author meant the parent, and no operand can express \"the middle scope\").\n * Top-level `outerField`s are rewritten to `field`, which is what they meant.\n */\nexport function embedParentExpression(expr: PolicyExpression, depth = 0): PolicyExpression | null {\n switch (expr.kind) {\n case \"raw\":\n return null;\n case \"and\":\n case \"or\": {\n const parts: PolicyExpression[] = [];\n for (const child of expr.operands) {\n const embedded = embedParentExpression(child, depth);\n if (!embedded) return null;\n parts.push(embedded);\n }\n return expr.kind === \"and\" ? policy.and(...parts) : policy.or(...parts);\n }\n case \"not\": {\n const embedded = embedParentExpression(expr.operand, depth);\n return embedded ? policy.not(embedded) : null;\n }\n case \"existsIn\": {\n const where = embedParentExpression(expr.where, depth + 1);\n return where ? policy.existsIn({ collection: expr.collection, where }) : null;\n }\n case \"compare\": {\n const left = embedOperand(expr.left, depth);\n const right = embedOperand(expr.right, depth);\n if (!left || !right) return null;\n return { ...expr, left, right };\n }\n default:\n // Leaf expressions with no field references (true, false,\n // serverContext, authenticated, rolesOverlap, rolesContain) are\n // position-independent.\n return expr;\n }\n}\n\n/** Re-scope an operand, or return `null` if its binding cannot be preserved. */\nfunction embedOperand(operand: PolicyOperand, depth: number): PolicyOperand | null {\n if (operand.kind === \"outerField\") {\n // Outside a subquery, outerField ≡ field: the author meant their own\n // row, which after embedding is the EXISTS's joined table → field.\n if (depth === 0) return policy.field(operand.name);\n // Inside the author's own existsIn it meant the parent row; after\n // embedding it would bind to the junction. Not expressible.\n return null;\n }\n return operand;\n}\n\n/** Does the rule cover the `update` operation? */\nfunction coversUpdate(rule: SecurityRule): boolean {\n return getPolicyOperations(rule).some(op => op === \"update\" || op === \"all\");\n}\n\n/**\n * The full derived policy set for a junction table: the locked server/admin\n * baseline, the endpoint-visibility read grant, inherited write grants, and\n * inherited restrictive gates. Returns `[]` when every declaring collection set\n * `disableDefaultPolicies` — the junction is then the author's to police, and\n * stays locked (RLS is still enabled) until they write policies for it.\n */\nexport function getJunctionSecurityRules(spec: JunctionSpec): SecurityRule[] {\n if (spec.declaringSides.every(side => defaultPoliciesDisabled(side.collection))) {\n return [];\n }\n\n const rules: SecurityRule[] = [];\n\n // 1. Locked baseline — same shape and naming as every collection's.\n rules.push({\n name: `${spec.table}_default_admin_read`,\n operations: [\"select\"],\n condition: SERVER_OR_ADMIN_EXPR\n });\n rules.push({\n name: `${spec.table}_default_admin_write`,\n operations: [\"insert\", \"update\", \"delete\"],\n condition: SERVER_OR_ADMIN_EXPR,\n check: SERVER_OR_ADMIN_EXPR\n });\n\n // 2. Reads follow the endpoints: the edge is visible iff both rows are.\n // The EXISTS subqueries run under the caller's role, so each endpoint's\n // own RLS applies inside them — visibility is delegated, not copied.\n rules.push({\n name: `${spec.table}_default_edge_read`,\n operations: [\"select\"],\n condition: policy.and(\n existsEndpoint(spec.endpoints[0]),\n existsEndpoint(spec.endpoints[1])\n )\n });\n\n // 3. Writes follow the owning side's explicit update rules.\n const writeGrants: PolicyExpression[] = [];\n for (const side of spec.declaringSides) {\n const explicitRules = (isPostgresCollectionConfig(side.collection)\n ? side.collection.securityRules\n : undefined) ?? [];\n const updateRules = explicitRules.filter(coversUpdate);\n\n const permissive = updateRules.filter(r => r.mode !== \"restrictive\");\n const restrictive = updateRules.filter(r => r.mode === \"restrictive\");\n\n // Embed the restrictive gates first: if any of them cannot be carried\n // over, granting writes from this side would be looser than the parent\n // itself allows — so the whole side's grant is suppressed.\n const embeddedGates: PolicyExpression[] = [];\n let gatesEmbeddable = true;\n for (const gate of restrictive) {\n const using = securityRuleToConditions(gate).usingExpr;\n const embedded = using ? embedParentExpression(using) : null;\n if (!embedded) {\n gatesEmbeddable = false;\n break;\n }\n embeddedGates.push(embedded);\n }\n if (!gatesEmbeddable) continue;\n\n const grants: PolicyExpression[] = [];\n for (const rule of permissive) {\n const using = securityRuleToConditions(rule).usingExpr;\n const embedded = using ? embedParentExpression(using) : null;\n if (embedded) grants.push(embedded);\n }\n if (grants.length === 0) continue;\n\n // \"May update the owning row\": any permissive grant, AND every gate.\n const condition = embeddedGates.length > 0\n ? policy.and(policy.or(...grants), ...embeddedGates)\n : policy.or(...grants);\n\n writeGrants.push(existsEndpoint(side, condition));\n }\n\n if (writeGrants.length > 0) {\n rules.push({\n name: `${spec.table}_default_edge_write`,\n operations: [\"insert\", \"update\", \"delete\"],\n condition: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants),\n check: writeGrants.length === 1 ? writeGrants[0] : policy.or(...writeGrants)\n });\n }\n\n return rules;\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CollectionConfig, type SecurityOperation, type SecurityRule } from "@rebasepro/types";
|
|
2
2
|
import { type PolicyCompileOptions } from "./policyToPostgres.js";
|
|
3
3
|
/**
|
|
4
4
|
* One Postgres policy a security rule compiles to: its name and the clauses
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getTableName } from "../relations.es.js";
|
|
2
2
|
import { securityRuleToConditions } from "./securityRuleToConditions.es.js";
|
|
3
3
|
import { policyToPostgres } from "./policyToPostgres.es.js";
|
|
4
|
+
import { DEFAULT_DATA_SOURCE_KEY, RLS_IS_REQUEST_ROLE_SQL, declaredRlsPolicyShape } from "@rebasepro/types";
|
|
4
5
|
import { getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
6
|
//#region src/util/policy/compileRulePolicies.ts
|
|
6
7
|
/**
|
|
@@ -20,6 +21,9 @@ function compileRulePolicies(collection, rule, options) {
|
|
|
20
21
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
21
22
|
const policyNames = getPolicyNamesForRule(rule, getTableName(collection));
|
|
22
23
|
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
24
|
+
const mode = rule.mode ?? "permissive";
|
|
25
|
+
const narrowed = mode === "permissive" && declaredRlsPolicyShape(collection.dataSource ?? DEFAULT_DATA_SOURCE_KEY).policyTarget === "requestRole";
|
|
26
|
+
const forRequestRoles = (clause) => narrowed ? `(${clause}) AND ${RLS_IS_REQUEST_ROLE_SQL}` : clause;
|
|
23
27
|
return ops.map((operation, index) => {
|
|
24
28
|
const needsUsing = operation !== "insert";
|
|
25
29
|
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
@@ -30,10 +34,10 @@ function compileRulePolicies(collection, rule, options) {
|
|
|
30
34
|
return {
|
|
31
35
|
name: policyNames[index],
|
|
32
36
|
operation,
|
|
33
|
-
mode
|
|
37
|
+
mode,
|
|
34
38
|
roles: rule.pgRoles ? [...rule.pgRoles].sort() : ["public"],
|
|
35
|
-
using,
|
|
36
|
-
withCheck
|
|
39
|
+
using: using === null ? null : forRequestRoles(using),
|
|
40
|
+
withCheck: withCheck === null ? null : forRequestRoles(withCheck)
|
|
37
41
|
};
|
|
38
42
|
});
|
|
39
43
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compileRulePolicies.es.js","names":[],"sources":["../../../src/util/policy/compileRulePolicies.ts"],"sourcesContent":["import type
|
|
1
|
+
{"version":3,"file":"compileRulePolicies.es.js","names":[],"sources":["../../../src/util/policy/compileRulePolicies.ts"],"sourcesContent":["import { declaredRlsPolicyShape, DEFAULT_DATA_SOURCE_KEY, RLS_IS_REQUEST_ROLE_SQL, type CollectionConfig, type SecurityOperation, type SecurityRule } from \"@rebasepro/types\";\nimport { getPolicyNamesForRule } from \"@rebasepro/utils\";\nimport { getTableName } from \"../relations\";\nimport { securityRuleToConditions } from \"./securityRuleToConditions\";\nimport { policyToPostgres, type PolicyCompileOptions } from \"./policyToPostgres\";\n\n/**\n * One Postgres policy a security rule compiles to: its name and the clauses\n * `CREATE POLICY` is given for it.\n */\nexport interface CompiledRulePolicy {\n name: string;\n operation: SecurityOperation;\n /** `permissive` | `restrictive`, lower-case as the rule spells it. */\n mode: \"permissive\" | \"restrictive\";\n /** The `TO` list. Sorted, `[\"public\"]` when the rule names none. */\n roles: string[];\n /** The `USING` clause, or `null` when the operation takes none. */\n using: string | null;\n /** The `WITH CHECK` clause, or `null` when the operation takes none. */\n withCheck: string | null;\n}\n\n/**\n * The policies one security rule compiles to — one per operation.\n *\n * The schema planner writes these into the database, and the Studio's RLS\n * editor compares the database against them; both call this, so \"what the code\n * declares\" has one meaning.\n *\n * The desugaring (`access` / `ownerField` / `roles` / structured condition /\n * raw SQL → `PolicyExpression`) is {@link securityRuleToConditions}, and the SQL\n * is {@link policyToPostgres}. What lives here is only the shape: which\n * operations a rule expands to, which clauses each operation takes, and the\n * deny-all fallback for a clause that compiled to nothing.\n */\nexport function compileRulePolicies(\n collection: CollectionConfig,\n rule: SecurityRule,\n options?: PolicyCompileOptions\n): CompiledRulePolicy[] {\n const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? \"all\"];\n const policyNames = getPolicyNamesForRule(rule, getTableName(collection));\n const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);\n const mode = rule.mode ?? \"permissive\";\n // `rls.policyTarget: \"requestRole\"`: a grant is for the request roles only.\n // In the compiler, not on the rule, because a policy's name hashes the rule\n // — narrowing it there would rename every policy in every database. Never on\n // a restrictive policy: it can only take access away, from everyone.\n const narrowed = mode === \"permissive\"\n && declaredRlsPolicyShape(collection.dataSource ?? DEFAULT_DATA_SOURCE_KEY).policyTarget === \"requestRole\";\n const forRequestRoles = (clause: string): string => narrowed ? `(${clause}) AND ${RLS_IS_REQUEST_ROLE_SQL}` : clause;\n\n return ops.map((operation, index) => {\n const needsUsing = operation !== \"insert\";\n const needsWithCheck = operation !== \"select\" && operation !== \"delete\";\n let using = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, options) : null;\n let withCheck = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, options) : null;\n // A clause that compiled to nothing denies rather than opens.\n if (!using && needsUsing) using = \"false\";\n if (!withCheck && needsWithCheck) withCheck = \"false\";\n return {\n name: policyNames[index],\n operation,\n mode,\n roles: rule.pgRoles ? [...rule.pgRoles].sort() : [\"public\"],\n using: using === null ? null : forRequestRoles(using),\n withCheck: withCheck === null ? null : forRequestRoles(withCheck)\n };\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAoCA,SAAgB,oBACZ,YACA,MACA,SACoB;CACpB,MAAM,MAAM,KAAK,cAAc,KAAK,WAAW,SAAS,IAAI,KAAK,aAAa,CAAC,KAAK,aAAa,KAAK;CACtG,MAAM,cAAc,sBAAsB,MAAM,aAAa,UAAU,CAAC;CACxE,MAAM,EAAE,WAAW,kBAAkB,yBAAyB,IAAI;CAClE,MAAM,OAAO,KAAK,QAAQ;CAK1B,MAAM,WAAW,SAAS,gBACnB,uBAAuB,WAAW,cAAc,uBAAuB,CAAC,CAAC,iBAAiB;CACjG,MAAM,mBAAmB,WAA2B,WAAW,IAAI,OAAO,QAAQ,4BAA4B;CAE9G,OAAO,IAAI,KAAK,WAAW,UAAU;EACjC,MAAM,aAAa,cAAc;EACjC,MAAM,iBAAiB,cAAc,YAAY,cAAc;EAC/D,IAAI,QAAQ,cAAc,YAAY,iBAAiB,WAAW,YAAY,OAAO,IAAI;EACzF,IAAI,YAAY,kBAAkB,gBAAgB,iBAAiB,eAAe,YAAY,OAAO,IAAI;EAEzG,IAAI,CAAC,SAAS,YAAY,QAAQ;EAClC,IAAI,CAAC,aAAa,gBAAgB,YAAY;EAC9C,OAAO;GACH,MAAM,YAAY;GAClB;GACA;GACA,OAAO,KAAK,UAAU,CAAC,GAAG,KAAK,OAAO,CAAC,CAAC,KAAK,IAAI,CAAC,QAAQ;GAC1D,OAAO,UAAU,OAAO,OAAO,gBAAgB,KAAK;GACpD,WAAW,cAAc,OAAO,OAAO,gBAAgB,SAAS;EACpE;CACJ,CAAC;AACL"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rebase",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"fast-equals": "6.0.2",
|
|
48
48
|
"json-logic-js": "^2.0.5",
|
|
49
|
-
"@rebasepro/types": "0.29.
|
|
50
|
-
"@rebasepro/utils": "0.29.
|
|
49
|
+
"@rebasepro/types": "0.29.1",
|
|
50
|
+
"@rebasepro/utils": "0.29.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@jest/globals": "^30.4.1",
|