@objectstack/plugin-security 6.9.0 → 7.0.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.js CHANGED
@@ -28,8 +28,9 @@ __export(index_exports, {
28
28
  SECURITY_PLUGIN_ID: () => SECURITY_PLUGIN_ID,
29
29
  SECURITY_PLUGIN_VERSION: () => SECURITY_PLUGIN_VERSION,
30
30
  SecurityPlugin: () => SecurityPlugin,
31
- cloneTenantSeedData: () => cloneTenantSeedData,
31
+ backfillOrgAdminGrants: () => backfillOrgAdminGrants,
32
32
  isPermissionDeniedError: () => isPermissionDeniedError,
33
+ reconcileOrgAdminGrant: () => reconcileOrgAdminGrant,
33
34
  securityDefaultPermissionSets: () => securityDefaultPermissionSets,
34
35
  securityObjects: () => securityObjects,
35
36
  securityPluginManifestHeader: () => securityPluginManifestHeader
@@ -377,7 +378,6 @@ function genId(prefix) {
377
378
  }
378
379
  async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {}) {
379
380
  const logger = options.logger;
380
- const multiTenant = options.multiTenant === true;
381
381
  if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
382
382
  return { seeded: 0, adminPromoted: false, reason: "objectql_unavailable" };
383
383
  }
@@ -397,6 +397,16 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
397
397
  description: ps.description ?? null,
398
398
  object_permissions: JSON.stringify(ps.objects ?? {}),
399
399
  field_permissions: JSON.stringify(ps.fields ?? {}),
400
+ // Persist the remaining permset facets so the runtime resolver
401
+ // (rest-server.ts / resolve-execution-context.ts) can hydrate
402
+ // them back into ExecutionContext.systemPermissions etc. Without
403
+ // these the platform-admin promotion grants the right LINK row
404
+ // but the permission set itself carries no capabilities, so
405
+ // `setup.access` / `studio.access` never reach the app filter
406
+ // and the Setup app is invisible even to admin_full_access.
407
+ system_permissions: JSON.stringify(ps.systemPermissions ?? []),
408
+ row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []),
409
+ tab_permissions: JSON.stringify(ps.tabPermissions ?? {}),
400
410
  active: true
401
411
  });
402
412
  if (created?.id) seeded[ps.name] = created.id;
@@ -439,295 +449,176 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
439
449
  return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
440
450
  }
441
451
  logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
442
- let defaultOrgCreated = false;
443
- let defaultOrgId;
444
- if (multiTenant) {
445
- const memberships = await tryFind(ql, "sys_member", { user_id: target.id }, 1);
446
- if (memberships.length === 0) {
447
- const existingDefault = await tryFind(ql, "sys_organization", { slug: "default" }, 1);
448
- if (existingDefault.length > 0 && existingDefault[0].id) {
449
- defaultOrgId = String(existingDefault[0].id);
450
- } else {
451
- const newOrgId = genId("org");
452
- const orgRow = await tryInsert(ql, "sys_organization", {
453
- id: newOrgId,
454
- name: "Default Organization",
455
- slug: "default",
456
- logo: null,
457
- metadata: null
458
- });
459
- if (orgRow) {
460
- defaultOrgId = orgRow?.id ?? newOrgId;
461
- defaultOrgCreated = true;
462
- } else {
463
- logger?.warn?.("[security] failed to create default organization for platform admin");
464
- }
465
- }
466
- if (defaultOrgId) {
467
- const memRow = await tryInsert(ql, "sys_member", {
468
- id: genId("mem"),
469
- organization_id: defaultOrgId,
470
- user_id: target.id,
471
- role: "owner"
472
- });
473
- if (memRow) {
474
- logger?.info?.(
475
- `[security] bound platform admin to default organization (${defaultOrgId}): ${target.email ?? target.id}`
476
- );
477
- } else {
478
- logger?.warn?.("[security] failed to bind platform admin to default organization");
479
- }
480
- }
481
- }
482
- }
483
- return { seeded: seededCount, adminPromoted: true, defaultOrgCreated, defaultOrgId };
452
+ return { seeded: seededCount, adminPromoted: true };
484
453
  }
485
454
 
486
- // src/claim-orphan-tenant-rows.ts
455
+ // src/auto-org-admin-grant.ts
487
456
  var SYSTEM_CTX2 = { isSystem: true };
488
- function hasOrganizationField(schema) {
489
- const fields = schema?.fields;
490
- if (!fields) return false;
491
- if (Array.isArray(fields)) {
492
- return fields.some((f) => f?.name === "organization_id");
493
- }
494
- return Object.prototype.hasOwnProperty.call(fields, "organization_id");
457
+ var PERMISSION_SET_NAME = "organization_admin";
458
+ function genId2(prefix) {
459
+ const rand = Math.random().toString(36).slice(2, 10);
460
+ const ts = Date.now().toString(36);
461
+ return `${prefix}_${ts}${rand}`;
495
462
  }
496
- async function claimOrphanTenantRows(ql, organizationId, options = {}) {
497
- const logger = options.logger;
498
- if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
499
- return [];
500
- }
501
- const registry = ql.registry;
502
- if (!registry || typeof registry.getAllObjects !== "function") {
503
- logger?.warn?.("[security] claimOrphanTenantRows: registry unavailable");
463
+ async function tryFind2(ql, object, where, limit = 50) {
464
+ try {
465
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
466
+ return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
467
+ } catch {
504
468
  return [];
505
469
  }
506
- const schemas = registry.getAllObjects();
507
- const results = [];
508
- for (const schema of schemas) {
509
- if (!schema?.name) continue;
510
- if (schema.managedBy) continue;
511
- if (schema.name.startsWith("sys_")) continue;
512
- if (!hasOrganizationField(schema)) continue;
513
- try {
514
- const orphans = await ql.find(
515
- schema.name,
516
- { where: { organization_id: null }, limit: 1e4, fields: ["id"] },
517
- { context: SYSTEM_CTX2 }
518
- );
519
- const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
520
- if (list.length === 0) continue;
521
- let updated = 0;
522
- for (const row of list) {
523
- if (!row?.id) continue;
524
- try {
525
- await ql.update(
526
- schema.name,
527
- { id: row.id, organization_id: organizationId },
528
- { context: SYSTEM_CTX2 }
529
- );
530
- updated += 1;
531
- } catch (e) {
532
- logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
533
- error: e.message
534
- });
535
- }
536
- }
537
- if (updated > 0) {
538
- results.push({ object: schema.name, count: updated });
539
- }
540
- } catch (e) {
541
- logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
542
- error: e.message
543
- });
544
- }
545
- }
546
- if (results.length > 0) {
547
- const total = results.reduce((s, r) => s + r.count, 0);
548
- logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
549
- breakdown: results
550
- });
551
- }
552
- return results;
553
- }
554
-
555
- // src/clone-tenant-seed-data.ts
556
- var SYSTEM_CTX3 = { isSystem: true };
557
- var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
558
- "id",
559
- "created_at",
560
- "updated_at",
561
- "organization_id"
562
- ]);
563
- var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
564
- function fieldList(schema) {
565
- const fields = schema?.fields;
566
- if (!fields) return [];
567
- if (Array.isArray(fields)) {
568
- return fields.map((f) => ({
569
- name: f?.name,
570
- type: f?.type,
571
- reference: f?.reference,
572
- multiple: f?.multiple,
573
- unique: f?.unique
574
- }));
575
- }
576
- return Object.entries(fields).map(([name, f]) => ({
577
- name,
578
- type: f?.type,
579
- reference: f?.reference,
580
- multiple: f?.multiple,
581
- unique: f?.unique
582
- }));
583
470
  }
584
- function isLookupField(f) {
585
- return (f.type === "lookup" || f.type === "master_detail" || f.type === "tree") && !!f.reference;
586
- }
587
- function hasOrgField(schema) {
588
- return fieldList(schema).some((f) => f.name === "organization_id");
589
- }
590
- function shortId() {
591
- const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
592
- let out = "";
593
- for (let i = 0; i < 16; i++) {
594
- out += alphabet[Math.floor(Math.random() * alphabet.length)];
471
+ async function tryInsert2(ql, object, data) {
472
+ try {
473
+ return await ql.insert(object, data, { context: SYSTEM_CTX2 });
474
+ } catch {
475
+ return null;
595
476
  }
596
- return out;
597
477
  }
598
- async function findDonorOrgId(ql) {
478
+ async function tryDelete(ql, object, id) {
599
479
  try {
600
- const res = await ql.find(
601
- "sys_organization",
602
- { orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
603
- { context: SYSTEM_CTX3 }
604
- );
605
- const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
606
- return list[0]?.id ?? null;
480
+ await ql.delete(object, id, { context: SYSTEM_CTX2 });
481
+ return true;
607
482
  } catch {
608
- return null;
483
+ return false;
609
484
  }
610
485
  }
611
- async function cloneTenantSeedData(ql, targetOrgId, options = {}) {
486
+ function parseRoles(raw) {
487
+ if (typeof raw !== "string") return [];
488
+ return raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
489
+ }
490
+ function isAdminRole(raw) {
491
+ const roles = parseRoles(raw);
492
+ return roles.includes("owner") || roles.includes("admin");
493
+ }
494
+ var permissionSetIdCache = /* @__PURE__ */ new WeakMap();
495
+ async function resolvePermissionSetId(ql) {
496
+ const cached = permissionSetIdCache.get(ql);
497
+ if (cached) return cached;
498
+ const rows = await tryFind2(ql, "sys_permission_set", { name: PERMISSION_SET_NAME }, 1);
499
+ const id = rows[0]?.id;
500
+ if (typeof id === "string" && id.length > 0) {
501
+ permissionSetIdCache.set(ql, id);
502
+ return id;
503
+ }
504
+ return null;
505
+ }
506
+ async function reconcileOrgAdminGrant(ql, userId, orgId, options = {}) {
612
507
  const logger = options.logger;
613
508
  if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
614
- return [];
509
+ return { action: "skipped", reason: "objectql_unavailable" };
615
510
  }
616
- const registry = ql.registry;
617
- if (!registry || typeof registry.getAllObjects !== "function") {
618
- logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
619
- return [];
511
+ if (!userId || !orgId) {
512
+ return { action: "skipped", reason: "missing_keys" };
620
513
  }
621
- const donorOrgId = await findDonorOrgId(ql);
622
- if (!donorOrgId) return [];
623
- if (donorOrgId === targetOrgId) return [];
624
- const schemas = registry.getAllObjects().filter(
625
- (s) => s?.name && !s.managedBy && !s.name.startsWith("sys_") && hasOrgField(s)
514
+ const permSetId = await resolvePermissionSetId(ql);
515
+ if (!permSetId) {
516
+ return { action: "skipped", reason: "permission_set_missing" };
517
+ }
518
+ const memberships = await tryFind2(
519
+ ql,
520
+ "sys_member",
521
+ { user_id: userId, organization_id: orgId },
522
+ 10
626
523
  );
627
- const remap = {};
628
- const summary = [];
629
- const inserted = [];
630
- for (const schema of schemas) {
631
- const objectName = schema.name;
632
- try {
633
- const existing = await ql.find(
634
- objectName,
635
- { where: { organization_id: targetOrgId }, limit: 1, fields: ["id"] },
636
- { context: SYSTEM_CTX3 }
637
- );
638
- const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
639
- if (existingList.length > 0) {
640
- continue;
641
- }
642
- const donorRows = await ql.find(
643
- objectName,
644
- { where: { organization_id: donorOrgId }, limit: 1e4 },
645
- { context: SYSTEM_CTX3 }
646
- );
647
- const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
648
- if (rows.length === 0) continue;
649
- const fields = fieldList(schema);
650
- const lookups = fields.filter(isLookupField);
651
- const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
652
- const objectRemap = remap[objectName] ?? (remap[objectName] = {});
653
- let cloned = 0;
654
- for (const row of rows) {
655
- const newId = shortId();
656
- const data = { id: newId, organization_id: targetOrgId };
657
- for (const f of fields) {
658
- if (SKIP_COPY_FIELDS.has(f.name)) continue;
659
- if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
660
- if (row[f.name] === void 0) continue;
661
- data[f.name] = row[f.name];
662
- }
663
- const suffix = `+${targetOrgId.slice(-6)}`;
664
- for (const uf of uniqueFields) {
665
- const v = data[uf.name];
666
- if (typeof v !== "string" || !v) continue;
667
- if (uf.type === "email" && v.includes("@")) {
668
- const [local, domain] = v.split("@");
669
- data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
670
- } else {
671
- data[uf.name] = `${v}${suffix}`;
672
- }
673
- }
674
- try {
675
- await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
676
- objectRemap[row.id] = newId;
677
- inserted.push({ object: objectName, newId, record: data, lookups });
678
- cloned++;
679
- } catch (e) {
680
- logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
681
- object: objectName,
682
- error: e.message
683
- });
684
- }
524
+ const shouldGrant = memberships.some((m) => isAdminRole(m?.role));
525
+ const existingGrants = await tryFind2(
526
+ ql,
527
+ "sys_user_permission_set",
528
+ { user_id: userId, organization_id: orgId, permission_set_id: permSetId },
529
+ 5
530
+ );
531
+ if (shouldGrant) {
532
+ if (existingGrants.length > 0) {
533
+ for (const extra of existingGrants.slice(1)) {
534
+ if (extra?.id) await tryDelete(ql, "sys_user_permission_set", String(extra.id));
685
535
  }
686
- if (cloned > 0) summary.push({ object: objectName, count: cloned });
687
- } catch (e) {
688
- logger?.warn?.("[security] cloneTenantSeedData: object failed", {
689
- object: objectName,
690
- error: e.message
691
- });
536
+ return { action: "noop" };
692
537
  }
693
- }
694
- for (const item of inserted) {
695
- if (item.lookups.length === 0) continue;
696
- const patch = {};
697
- let dirty = false;
698
- for (const f of item.lookups) {
699
- const oldVal = item.record[f.name];
700
- if (oldVal == null) continue;
701
- const targetMap = remap[f.reference];
702
- if (Array.isArray(oldVal)) {
703
- const next = oldVal.map((v) => typeof v === "string" && targetMap?.[v] || null).filter((v) => v != null);
704
- if (next.length !== oldVal.length || next.some((v, i) => v !== oldVal[i])) {
705
- patch[f.name] = next.length > 0 ? next : null;
706
- dirty = true;
707
- }
708
- } else if (typeof oldVal === "string") {
709
- if (targetMap && targetMap[oldVal]) {
710
- patch[f.name] = targetMap[oldVal];
711
- dirty = true;
712
- } else {
713
- patch[f.name] = null;
714
- dirty = true;
715
- }
716
- }
538
+ const created = await tryInsert2(ql, "sys_user_permission_set", {
539
+ id: genId2("ups"),
540
+ user_id: userId,
541
+ permission_set_id: permSetId,
542
+ organization_id: orgId,
543
+ granted_by: null
544
+ });
545
+ if (created) {
546
+ logger?.info?.("[security] granted organization_admin", { userId, orgId });
547
+ return { action: "granted" };
717
548
  }
718
- if (!dirty) continue;
719
- try {
720
- await ql.update(item.object, { id: item.newId, ...patch }, { context: SYSTEM_CTX3 });
721
- } catch (e) {
722
- logger?.warn?.("[security] cloneTenantSeedData: lookup remap failed", {
723
- object: item.object,
724
- id: item.newId,
725
- error: e.message
726
- });
549
+ return { action: "skipped", reason: "insert_failed" };
550
+ }
551
+ if (existingGrants.length === 0) {
552
+ return { action: "noop" };
553
+ }
554
+ let removed = 0;
555
+ for (const row of existingGrants) {
556
+ if (row?.id && await tryDelete(ql, "sys_user_permission_set", String(row.id))) {
557
+ removed += 1;
727
558
  }
728
559
  }
560
+ if (removed > 0) {
561
+ logger?.info?.("[security] revoked organization_admin", { userId, orgId, removed });
562
+ return { action: "revoked" };
563
+ }
564
+ return { action: "skipped", reason: "delete_failed" };
565
+ }
566
+ async function backfillOrgAdminGrants(ql, options = {}) {
567
+ const logger = options.logger;
568
+ const limit = options.limit ?? 5e3;
569
+ const summary = { scanned: 0, granted: 0, revoked: 0, skipped: 0 };
570
+ if (!ql || typeof ql.find !== "function") return summary;
571
+ const permSetId = await resolvePermissionSetId(ql);
572
+ if (!permSetId) {
573
+ logger?.debug?.("[security] organization_admin backfill skipped \u2014 permission set missing");
574
+ return summary;
575
+ }
576
+ const members = await tryFind2(ql, "sys_member", {}, limit);
577
+ const seen = /* @__PURE__ */ new Set();
578
+ for (const m of members) {
579
+ const userId = String(m?.user_id ?? "");
580
+ const orgId = String(m?.organization_id ?? "");
581
+ if (!userId || !orgId) continue;
582
+ const key = `${userId}|${orgId}`;
583
+ if (seen.has(key)) continue;
584
+ seen.add(key);
585
+ summary.scanned += 1;
586
+ const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger });
587
+ if (res.action === "granted") summary.granted += 1;
588
+ else if (res.action === "revoked") summary.revoked += 1;
589
+ else if (res.action === "skipped") summary.skipped += 1;
590
+ }
591
+ const allGrants = await tryFind2(
592
+ ql,
593
+ "sys_user_permission_set",
594
+ { permission_set_id: permSetId },
595
+ limit
596
+ );
597
+ for (const g of allGrants) {
598
+ const userId = String(g?.user_id ?? "");
599
+ const orgId = String(g?.organization_id ?? "");
600
+ if (!userId || !orgId) continue;
601
+ const key = `${userId}|${orgId}`;
602
+ if (seen.has(key)) continue;
603
+ const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger });
604
+ if (res.action === "revoked") summary.revoked += 1;
605
+ }
606
+ logger?.info?.("[security] organization_admin backfill complete", summary);
729
607
  return summary;
730
608
  }
609
+ function extractMemberPairs(opCtx) {
610
+ const out = /* @__PURE__ */ new Map();
611
+ const add = (userId, orgId) => {
612
+ if (typeof userId === "string" && typeof orgId === "string" && userId && orgId) {
613
+ out.set(`${userId}|${orgId}`, { userId, orgId });
614
+ }
615
+ };
616
+ add(opCtx?.result?.user_id, opCtx?.result?.organization_id);
617
+ add(opCtx?.data?.user_id, opCtx?.data?.organization_id);
618
+ add(opCtx?.before?.user_id, opCtx?.before?.organization_id);
619
+ add(opCtx?.existing?.user_id, opCtx?.existing?.organization_id);
620
+ return Array.from(out.values());
621
+ }
731
622
 
732
623
  // src/manifest.ts
733
624
  var import_security = require("@objectstack/platform-objects/security");
@@ -761,6 +652,15 @@ var SecurityPlugin = class {
761
652
  this.permissionEvaluator = new PermissionEvaluator();
762
653
  this.rlsCompiler = new RLSCompiler();
763
654
  this.fieldMasker = new FieldMasker();
655
+ /**
656
+ * Runtime probe — set in `start()` from
657
+ * `ctx.getService('org-scoping')`. When `false`, wildcard RLS
658
+ * policies that reference `current_user.organization_id` are
659
+ * stripped from the per-request policy set (saves the
660
+ * field-existence safety net cost on every find in single-tenant
661
+ * deployments). When `true`, the policies apply normally.
662
+ */
663
+ this.orgScopingEnabled = false;
764
664
  /**
765
665
  * Per-object field-name cache. Populated lazily from the metadata
766
666
  * service / ObjectQL registry on first access per object. Schemas are
@@ -782,7 +682,6 @@ var SecurityPlugin = class {
782
682
  this.tenancyDisabledCache = /* @__PURE__ */ new Map();
783
683
  this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
784
684
  this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
785
- this.multiTenant = options.multiTenant !== false;
786
685
  }
787
686
  async init(ctx) {
788
687
  ctx.logger.info("Initializing Security Plugin...");
@@ -818,6 +717,21 @@ var SecurityPlugin = class {
818
717
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
819
718
  return;
820
719
  }
720
+ try {
721
+ const orgScoping = ctx.getService("org-scoping");
722
+ this.orgScopingEnabled = !!orgScoping;
723
+ } catch {
724
+ this.orgScopingEnabled = false;
725
+ }
726
+ if (this.orgScopingEnabled) {
727
+ ctx.logger.info(
728
+ "[security] org-scoping plugin detected \u2014 wildcard `organization_id` RLS policies will apply"
729
+ );
730
+ } else {
731
+ ctx.logger.info(
732
+ "[security] org-scoping plugin not present \u2014 wildcard `organization_id` RLS policies will be stripped (single-tenant mode)"
733
+ );
734
+ }
821
735
  const dbLoader = ql ? async (names) => {
822
736
  let rows;
823
737
  try {
@@ -907,19 +821,12 @@ var SecurityPlugin = class {
907
821
  }
908
822
  }
909
823
  }
910
- if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
911
- const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
912
- const needsOwner = !!opCtx.context?.userId;
913
- if (needsTenant || needsOwner) {
914
- const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
915
- if (fields) {
916
- const data = opCtx.data;
917
- if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
918
- data.organization_id = opCtx.context.tenantId;
919
- }
920
- if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
921
- data.owner_id = opCtx.context.userId;
922
- }
824
+ if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data) && !!opCtx.context?.userId) {
825
+ const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
826
+ if (fields) {
827
+ const data = opCtx.data;
828
+ if (fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
829
+ data.owner_id = opCtx.context.userId;
923
830
  }
924
831
  }
925
832
  }
@@ -963,8 +870,7 @@ var SecurityPlugin = class {
963
870
  const runBootstrap = async () => {
964
871
  try {
965
872
  const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
966
- logger: ctx.logger,
967
- multiTenant: this.multiTenant
873
+ logger: ctx.logger
968
874
  });
969
875
  bootstrapRanOnce = true;
970
876
  ctx.logger.info("[security] platform bootstrap complete", report);
@@ -987,96 +893,39 @@ var SecurityPlugin = class {
987
893
  }
988
894
  }
989
895
  });
990
- if (this.multiTenant) {
991
- ql.registerMiddleware(async (opCtx, next) => {
992
- await next();
993
- if (opCtx?.object !== "sys_organization" || opCtx?.operation !== "create" && opCtx?.operation !== "insert") {
994
- return;
995
- }
996
- const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
997
- if (!newOrgId) return;
998
- const kernel = ctx.kernel ?? ctx;
999
- let datasets;
1000
- try {
1001
- const raw = kernel?.getService?.("seed-datasets");
1002
- if (Array.isArray(raw) && raw.length > 0) datasets = raw;
1003
- } catch {
1004
- }
1005
- let orgCount = 0;
1006
- try {
1007
- const allOrgs = await ql.find(
1008
- "sys_organization",
1009
- { limit: 2, fields: ["id"] },
1010
- { context: { isSystem: true } }
1011
- );
1012
- const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
1013
- orgCount = list.length;
1014
- } catch (e) {
1015
- ctx.logger.warn("[security] failed to count organizations", {
1016
- error: e.message
1017
- });
1018
- }
1019
- let replayed = false;
896
+ ql.registerMiddleware(async (opCtx, next) => {
897
+ await next();
898
+ if (opCtx?.object !== "sys_member") return;
899
+ const op = opCtx?.operation;
900
+ if (op !== "insert" && op !== "create" && op !== "update" && op !== "delete" && op !== "remove") {
901
+ return;
902
+ }
903
+ const pairs = extractMemberPairs(opCtx);
904
+ for (const { userId, orgId } of pairs) {
1020
905
  try {
1021
- const replayer = kernel?.getService?.("seed-replayer");
1022
- if (typeof replayer === "function") {
1023
- const summary = await replayer(newOrgId);
1024
- const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
1025
- ctx.logger.info(
1026
- `[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
1027
- {
1028
- organizationId: newOrgId,
1029
- errors: summary?.errors?.slice?.(0, 5)
1030
- }
1031
- );
1032
- if (total > 0) replayed = true;
1033
- } else if (datasets) {
1034
- ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
1035
- organizationId: newOrgId
1036
- });
1037
- }
906
+ await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger });
1038
907
  } catch (e) {
1039
- ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1040
- organizationId: newOrgId,
908
+ ctx.logger.warn?.("[security] org_admin reconcile failed", {
909
+ userId,
910
+ orgId,
1041
911
  error: e.message
1042
912
  });
1043
913
  }
1044
- if (replayed) return;
1045
- if (orgCount === 1) {
1046
- try {
1047
- const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
1048
- if (claims.length > 0) {
1049
- const total = claims.reduce((s, c) => s + c.count, 0);
1050
- ctx.logger.info(
1051
- `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
1052
- { breakdown: claims }
1053
- );
1054
- return;
1055
- }
1056
- } catch (e) {
1057
- ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1058
- error: e.message
1059
- });
1060
- }
1061
- }
1062
- if (orgCount > 1) {
1063
- try {
1064
- const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
1065
- if (summary.length > 0) {
1066
- const total = summary.reduce((s, c) => s + c.count, 0);
1067
- ctx.logger.info(
1068
- `[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
1069
- { breakdown: summary }
1070
- );
1071
- }
1072
- } catch (e) {
1073
- ctx.logger.warn("[security] clone-tenant-seed-data failed", {
1074
- organizationId: newOrgId,
1075
- error: e.message
1076
- });
1077
- }
1078
- }
1079
- });
914
+ }
915
+ });
916
+ const runOrgAdminBackfill = async () => {
917
+ try {
918
+ await backfillOrgAdminGrants(ql, { logger: ctx.logger });
919
+ } catch (e) {
920
+ ctx.logger.warn?.("[security] organization_admin backfill failed", {
921
+ error: e.message
922
+ });
923
+ }
924
+ };
925
+ if (typeof ctx.hook === "function") {
926
+ ctx.hook("kernel:ready", runOrgAdminBackfill);
927
+ } else {
928
+ void runOrgAdminBackfill();
1080
929
  }
1081
930
  }
1082
931
  async destroy() {
@@ -1089,7 +938,7 @@ var SecurityPlugin = class {
1089
938
  for (const ps of permissionSets) {
1090
939
  if (ps.rowLevelSecurity) {
1091
940
  for (const policy of ps.rowLevelSecurity) {
1092
- if (!this.multiTenant && policy.using && policy.using.includes("current_user.organization_id")) {
941
+ if (!this.orgScopingEnabled && policy.using && policy.using.includes("current_user.organization_id")) {
1093
942
  continue;
1094
943
  }
1095
944
  allPolicies.push(policy);
@@ -1161,8 +1010,9 @@ var SecurityPlugin = class {
1161
1010
  SECURITY_PLUGIN_ID,
1162
1011
  SECURITY_PLUGIN_VERSION,
1163
1012
  SecurityPlugin,
1164
- cloneTenantSeedData,
1013
+ backfillOrgAdminGrants,
1165
1014
  isPermissionDeniedError,
1015
+ reconcileOrgAdminGrant,
1166
1016
  securityDefaultPermissionSets,
1167
1017
  securityObjects,
1168
1018
  securityPluginManifestHeader