@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/README.md +13 -17
- package/dist/index.d.mts +806 -219
- package/dist/index.d.ts +806 -219
- package/dist/index.js +219 -369
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +217 -368
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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/
|
|
455
|
+
// src/auto-org-admin-grant.ts
|
|
487
456
|
var SYSTEM_CTX2 = { isSystem: true };
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
478
|
+
async function tryDelete(ql, object, id) {
|
|
599
479
|
try {
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
483
|
+
return false;
|
|
609
484
|
}
|
|
610
485
|
}
|
|
611
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
|
|
619
|
-
return [];
|
|
511
|
+
if (!userId || !orgId) {
|
|
512
|
+
return { action: "skipped", reason: "missing_keys" };
|
|
620
513
|
}
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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]
|
|
1040
|
-
|
|
908
|
+
ctx.logger.warn?.("[security] org_admin reconcile failed", {
|
|
909
|
+
userId,
|
|
910
|
+
orgId,
|
|
1041
911
|
error: e.message
|
|
1042
912
|
});
|
|
1043
913
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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.
|
|
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
|
-
|
|
1013
|
+
backfillOrgAdminGrants,
|
|
1165
1014
|
isPermissionDeniedError,
|
|
1015
|
+
reconcileOrgAdminGrant,
|
|
1166
1016
|
securityDefaultPermissionSets,
|
|
1167
1017
|
securityObjects,
|
|
1168
1018
|
securityPluginManifestHeader
|