@objectstack/plugin-security 9.9.0 → 9.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1041,6 +1041,14 @@ var RLSCompiler = class {
1041
1041
  roles: executionContext?.roles,
1042
1042
  org_user_ids: executionContext?.org_user_ids
1043
1043
  };
1044
+ const membership = executionContext?.rlsMembership;
1045
+ if (membership && typeof membership === "object") {
1046
+ for (const [key, value] of Object.entries(membership)) {
1047
+ if (Array.isArray(value) && userCtx[key] === void 0) {
1048
+ userCtx[key] = value;
1049
+ }
1050
+ }
1051
+ }
1044
1052
  const filters = [];
1045
1053
  for (const policy of policies) {
1046
1054
  if (!policy.using) continue;
@@ -1057,14 +1065,24 @@ var RLSCompiler = class {
1057
1065
  }
1058
1066
  /**
1059
1067
  * Compile a single RLS expression into a query filter.
1060
- *
1061
- * Supports simple expressions like:
1062
- * - "field_name = current_user.property"
1063
- * - "field_name IN (current_user.array_property)"
1064
- * - "field_name = 'literal_value'"
1068
+ *
1069
+ * This reference compiler recognizes exactly four forms — anything else
1070
+ * returns `null` and (via {@link compileFilter}) fails closed:
1071
+ * - `field = current_user.property` → `{ field: <value> }`
1072
+ * - `field = 'literal_value'` → `{ field: 'literal_value' }`
1073
+ * - `field IN (current_user.array)` → `{ field: { $in: [...] } }`
1074
+ * (the array may be a §7.3.1 pre-resolved membership set)
1075
+ * - `1 = 1` → `{}` (always-true / no restriction)
1076
+ *
1077
+ * There is intentionally no support for subqueries, `LIKE`/`ILIKE`,
1078
+ * regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those
1079
+ * needs as a `current_user.*` property the runtime pre-resolves instead.
1065
1080
  */
1066
1081
  compileExpression(expression, userCtx) {
1067
1082
  if (!expression) return null;
1083
+ if (/^\s*1\s*=\s*1\s*$/.test(expression)) {
1084
+ return {};
1085
+ }
1068
1086
  const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
1069
1087
  if (eqMatch) {
1070
1088
  const [, field, prop] = eqMatch;
@@ -1212,11 +1230,91 @@ function isPermissionDeniedError(e) {
1212
1230
  }
1213
1231
 
1214
1232
  // src/bootstrap-platform-admin.ts
1233
+ import { SystemUserId as SystemUserId2 } from "@objectstack/spec/system";
1234
+
1235
+ // src/claim-seed-ownership.ts
1215
1236
  import { SystemUserId } from "@objectstack/spec/system";
1216
1237
  var SYSTEM_CTX = { isSystem: true };
1238
+ function hasOwnerField(schema) {
1239
+ const fields = schema?.fields;
1240
+ if (!fields) return false;
1241
+ if (Array.isArray(fields)) {
1242
+ return fields.some((f) => f?.name === "owner_id");
1243
+ }
1244
+ return Object.prototype.hasOwnProperty.call(fields, "owner_id");
1245
+ }
1246
+ async function claimSeedOwnership(ql, adminUserId, options = {}) {
1247
+ const logger = options.logger;
1248
+ if (!adminUserId || adminUserId === SystemUserId.SYSTEM) return [];
1249
+ if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
1250
+ return [];
1251
+ }
1252
+ const registry = ql.registry;
1253
+ if (!registry || typeof registry.getAllObjects !== "function") {
1254
+ logger?.warn?.("[security] claimSeedOwnership: registry unavailable");
1255
+ return [];
1256
+ }
1257
+ const schemas = registry.getAllObjects();
1258
+ const results = [];
1259
+ for (const schema of schemas) {
1260
+ if (!schema?.name) continue;
1261
+ if (schema.managedBy) continue;
1262
+ if (schema.name.startsWith("sys_")) continue;
1263
+ if (!hasOwnerField(schema)) continue;
1264
+ try {
1265
+ const seen = /* @__PURE__ */ new Set();
1266
+ const ids = [];
1267
+ for (const where of [{ owner_id: null }, { owner_id: SystemUserId.SYSTEM }]) {
1268
+ const rows = await ql.find(
1269
+ schema.name,
1270
+ { where, limit: 1e4, fields: ["id"] },
1271
+ { context: SYSTEM_CTX }
1272
+ );
1273
+ const list = Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
1274
+ for (const r of list) {
1275
+ if (r?.id && !seen.has(r.id)) {
1276
+ seen.add(r.id);
1277
+ ids.push(r.id);
1278
+ }
1279
+ }
1280
+ }
1281
+ if (ids.length === 0) continue;
1282
+ let updated = 0;
1283
+ for (const id of ids) {
1284
+ try {
1285
+ await ql.update(
1286
+ schema.name,
1287
+ { id, owner_id: adminUserId },
1288
+ { context: SYSTEM_CTX }
1289
+ );
1290
+ updated += 1;
1291
+ } catch (e) {
1292
+ logger?.warn?.(`[security] claimSeedOwnership failed for ${schema.name}:${id}`, {
1293
+ error: e.message
1294
+ });
1295
+ }
1296
+ }
1297
+ if (updated > 0) results.push({ object: schema.name, count: updated });
1298
+ } catch (e) {
1299
+ logger?.warn?.(`[security] claimSeedOwnership scan failed for ${schema.name}`, {
1300
+ error: e.message
1301
+ });
1302
+ }
1303
+ }
1304
+ if (results.length > 0) {
1305
+ const total = results.reduce((s, r) => s + r.count, 0);
1306
+ logger?.info?.(`[security] handed ${total} seeded record(s) to first admin ${adminUserId}`, {
1307
+ breakdown: results
1308
+ });
1309
+ }
1310
+ return results;
1311
+ }
1312
+
1313
+ // src/bootstrap-platform-admin.ts
1314
+ var SYSTEM_CTX2 = { isSystem: true };
1217
1315
  async function tryFind(ql, object, where, limit = 100) {
1218
1316
  try {
1219
- const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
1317
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
1220
1318
  return Array.isArray(rows) ? rows : [];
1221
1319
  } catch {
1222
1320
  return [];
@@ -1224,7 +1322,7 @@ async function tryFind(ql, object, where, limit = 100) {
1224
1322
  }
1225
1323
  async function tryInsert(ql, object, data) {
1226
1324
  try {
1227
- return await ql.insert(object, data, { context: SYSTEM_CTX });
1325
+ return await ql.insert(object, data, { context: SYSTEM_CTX2 });
1228
1326
  } catch {
1229
1327
  return null;
1230
1328
  }
@@ -1252,6 +1350,9 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
1252
1350
  id,
1253
1351
  name: ps.name,
1254
1352
  label: ps.label ?? ps.name,
1353
+ // `description` is not part of the typed PermissionSet shape (name/label
1354
+ // only); read it defensively so a runtime-provided description still
1355
+ // persists without tripping the dts typecheck.
1255
1356
  description: ps.description ?? null,
1256
1357
  object_permissions: JSON.stringify(ps.objects ?? {}),
1257
1358
  field_permissions: JSON.stringify(ps.fields ?? {}),
@@ -1281,12 +1382,12 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
1281
1382
  { permission_set_id: adminPsId },
1282
1383
  50
1283
1384
  );
1284
- if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM)) {
1385
+ if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId2.SYSTEM)) {
1285
1386
  return { seeded: seededCount, adminPromoted: false, reason: "already_have_admin" };
1286
1387
  }
1287
1388
  const allUsers = await tryFind(ql, "sys_user", {}, 50);
1288
1389
  const humanUsers = allUsers.filter(
1289
- (u) => u.id !== SystemUserId.SYSTEM && u.role !== "system"
1390
+ (u) => u.id !== SystemUserId2.SYSTEM && u.role !== "system"
1290
1391
  );
1291
1392
  if (humanUsers.length === 0) {
1292
1393
  logger?.info?.("[security] no human users yet \u2014 first sign-up will be promoted to platform admin");
@@ -1310,11 +1411,18 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
1310
1411
  return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
1311
1412
  }
1312
1413
  logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
1313
- return { seeded: seededCount, adminPromoted: true };
1414
+ let ownershipClaimed = 0;
1415
+ try {
1416
+ const claims = await claimSeedOwnership(ql, target.id, { logger });
1417
+ ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
1418
+ } catch (e) {
1419
+ logger?.warn?.("[security] seed ownership handoff failed", { error: e.message });
1420
+ }
1421
+ return { seeded: seededCount, adminPromoted: true, ownershipClaimed };
1314
1422
  }
1315
1423
 
1316
1424
  // src/auto-org-admin-grant.ts
1317
- var SYSTEM_CTX2 = { isSystem: true };
1425
+ var SYSTEM_CTX3 = { isSystem: true };
1318
1426
  var PERMISSION_SET_NAME = "organization_admin";
1319
1427
  function genId2(prefix) {
1320
1428
  const rand = Math.random().toString(36).slice(2, 10);
@@ -1323,7 +1431,7 @@ function genId2(prefix) {
1323
1431
  }
1324
1432
  async function tryFind2(ql, object, where, limit = 50) {
1325
1433
  try {
1326
- const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
1434
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX3 });
1327
1435
  return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
1328
1436
  } catch {
1329
1437
  return [];
@@ -1331,14 +1439,14 @@ async function tryFind2(ql, object, where, limit = 50) {
1331
1439
  }
1332
1440
  async function tryInsert2(ql, object, data) {
1333
1441
  try {
1334
- return await ql.insert(object, data, { context: SYSTEM_CTX2 });
1442
+ return await ql.insert(object, data, { context: SYSTEM_CTX3 });
1335
1443
  } catch {
1336
1444
  return null;
1337
1445
  }
1338
1446
  }
1339
1447
  async function tryDelete(ql, object, id) {
1340
1448
  try {
1341
- await ql.delete(object, id, { context: SYSTEM_CTX2 });
1449
+ await ql.delete(object, id, { context: SYSTEM_CTX3 });
1342
1450
  return true;
1343
1451
  } catch {
1344
1452
  return false;
@@ -3070,6 +3178,8 @@ export {
3070
3178
  SECURITY_PLUGIN_VERSION,
3071
3179
  SecurityPlugin,
3072
3180
  backfillOrgAdminGrants,
3181
+ bootstrapPlatformAdmin,
3182
+ claimSeedOwnership,
3073
3183
  isPermissionDeniedError,
3074
3184
  reconcileOrgAdminGrant,
3075
3185
  securityDefaultPermissionSets,