@objectstack/plugin-security 6.8.1 → 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/README.md +13 -17
- package/dist/index.d.mts +832 -276
- package/dist/index.d.ts +832 -276
- package/dist/index.js +213 -457
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +211 -455
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -28,9 +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
|
-
|
|
32
|
-
ensureUserHasOrganization: () => ensureUserHasOrganization,
|
|
31
|
+
backfillOrgAdminGrants: () => backfillOrgAdminGrants,
|
|
33
32
|
isPermissionDeniedError: () => isPermissionDeniedError,
|
|
33
|
+
reconcileOrgAdminGrant: () => reconcileOrgAdminGrant,
|
|
34
34
|
securityDefaultPermissionSets: () => securityDefaultPermissionSets,
|
|
35
35
|
securityObjects: () => securityObjects,
|
|
36
36
|
securityPluginManifestHeader: () => securityPluginManifestHeader
|
|
@@ -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;
|
|
@@ -442,370 +452,172 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
442
452
|
return { seeded: seededCount, adminPromoted: true };
|
|
443
453
|
}
|
|
444
454
|
|
|
445
|
-
// src/
|
|
455
|
+
// src/auto-org-admin-grant.ts
|
|
446
456
|
var SYSTEM_CTX2 = { isSystem: true };
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
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}`;
|
|
454
462
|
}
|
|
455
|
-
async function
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
return [];
|
|
459
|
-
}
|
|
460
|
-
const registry = ql.registry;
|
|
461
|
-
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
462
|
-
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 {
|
|
463
468
|
return [];
|
|
464
469
|
}
|
|
465
|
-
const schemas = registry.getAllObjects();
|
|
466
|
-
const results = [];
|
|
467
|
-
for (const schema of schemas) {
|
|
468
|
-
if (!schema?.name) continue;
|
|
469
|
-
if (schema.managedBy) continue;
|
|
470
|
-
if (schema.name.startsWith("sys_")) continue;
|
|
471
|
-
if (!hasOrganizationField(schema)) continue;
|
|
472
|
-
try {
|
|
473
|
-
const orphans = await ql.find(
|
|
474
|
-
schema.name,
|
|
475
|
-
{ where: { organization_id: null }, limit: 1e4, fields: ["id"] },
|
|
476
|
-
{ context: SYSTEM_CTX2 }
|
|
477
|
-
);
|
|
478
|
-
const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
|
|
479
|
-
if (list.length === 0) continue;
|
|
480
|
-
let updated = 0;
|
|
481
|
-
for (const row of list) {
|
|
482
|
-
if (!row?.id) continue;
|
|
483
|
-
try {
|
|
484
|
-
await ql.update(
|
|
485
|
-
schema.name,
|
|
486
|
-
{ id: row.id, organization_id: organizationId },
|
|
487
|
-
{ context: SYSTEM_CTX2 }
|
|
488
|
-
);
|
|
489
|
-
updated += 1;
|
|
490
|
-
} catch (e) {
|
|
491
|
-
logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
|
|
492
|
-
error: e.message
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (updated > 0) {
|
|
497
|
-
results.push({ object: schema.name, count: updated });
|
|
498
|
-
}
|
|
499
|
-
} catch (e) {
|
|
500
|
-
logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
|
|
501
|
-
error: e.message
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
if (results.length > 0) {
|
|
506
|
-
const total = results.reduce((s, r) => s + r.count, 0);
|
|
507
|
-
logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
|
|
508
|
-
breakdown: results
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
return results;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// src/clone-tenant-seed-data.ts
|
|
515
|
-
var SYSTEM_CTX3 = { isSystem: true };
|
|
516
|
-
var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
|
|
517
|
-
"id",
|
|
518
|
-
"created_at",
|
|
519
|
-
"updated_at",
|
|
520
|
-
"organization_id"
|
|
521
|
-
]);
|
|
522
|
-
var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
|
|
523
|
-
function fieldList(schema) {
|
|
524
|
-
const fields = schema?.fields;
|
|
525
|
-
if (!fields) return [];
|
|
526
|
-
if (Array.isArray(fields)) {
|
|
527
|
-
return fields.map((f) => ({
|
|
528
|
-
name: f?.name,
|
|
529
|
-
type: f?.type,
|
|
530
|
-
reference: f?.reference,
|
|
531
|
-
multiple: f?.multiple,
|
|
532
|
-
unique: f?.unique
|
|
533
|
-
}));
|
|
534
|
-
}
|
|
535
|
-
return Object.entries(fields).map(([name, f]) => ({
|
|
536
|
-
name,
|
|
537
|
-
type: f?.type,
|
|
538
|
-
reference: f?.reference,
|
|
539
|
-
multiple: f?.multiple,
|
|
540
|
-
unique: f?.unique
|
|
541
|
-
}));
|
|
542
|
-
}
|
|
543
|
-
function isLookupField(f) {
|
|
544
|
-
return (f.type === "lookup" || f.type === "master_detail" || f.type === "tree") && !!f.reference;
|
|
545
|
-
}
|
|
546
|
-
function hasOrgField(schema) {
|
|
547
|
-
return fieldList(schema).some((f) => f.name === "organization_id");
|
|
548
470
|
}
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
471
|
+
async function tryInsert2(ql, object, data) {
|
|
472
|
+
try {
|
|
473
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX2 });
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
554
476
|
}
|
|
555
|
-
return out;
|
|
556
477
|
}
|
|
557
|
-
async function
|
|
478
|
+
async function tryDelete(ql, object, id) {
|
|
558
479
|
try {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
{ orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
|
|
562
|
-
{ context: SYSTEM_CTX3 }
|
|
563
|
-
);
|
|
564
|
-
const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
|
|
565
|
-
return list[0]?.id ?? null;
|
|
480
|
+
await ql.delete(object, id, { context: SYSTEM_CTX2 });
|
|
481
|
+
return true;
|
|
566
482
|
} catch {
|
|
567
|
-
return
|
|
483
|
+
return false;
|
|
568
484
|
}
|
|
569
485
|
}
|
|
570
|
-
|
|
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 = {}) {
|
|
571
507
|
const logger = options.logger;
|
|
572
508
|
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
573
|
-
return
|
|
509
|
+
return { action: "skipped", reason: "objectql_unavailable" };
|
|
574
510
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
|
|
578
|
-
return [];
|
|
511
|
+
if (!userId || !orgId) {
|
|
512
|
+
return { action: "skipped", reason: "missing_keys" };
|
|
579
513
|
}
|
|
580
|
-
const
|
|
581
|
-
if (!
|
|
582
|
-
|
|
583
|
-
const schemas = registry.getAllObjects().filter(
|
|
584
|
-
(s) => s?.name && !s.managedBy && !s.name.startsWith("sys_") && hasOrgField(s)
|
|
585
|
-
);
|
|
586
|
-
const remap = {};
|
|
587
|
-
const summary = [];
|
|
588
|
-
const inserted = [];
|
|
589
|
-
for (const schema of schemas) {
|
|
590
|
-
const objectName = schema.name;
|
|
591
|
-
try {
|
|
592
|
-
const existing = await ql.find(
|
|
593
|
-
objectName,
|
|
594
|
-
{ where: { organization_id: targetOrgId }, limit: 1, fields: ["id"] },
|
|
595
|
-
{ context: SYSTEM_CTX3 }
|
|
596
|
-
);
|
|
597
|
-
const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
|
|
598
|
-
if (existingList.length > 0) {
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
|
-
const donorRows = await ql.find(
|
|
602
|
-
objectName,
|
|
603
|
-
{ where: { organization_id: donorOrgId }, limit: 1e4 },
|
|
604
|
-
{ context: SYSTEM_CTX3 }
|
|
605
|
-
);
|
|
606
|
-
const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
|
|
607
|
-
if (rows.length === 0) continue;
|
|
608
|
-
const fields = fieldList(schema);
|
|
609
|
-
const lookups = fields.filter(isLookupField);
|
|
610
|
-
const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
|
|
611
|
-
const objectRemap = remap[objectName] ?? (remap[objectName] = {});
|
|
612
|
-
let cloned = 0;
|
|
613
|
-
for (const row of rows) {
|
|
614
|
-
const newId = shortId();
|
|
615
|
-
const data = { id: newId, organization_id: targetOrgId };
|
|
616
|
-
for (const f of fields) {
|
|
617
|
-
if (SKIP_COPY_FIELDS.has(f.name)) continue;
|
|
618
|
-
if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
|
|
619
|
-
if (row[f.name] === void 0) continue;
|
|
620
|
-
data[f.name] = row[f.name];
|
|
621
|
-
}
|
|
622
|
-
const suffix = `+${targetOrgId.slice(-6)}`;
|
|
623
|
-
for (const uf of uniqueFields) {
|
|
624
|
-
const v = data[uf.name];
|
|
625
|
-
if (typeof v !== "string" || !v) continue;
|
|
626
|
-
if (uf.type === "email" && v.includes("@")) {
|
|
627
|
-
const [local, domain] = v.split("@");
|
|
628
|
-
data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
|
|
629
|
-
} else {
|
|
630
|
-
data[uf.name] = `${v}${suffix}`;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
try {
|
|
634
|
-
await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
|
|
635
|
-
objectRemap[row.id] = newId;
|
|
636
|
-
inserted.push({ object: objectName, newId, record: data, lookups });
|
|
637
|
-
cloned++;
|
|
638
|
-
} catch (e) {
|
|
639
|
-
logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
|
|
640
|
-
object: objectName,
|
|
641
|
-
error: e.message
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
if (cloned > 0) summary.push({ object: objectName, count: cloned });
|
|
646
|
-
} catch (e) {
|
|
647
|
-
logger?.warn?.("[security] cloneTenantSeedData: object failed", {
|
|
648
|
-
object: objectName,
|
|
649
|
-
error: e.message
|
|
650
|
-
});
|
|
651
|
-
}
|
|
514
|
+
const permSetId = await resolvePermissionSetId(ql);
|
|
515
|
+
if (!permSetId) {
|
|
516
|
+
return { action: "skipped", reason: "permission_set_missing" };
|
|
652
517
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
dirty = true;
|
|
671
|
-
} else {
|
|
672
|
-
patch[f.name] = null;
|
|
673
|
-
dirty = true;
|
|
674
|
-
}
|
|
518
|
+
const memberships = await tryFind2(
|
|
519
|
+
ql,
|
|
520
|
+
"sys_member",
|
|
521
|
+
{ user_id: userId, organization_id: orgId },
|
|
522
|
+
10
|
|
523
|
+
);
|
|
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));
|
|
675
535
|
}
|
|
536
|
+
return { action: "noop" };
|
|
676
537
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
});
|
|
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" };
|
|
686
548
|
}
|
|
549
|
+
return { action: "skipped", reason: "insert_failed" };
|
|
687
550
|
}
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// src/ensure-user-has-organization.ts
|
|
692
|
-
var SYSTEM_CTX4 = { isSystem: true };
|
|
693
|
-
function genId2(prefix) {
|
|
694
|
-
const rand = Math.random().toString(36).slice(2, 10);
|
|
695
|
-
const ts = Date.now().toString(36);
|
|
696
|
-
return `${prefix}_${ts}${rand}`;
|
|
697
|
-
}
|
|
698
|
-
function slugify(input, fallback = "workspace") {
|
|
699
|
-
const cleaned = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
700
|
-
return cleaned || fallback;
|
|
701
|
-
}
|
|
702
|
-
function deriveSlugFallback(user) {
|
|
703
|
-
if (user.email) {
|
|
704
|
-
const local = user.email.split("@")[0] ?? "";
|
|
705
|
-
const localSlug = local.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
706
|
-
if (localSlug) return localSlug;
|
|
551
|
+
if (existingGrants.length === 0) {
|
|
552
|
+
return { action: "noop" };
|
|
707
553
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (user.email) {
|
|
714
|
-
const local = user.email.split("@")[0];
|
|
715
|
-
if (local) return local;
|
|
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;
|
|
558
|
+
}
|
|
716
559
|
}
|
|
717
|
-
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
try {
|
|
721
|
-
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX4 });
|
|
722
|
-
return Array.isArray(rows) ? rows : [];
|
|
723
|
-
} catch {
|
|
724
|
-
return [];
|
|
560
|
+
if (removed > 0) {
|
|
561
|
+
logger?.info?.("[security] revoked organization_admin", { userId, orgId, removed });
|
|
562
|
+
return { action: "revoked" };
|
|
725
563
|
}
|
|
564
|
+
return { action: "skipped", reason: "delete_failed" };
|
|
726
565
|
}
|
|
727
|
-
async function
|
|
566
|
+
async function backfillOrgAdminGrants(ql, options = {}) {
|
|
728
567
|
const logger = options.logger;
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if (!
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
orgRow = await ql.insert(
|
|
758
|
-
"sys_organization",
|
|
759
|
-
{ id: orgId, name: orgName, slug, logo: null, metadata: null },
|
|
760
|
-
{ context: SYSTEM_CTX4 }
|
|
761
|
-
);
|
|
762
|
-
} catch (e) {
|
|
763
|
-
logger?.warn?.(`[security] failed to create personal org for ${user.email ?? user.id}`, {
|
|
764
|
-
error: e.message
|
|
765
|
-
});
|
|
766
|
-
return { created: false, reason: "org_insert_failed" };
|
|
767
|
-
}
|
|
768
|
-
const finalOrgId = orgRow?.id ?? orgId;
|
|
769
|
-
try {
|
|
770
|
-
await ql.insert(
|
|
771
|
-
"sys_member",
|
|
772
|
-
{
|
|
773
|
-
id: genId2("mem"),
|
|
774
|
-
organization_id: finalOrgId,
|
|
775
|
-
user_id: user.id,
|
|
776
|
-
role: "owner"
|
|
777
|
-
},
|
|
778
|
-
{ context: SYSTEM_CTX4 }
|
|
779
|
-
);
|
|
780
|
-
} catch (e) {
|
|
781
|
-
logger?.warn?.(`[security] failed to create owner-member row for ${user.email ?? user.id}`, {
|
|
782
|
-
error: e.message
|
|
783
|
-
});
|
|
784
|
-
return { created: false, reason: "member_insert_failed", organizationId: finalOrgId };
|
|
785
|
-
}
|
|
786
|
-
logger?.info?.(
|
|
787
|
-
`[security] created personal organization "${orgName}" (${finalOrgId}) for ${user.email ?? user.id}`
|
|
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
|
|
788
596
|
);
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
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);
|
|
607
|
+
return summary;
|
|
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());
|
|
809
621
|
}
|
|
810
622
|
|
|
811
623
|
// src/manifest.ts
|
|
@@ -840,6 +652,15 @@ var SecurityPlugin = class {
|
|
|
840
652
|
this.permissionEvaluator = new PermissionEvaluator();
|
|
841
653
|
this.rlsCompiler = new RLSCompiler();
|
|
842
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;
|
|
843
664
|
/**
|
|
844
665
|
* Per-object field-name cache. Populated lazily from the metadata
|
|
845
666
|
* service / ObjectQL registry on first access per object. Schemas are
|
|
@@ -861,7 +682,6 @@ var SecurityPlugin = class {
|
|
|
861
682
|
this.tenancyDisabledCache = /* @__PURE__ */ new Map();
|
|
862
683
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
863
684
|
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
864
|
-
this.multiTenant = options.multiTenant !== false;
|
|
865
685
|
}
|
|
866
686
|
async init(ctx) {
|
|
867
687
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -897,6 +717,21 @@ var SecurityPlugin = class {
|
|
|
897
717
|
ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
|
|
898
718
|
return;
|
|
899
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
|
+
}
|
|
900
735
|
const dbLoader = ql ? async (names) => {
|
|
901
736
|
let rows;
|
|
902
737
|
try {
|
|
@@ -986,19 +821,12 @@ var SecurityPlugin = class {
|
|
|
986
821
|
}
|
|
987
822
|
}
|
|
988
823
|
}
|
|
989
|
-
if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
const data = opCtx.data;
|
|
996
|
-
if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
|
|
997
|
-
data.organization_id = opCtx.context.tenantId;
|
|
998
|
-
}
|
|
999
|
-
if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
|
|
1000
|
-
data.owner_id = opCtx.context.userId;
|
|
1001
|
-
}
|
|
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;
|
|
1002
830
|
}
|
|
1003
831
|
}
|
|
1004
832
|
}
|
|
@@ -1063,113 +891,41 @@ var SecurityPlugin = class {
|
|
|
1063
891
|
if (bootstrapRanOnce) {
|
|
1064
892
|
await runBootstrap();
|
|
1065
893
|
}
|
|
1066
|
-
if (this.multiTenant) {
|
|
1067
|
-
const newUser = opCtx?.result ?? opCtx?.data;
|
|
1068
|
-
if (newUser?.id) {
|
|
1069
|
-
try {
|
|
1070
|
-
await ensureUserHasOrganization(ql, newUser, {
|
|
1071
|
-
logger: ctx.logger,
|
|
1072
|
-
cloneSeedData: cloneTenantSeedData
|
|
1073
|
-
});
|
|
1074
|
-
} catch (e) {
|
|
1075
|
-
ctx.logger.warn("[security] ensure-user-has-organization failed", {
|
|
1076
|
-
error: e.message
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
894
|
}
|
|
1082
895
|
});
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
let datasets;
|
|
1093
|
-
try {
|
|
1094
|
-
const raw = kernel?.getService?.("seed-datasets");
|
|
1095
|
-
if (Array.isArray(raw) && raw.length > 0) datasets = raw;
|
|
1096
|
-
} catch {
|
|
1097
|
-
}
|
|
1098
|
-
let orgCount = 0;
|
|
1099
|
-
try {
|
|
1100
|
-
const allOrgs = await ql.find(
|
|
1101
|
-
"sys_organization",
|
|
1102
|
-
{ limit: 2, fields: ["id"] },
|
|
1103
|
-
{ context: { isSystem: true } }
|
|
1104
|
-
);
|
|
1105
|
-
const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
|
|
1106
|
-
orgCount = list.length;
|
|
1107
|
-
} catch (e) {
|
|
1108
|
-
ctx.logger.warn("[security] failed to count organizations", {
|
|
1109
|
-
error: e.message
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
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) {
|
|
1113
905
|
try {
|
|
1114
|
-
|
|
1115
|
-
if (typeof replayer === "function") {
|
|
1116
|
-
const summary = await replayer(newOrgId);
|
|
1117
|
-
const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
|
|
1118
|
-
ctx.logger.info(
|
|
1119
|
-
`[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
|
|
1120
|
-
{
|
|
1121
|
-
organizationId: newOrgId,
|
|
1122
|
-
errors: summary?.errors?.slice?.(0, 5)
|
|
1123
|
-
}
|
|
1124
|
-
);
|
|
1125
|
-
if (total > 0) replayed = true;
|
|
1126
|
-
} else if (datasets) {
|
|
1127
|
-
ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
|
|
1128
|
-
organizationId: newOrgId
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
906
|
+
await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger });
|
|
1131
907
|
} catch (e) {
|
|
1132
|
-
ctx.logger.warn("[security]
|
|
1133
|
-
|
|
908
|
+
ctx.logger.warn?.("[security] org_admin reconcile failed", {
|
|
909
|
+
userId,
|
|
910
|
+
orgId,
|
|
1134
911
|
error: e.message
|
|
1135
912
|
});
|
|
1136
913
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
if (orgCount > 1) {
|
|
1156
|
-
try {
|
|
1157
|
-
const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
|
|
1158
|
-
if (summary.length > 0) {
|
|
1159
|
-
const total = summary.reduce((s, c) => s + c.count, 0);
|
|
1160
|
-
ctx.logger.info(
|
|
1161
|
-
`[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
|
|
1162
|
-
{ breakdown: summary }
|
|
1163
|
-
);
|
|
1164
|
-
}
|
|
1165
|
-
} catch (e) {
|
|
1166
|
-
ctx.logger.warn("[security] clone-tenant-seed-data failed", {
|
|
1167
|
-
organizationId: newOrgId,
|
|
1168
|
-
error: e.message
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
});
|
|
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();
|
|
1173
929
|
}
|
|
1174
930
|
}
|
|
1175
931
|
async destroy() {
|
|
@@ -1182,7 +938,7 @@ var SecurityPlugin = class {
|
|
|
1182
938
|
for (const ps of permissionSets) {
|
|
1183
939
|
if (ps.rowLevelSecurity) {
|
|
1184
940
|
for (const policy of ps.rowLevelSecurity) {
|
|
1185
|
-
if (!this.
|
|
941
|
+
if (!this.orgScopingEnabled && policy.using && policy.using.includes("current_user.organization_id")) {
|
|
1186
942
|
continue;
|
|
1187
943
|
}
|
|
1188
944
|
allPolicies.push(policy);
|
|
@@ -1254,9 +1010,9 @@ var SecurityPlugin = class {
|
|
|
1254
1010
|
SECURITY_PLUGIN_ID,
|
|
1255
1011
|
SECURITY_PLUGIN_VERSION,
|
|
1256
1012
|
SecurityPlugin,
|
|
1257
|
-
|
|
1258
|
-
ensureUserHasOrganization,
|
|
1013
|
+
backfillOrgAdminGrants,
|
|
1259
1014
|
isPermissionDeniedError,
|
|
1015
|
+
reconcileOrgAdminGrant,
|
|
1260
1016
|
securityDefaultPermissionSets,
|
|
1261
1017
|
securityObjects,
|
|
1262
1018
|
securityPluginManifestHeader
|