@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.mjs
CHANGED
|
@@ -358,6 +358,16 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
358
358
|
description: ps.description ?? null,
|
|
359
359
|
object_permissions: JSON.stringify(ps.objects ?? {}),
|
|
360
360
|
field_permissions: JSON.stringify(ps.fields ?? {}),
|
|
361
|
+
// Persist the remaining permset facets so the runtime resolver
|
|
362
|
+
// (rest-server.ts / resolve-execution-context.ts) can hydrate
|
|
363
|
+
// them back into ExecutionContext.systemPermissions etc. Without
|
|
364
|
+
// these the platform-admin promotion grants the right LINK row
|
|
365
|
+
// but the permission set itself carries no capabilities, so
|
|
366
|
+
// `setup.access` / `studio.access` never reach the app filter
|
|
367
|
+
// and the Setup app is invisible even to admin_full_access.
|
|
368
|
+
system_permissions: JSON.stringify(ps.systemPermissions ?? []),
|
|
369
|
+
row_level_security: JSON.stringify(ps.rowLevelSecurity ?? []),
|
|
370
|
+
tab_permissions: JSON.stringify(ps.tabPermissions ?? {}),
|
|
361
371
|
active: true
|
|
362
372
|
});
|
|
363
373
|
if (created?.id) seeded[ps.name] = created.id;
|
|
@@ -403,370 +413,172 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
403
413
|
return { seeded: seededCount, adminPromoted: true };
|
|
404
414
|
}
|
|
405
415
|
|
|
406
|
-
// src/
|
|
416
|
+
// src/auto-org-admin-grant.ts
|
|
407
417
|
var SYSTEM_CTX2 = { isSystem: true };
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
return Object.prototype.hasOwnProperty.call(fields, "organization_id");
|
|
418
|
+
var PERMISSION_SET_NAME = "organization_admin";
|
|
419
|
+
function genId2(prefix) {
|
|
420
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
421
|
+
const ts = Date.now().toString(36);
|
|
422
|
+
return `${prefix}_${ts}${rand}`;
|
|
415
423
|
}
|
|
416
|
-
async function
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
return [];
|
|
420
|
-
}
|
|
421
|
-
const registry = ql.registry;
|
|
422
|
-
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
423
|
-
logger?.warn?.("[security] claimOrphanTenantRows: registry unavailable");
|
|
424
|
+
async function tryFind2(ql, object, where, limit = 50) {
|
|
425
|
+
try {
|
|
426
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
|
|
427
|
+
return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
|
|
428
|
+
} catch {
|
|
424
429
|
return [];
|
|
425
430
|
}
|
|
426
|
-
const schemas = registry.getAllObjects();
|
|
427
|
-
const results = [];
|
|
428
|
-
for (const schema of schemas) {
|
|
429
|
-
if (!schema?.name) continue;
|
|
430
|
-
if (schema.managedBy) continue;
|
|
431
|
-
if (schema.name.startsWith("sys_")) continue;
|
|
432
|
-
if (!hasOrganizationField(schema)) continue;
|
|
433
|
-
try {
|
|
434
|
-
const orphans = await ql.find(
|
|
435
|
-
schema.name,
|
|
436
|
-
{ where: { organization_id: null }, limit: 1e4, fields: ["id"] },
|
|
437
|
-
{ context: SYSTEM_CTX2 }
|
|
438
|
-
);
|
|
439
|
-
const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
|
|
440
|
-
if (list.length === 0) continue;
|
|
441
|
-
let updated = 0;
|
|
442
|
-
for (const row of list) {
|
|
443
|
-
if (!row?.id) continue;
|
|
444
|
-
try {
|
|
445
|
-
await ql.update(
|
|
446
|
-
schema.name,
|
|
447
|
-
{ id: row.id, organization_id: organizationId },
|
|
448
|
-
{ context: SYSTEM_CTX2 }
|
|
449
|
-
);
|
|
450
|
-
updated += 1;
|
|
451
|
-
} catch (e) {
|
|
452
|
-
logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
|
|
453
|
-
error: e.message
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
if (updated > 0) {
|
|
458
|
-
results.push({ object: schema.name, count: updated });
|
|
459
|
-
}
|
|
460
|
-
} catch (e) {
|
|
461
|
-
logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
|
|
462
|
-
error: e.message
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (results.length > 0) {
|
|
467
|
-
const total = results.reduce((s, r) => s + r.count, 0);
|
|
468
|
-
logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
|
|
469
|
-
breakdown: results
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
return results;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// src/clone-tenant-seed-data.ts
|
|
476
|
-
var SYSTEM_CTX3 = { isSystem: true };
|
|
477
|
-
var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
|
|
478
|
-
"id",
|
|
479
|
-
"created_at",
|
|
480
|
-
"updated_at",
|
|
481
|
-
"organization_id"
|
|
482
|
-
]);
|
|
483
|
-
var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
|
|
484
|
-
function fieldList(schema) {
|
|
485
|
-
const fields = schema?.fields;
|
|
486
|
-
if (!fields) return [];
|
|
487
|
-
if (Array.isArray(fields)) {
|
|
488
|
-
return fields.map((f) => ({
|
|
489
|
-
name: f?.name,
|
|
490
|
-
type: f?.type,
|
|
491
|
-
reference: f?.reference,
|
|
492
|
-
multiple: f?.multiple,
|
|
493
|
-
unique: f?.unique
|
|
494
|
-
}));
|
|
495
|
-
}
|
|
496
|
-
return Object.entries(fields).map(([name, f]) => ({
|
|
497
|
-
name,
|
|
498
|
-
type: f?.type,
|
|
499
|
-
reference: f?.reference,
|
|
500
|
-
multiple: f?.multiple,
|
|
501
|
-
unique: f?.unique
|
|
502
|
-
}));
|
|
503
|
-
}
|
|
504
|
-
function isLookupField(f) {
|
|
505
|
-
return (f.type === "lookup" || f.type === "master_detail" || f.type === "tree") && !!f.reference;
|
|
506
|
-
}
|
|
507
|
-
function hasOrgField(schema) {
|
|
508
|
-
return fieldList(schema).some((f) => f.name === "organization_id");
|
|
509
431
|
}
|
|
510
|
-
function
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
432
|
+
async function tryInsert2(ql, object, data) {
|
|
433
|
+
try {
|
|
434
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX2 });
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
515
437
|
}
|
|
516
|
-
return out;
|
|
517
438
|
}
|
|
518
|
-
async function
|
|
439
|
+
async function tryDelete(ql, object, id) {
|
|
519
440
|
try {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
{ orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
|
|
523
|
-
{ context: SYSTEM_CTX3 }
|
|
524
|
-
);
|
|
525
|
-
const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
|
|
526
|
-
return list[0]?.id ?? null;
|
|
441
|
+
await ql.delete(object, id, { context: SYSTEM_CTX2 });
|
|
442
|
+
return true;
|
|
527
443
|
} catch {
|
|
528
|
-
return
|
|
444
|
+
return false;
|
|
529
445
|
}
|
|
530
446
|
}
|
|
531
|
-
|
|
447
|
+
function parseRoles(raw) {
|
|
448
|
+
if (typeof raw !== "string") return [];
|
|
449
|
+
return raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0);
|
|
450
|
+
}
|
|
451
|
+
function isAdminRole(raw) {
|
|
452
|
+
const roles = parseRoles(raw);
|
|
453
|
+
return roles.includes("owner") || roles.includes("admin");
|
|
454
|
+
}
|
|
455
|
+
var permissionSetIdCache = /* @__PURE__ */ new WeakMap();
|
|
456
|
+
async function resolvePermissionSetId(ql) {
|
|
457
|
+
const cached = permissionSetIdCache.get(ql);
|
|
458
|
+
if (cached) return cached;
|
|
459
|
+
const rows = await tryFind2(ql, "sys_permission_set", { name: PERMISSION_SET_NAME }, 1);
|
|
460
|
+
const id = rows[0]?.id;
|
|
461
|
+
if (typeof id === "string" && id.length > 0) {
|
|
462
|
+
permissionSetIdCache.set(ql, id);
|
|
463
|
+
return id;
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
async function reconcileOrgAdminGrant(ql, userId, orgId, options = {}) {
|
|
532
468
|
const logger = options.logger;
|
|
533
469
|
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
534
|
-
return
|
|
470
|
+
return { action: "skipped", reason: "objectql_unavailable" };
|
|
535
471
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
|
|
539
|
-
return [];
|
|
472
|
+
if (!userId || !orgId) {
|
|
473
|
+
return { action: "skipped", reason: "missing_keys" };
|
|
540
474
|
}
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
543
|
-
|
|
544
|
-
const schemas = registry.getAllObjects().filter(
|
|
545
|
-
(s) => s?.name && !s.managedBy && !s.name.startsWith("sys_") && hasOrgField(s)
|
|
546
|
-
);
|
|
547
|
-
const remap = {};
|
|
548
|
-
const summary = [];
|
|
549
|
-
const inserted = [];
|
|
550
|
-
for (const schema of schemas) {
|
|
551
|
-
const objectName = schema.name;
|
|
552
|
-
try {
|
|
553
|
-
const existing = await ql.find(
|
|
554
|
-
objectName,
|
|
555
|
-
{ where: { organization_id: targetOrgId }, limit: 1, fields: ["id"] },
|
|
556
|
-
{ context: SYSTEM_CTX3 }
|
|
557
|
-
);
|
|
558
|
-
const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
|
|
559
|
-
if (existingList.length > 0) {
|
|
560
|
-
continue;
|
|
561
|
-
}
|
|
562
|
-
const donorRows = await ql.find(
|
|
563
|
-
objectName,
|
|
564
|
-
{ where: { organization_id: donorOrgId }, limit: 1e4 },
|
|
565
|
-
{ context: SYSTEM_CTX3 }
|
|
566
|
-
);
|
|
567
|
-
const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
|
|
568
|
-
if (rows.length === 0) continue;
|
|
569
|
-
const fields = fieldList(schema);
|
|
570
|
-
const lookups = fields.filter(isLookupField);
|
|
571
|
-
const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
|
|
572
|
-
const objectRemap = remap[objectName] ?? (remap[objectName] = {});
|
|
573
|
-
let cloned = 0;
|
|
574
|
-
for (const row of rows) {
|
|
575
|
-
const newId = shortId();
|
|
576
|
-
const data = { id: newId, organization_id: targetOrgId };
|
|
577
|
-
for (const f of fields) {
|
|
578
|
-
if (SKIP_COPY_FIELDS.has(f.name)) continue;
|
|
579
|
-
if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
|
|
580
|
-
if (row[f.name] === void 0) continue;
|
|
581
|
-
data[f.name] = row[f.name];
|
|
582
|
-
}
|
|
583
|
-
const suffix = `+${targetOrgId.slice(-6)}`;
|
|
584
|
-
for (const uf of uniqueFields) {
|
|
585
|
-
const v = data[uf.name];
|
|
586
|
-
if (typeof v !== "string" || !v) continue;
|
|
587
|
-
if (uf.type === "email" && v.includes("@")) {
|
|
588
|
-
const [local, domain] = v.split("@");
|
|
589
|
-
data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
|
|
590
|
-
} else {
|
|
591
|
-
data[uf.name] = `${v}${suffix}`;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
try {
|
|
595
|
-
await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
|
|
596
|
-
objectRemap[row.id] = newId;
|
|
597
|
-
inserted.push({ object: objectName, newId, record: data, lookups });
|
|
598
|
-
cloned++;
|
|
599
|
-
} catch (e) {
|
|
600
|
-
logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
|
|
601
|
-
object: objectName,
|
|
602
|
-
error: e.message
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (cloned > 0) summary.push({ object: objectName, count: cloned });
|
|
607
|
-
} catch (e) {
|
|
608
|
-
logger?.warn?.("[security] cloneTenantSeedData: object failed", {
|
|
609
|
-
object: objectName,
|
|
610
|
-
error: e.message
|
|
611
|
-
});
|
|
612
|
-
}
|
|
475
|
+
const permSetId = await resolvePermissionSetId(ql);
|
|
476
|
+
if (!permSetId) {
|
|
477
|
+
return { action: "skipped", reason: "permission_set_missing" };
|
|
613
478
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
dirty = true;
|
|
632
|
-
} else {
|
|
633
|
-
patch[f.name] = null;
|
|
634
|
-
dirty = true;
|
|
635
|
-
}
|
|
479
|
+
const memberships = await tryFind2(
|
|
480
|
+
ql,
|
|
481
|
+
"sys_member",
|
|
482
|
+
{ user_id: userId, organization_id: orgId },
|
|
483
|
+
10
|
|
484
|
+
);
|
|
485
|
+
const shouldGrant = memberships.some((m) => isAdminRole(m?.role));
|
|
486
|
+
const existingGrants = await tryFind2(
|
|
487
|
+
ql,
|
|
488
|
+
"sys_user_permission_set",
|
|
489
|
+
{ user_id: userId, organization_id: orgId, permission_set_id: permSetId },
|
|
490
|
+
5
|
|
491
|
+
);
|
|
492
|
+
if (shouldGrant) {
|
|
493
|
+
if (existingGrants.length > 0) {
|
|
494
|
+
for (const extra of existingGrants.slice(1)) {
|
|
495
|
+
if (extra?.id) await tryDelete(ql, "sys_user_permission_set", String(extra.id));
|
|
636
496
|
}
|
|
497
|
+
return { action: "noop" };
|
|
637
498
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
});
|
|
499
|
+
const created = await tryInsert2(ql, "sys_user_permission_set", {
|
|
500
|
+
id: genId2("ups"),
|
|
501
|
+
user_id: userId,
|
|
502
|
+
permission_set_id: permSetId,
|
|
503
|
+
organization_id: orgId,
|
|
504
|
+
granted_by: null
|
|
505
|
+
});
|
|
506
|
+
if (created) {
|
|
507
|
+
logger?.info?.("[security] granted organization_admin", { userId, orgId });
|
|
508
|
+
return { action: "granted" };
|
|
647
509
|
}
|
|
510
|
+
return { action: "skipped", reason: "insert_failed" };
|
|
648
511
|
}
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// src/ensure-user-has-organization.ts
|
|
653
|
-
var SYSTEM_CTX4 = { isSystem: true };
|
|
654
|
-
function genId2(prefix) {
|
|
655
|
-
const rand = Math.random().toString(36).slice(2, 10);
|
|
656
|
-
const ts = Date.now().toString(36);
|
|
657
|
-
return `${prefix}_${ts}${rand}`;
|
|
658
|
-
}
|
|
659
|
-
function slugify(input, fallback = "workspace") {
|
|
660
|
-
const cleaned = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
661
|
-
return cleaned || fallback;
|
|
662
|
-
}
|
|
663
|
-
function deriveSlugFallback(user) {
|
|
664
|
-
if (user.email) {
|
|
665
|
-
const local = user.email.split("@")[0] ?? "";
|
|
666
|
-
const localSlug = local.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
667
|
-
if (localSlug) return localSlug;
|
|
512
|
+
if (existingGrants.length === 0) {
|
|
513
|
+
return { action: "noop" };
|
|
668
514
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
if (user.email) {
|
|
675
|
-
const local = user.email.split("@")[0];
|
|
676
|
-
if (local) return local;
|
|
515
|
+
let removed = 0;
|
|
516
|
+
for (const row of existingGrants) {
|
|
517
|
+
if (row?.id && await tryDelete(ql, "sys_user_permission_set", String(row.id))) {
|
|
518
|
+
removed += 1;
|
|
519
|
+
}
|
|
677
520
|
}
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
try {
|
|
682
|
-
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX4 });
|
|
683
|
-
return Array.isArray(rows) ? rows : [];
|
|
684
|
-
} catch {
|
|
685
|
-
return [];
|
|
521
|
+
if (removed > 0) {
|
|
522
|
+
logger?.info?.("[security] revoked organization_admin", { userId, orgId, removed });
|
|
523
|
+
return { action: "revoked" };
|
|
686
524
|
}
|
|
525
|
+
return { action: "skipped", reason: "delete_failed" };
|
|
687
526
|
}
|
|
688
|
-
async function
|
|
527
|
+
async function backfillOrgAdminGrants(ql, options = {}) {
|
|
689
528
|
const logger = options.logger;
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (!
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
const
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
orgRow = await ql.insert(
|
|
719
|
-
"sys_organization",
|
|
720
|
-
{ id: orgId, name: orgName, slug, logo: null, metadata: null },
|
|
721
|
-
{ context: SYSTEM_CTX4 }
|
|
722
|
-
);
|
|
723
|
-
} catch (e) {
|
|
724
|
-
logger?.warn?.(`[security] failed to create personal org for ${user.email ?? user.id}`, {
|
|
725
|
-
error: e.message
|
|
726
|
-
});
|
|
727
|
-
return { created: false, reason: "org_insert_failed" };
|
|
728
|
-
}
|
|
729
|
-
const finalOrgId = orgRow?.id ?? orgId;
|
|
730
|
-
try {
|
|
731
|
-
await ql.insert(
|
|
732
|
-
"sys_member",
|
|
733
|
-
{
|
|
734
|
-
id: genId2("mem"),
|
|
735
|
-
organization_id: finalOrgId,
|
|
736
|
-
user_id: user.id,
|
|
737
|
-
role: "owner"
|
|
738
|
-
},
|
|
739
|
-
{ context: SYSTEM_CTX4 }
|
|
740
|
-
);
|
|
741
|
-
} catch (e) {
|
|
742
|
-
logger?.warn?.(`[security] failed to create owner-member row for ${user.email ?? user.id}`, {
|
|
743
|
-
error: e.message
|
|
744
|
-
});
|
|
745
|
-
return { created: false, reason: "member_insert_failed", organizationId: finalOrgId };
|
|
746
|
-
}
|
|
747
|
-
logger?.info?.(
|
|
748
|
-
`[security] created personal organization "${orgName}" (${finalOrgId}) for ${user.email ?? user.id}`
|
|
529
|
+
const limit = options.limit ?? 5e3;
|
|
530
|
+
const summary = { scanned: 0, granted: 0, revoked: 0, skipped: 0 };
|
|
531
|
+
if (!ql || typeof ql.find !== "function") return summary;
|
|
532
|
+
const permSetId = await resolvePermissionSetId(ql);
|
|
533
|
+
if (!permSetId) {
|
|
534
|
+
logger?.debug?.("[security] organization_admin backfill skipped \u2014 permission set missing");
|
|
535
|
+
return summary;
|
|
536
|
+
}
|
|
537
|
+
const members = await tryFind2(ql, "sys_member", {}, limit);
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
539
|
+
for (const m of members) {
|
|
540
|
+
const userId = String(m?.user_id ?? "");
|
|
541
|
+
const orgId = String(m?.organization_id ?? "");
|
|
542
|
+
if (!userId || !orgId) continue;
|
|
543
|
+
const key = `${userId}|${orgId}`;
|
|
544
|
+
if (seen.has(key)) continue;
|
|
545
|
+
seen.add(key);
|
|
546
|
+
summary.scanned += 1;
|
|
547
|
+
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger });
|
|
548
|
+
if (res.action === "granted") summary.granted += 1;
|
|
549
|
+
else if (res.action === "revoked") summary.revoked += 1;
|
|
550
|
+
else if (res.action === "skipped") summary.skipped += 1;
|
|
551
|
+
}
|
|
552
|
+
const allGrants = await tryFind2(
|
|
553
|
+
ql,
|
|
554
|
+
"sys_user_permission_set",
|
|
555
|
+
{ permission_set_id: permSetId },
|
|
556
|
+
limit
|
|
749
557
|
);
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
558
|
+
for (const g of allGrants) {
|
|
559
|
+
const userId = String(g?.user_id ?? "");
|
|
560
|
+
const orgId = String(g?.organization_id ?? "");
|
|
561
|
+
if (!userId || !orgId) continue;
|
|
562
|
+
const key = `${userId}|${orgId}`;
|
|
563
|
+
if (seen.has(key)) continue;
|
|
564
|
+
const res = await reconcileOrgAdminGrant(ql, userId, orgId, { logger });
|
|
565
|
+
if (res.action === "revoked") summary.revoked += 1;
|
|
566
|
+
}
|
|
567
|
+
logger?.info?.("[security] organization_admin backfill complete", summary);
|
|
568
|
+
return summary;
|
|
569
|
+
}
|
|
570
|
+
function extractMemberPairs(opCtx) {
|
|
571
|
+
const out = /* @__PURE__ */ new Map();
|
|
572
|
+
const add = (userId, orgId) => {
|
|
573
|
+
if (typeof userId === "string" && typeof orgId === "string" && userId && orgId) {
|
|
574
|
+
out.set(`${userId}|${orgId}`, { userId, orgId });
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
add(opCtx?.result?.user_id, opCtx?.result?.organization_id);
|
|
578
|
+
add(opCtx?.data?.user_id, opCtx?.data?.organization_id);
|
|
579
|
+
add(opCtx?.before?.user_id, opCtx?.before?.organization_id);
|
|
580
|
+
add(opCtx?.existing?.user_id, opCtx?.existing?.organization_id);
|
|
581
|
+
return Array.from(out.values());
|
|
770
582
|
}
|
|
771
583
|
|
|
772
584
|
// src/manifest.ts
|
|
@@ -807,6 +619,15 @@ var SecurityPlugin = class {
|
|
|
807
619
|
this.permissionEvaluator = new PermissionEvaluator();
|
|
808
620
|
this.rlsCompiler = new RLSCompiler();
|
|
809
621
|
this.fieldMasker = new FieldMasker();
|
|
622
|
+
/**
|
|
623
|
+
* Runtime probe — set in `start()` from
|
|
624
|
+
* `ctx.getService('org-scoping')`. When `false`, wildcard RLS
|
|
625
|
+
* policies that reference `current_user.organization_id` are
|
|
626
|
+
* stripped from the per-request policy set (saves the
|
|
627
|
+
* field-existence safety net cost on every find in single-tenant
|
|
628
|
+
* deployments). When `true`, the policies apply normally.
|
|
629
|
+
*/
|
|
630
|
+
this.orgScopingEnabled = false;
|
|
810
631
|
/**
|
|
811
632
|
* Per-object field-name cache. Populated lazily from the metadata
|
|
812
633
|
* service / ObjectQL registry on first access per object. Schemas are
|
|
@@ -828,7 +649,6 @@ var SecurityPlugin = class {
|
|
|
828
649
|
this.tenancyDisabledCache = /* @__PURE__ */ new Map();
|
|
829
650
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
830
651
|
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
831
|
-
this.multiTenant = options.multiTenant !== false;
|
|
832
652
|
}
|
|
833
653
|
async init(ctx) {
|
|
834
654
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -864,6 +684,21 @@ var SecurityPlugin = class {
|
|
|
864
684
|
ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
|
|
865
685
|
return;
|
|
866
686
|
}
|
|
687
|
+
try {
|
|
688
|
+
const orgScoping = ctx.getService("org-scoping");
|
|
689
|
+
this.orgScopingEnabled = !!orgScoping;
|
|
690
|
+
} catch {
|
|
691
|
+
this.orgScopingEnabled = false;
|
|
692
|
+
}
|
|
693
|
+
if (this.orgScopingEnabled) {
|
|
694
|
+
ctx.logger.info(
|
|
695
|
+
"[security] org-scoping plugin detected \u2014 wildcard `organization_id` RLS policies will apply"
|
|
696
|
+
);
|
|
697
|
+
} else {
|
|
698
|
+
ctx.logger.info(
|
|
699
|
+
"[security] org-scoping plugin not present \u2014 wildcard `organization_id` RLS policies will be stripped (single-tenant mode)"
|
|
700
|
+
);
|
|
701
|
+
}
|
|
867
702
|
const dbLoader = ql ? async (names) => {
|
|
868
703
|
let rows;
|
|
869
704
|
try {
|
|
@@ -953,19 +788,12 @@ var SecurityPlugin = class {
|
|
|
953
788
|
}
|
|
954
789
|
}
|
|
955
790
|
}
|
|
956
|
-
if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const data = opCtx.data;
|
|
963
|
-
if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
|
|
964
|
-
data.organization_id = opCtx.context.tenantId;
|
|
965
|
-
}
|
|
966
|
-
if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
|
|
967
|
-
data.owner_id = opCtx.context.userId;
|
|
968
|
-
}
|
|
791
|
+
if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data) && !!opCtx.context?.userId) {
|
|
792
|
+
const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
|
|
793
|
+
if (fields) {
|
|
794
|
+
const data = opCtx.data;
|
|
795
|
+
if (fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
|
|
796
|
+
data.owner_id = opCtx.context.userId;
|
|
969
797
|
}
|
|
970
798
|
}
|
|
971
799
|
}
|
|
@@ -1030,113 +858,41 @@ var SecurityPlugin = class {
|
|
|
1030
858
|
if (bootstrapRanOnce) {
|
|
1031
859
|
await runBootstrap();
|
|
1032
860
|
}
|
|
1033
|
-
if (this.multiTenant) {
|
|
1034
|
-
const newUser = opCtx?.result ?? opCtx?.data;
|
|
1035
|
-
if (newUser?.id) {
|
|
1036
|
-
try {
|
|
1037
|
-
await ensureUserHasOrganization(ql, newUser, {
|
|
1038
|
-
logger: ctx.logger,
|
|
1039
|
-
cloneSeedData: cloneTenantSeedData
|
|
1040
|
-
});
|
|
1041
|
-
} catch (e) {
|
|
1042
|
-
ctx.logger.warn("[security] ensure-user-has-organization failed", {
|
|
1043
|
-
error: e.message
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
861
|
}
|
|
1049
862
|
});
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
let datasets;
|
|
1060
|
-
try {
|
|
1061
|
-
const raw = kernel?.getService?.("seed-datasets");
|
|
1062
|
-
if (Array.isArray(raw) && raw.length > 0) datasets = raw;
|
|
1063
|
-
} catch {
|
|
1064
|
-
}
|
|
1065
|
-
let orgCount = 0;
|
|
1066
|
-
try {
|
|
1067
|
-
const allOrgs = await ql.find(
|
|
1068
|
-
"sys_organization",
|
|
1069
|
-
{ limit: 2, fields: ["id"] },
|
|
1070
|
-
{ context: { isSystem: true } }
|
|
1071
|
-
);
|
|
1072
|
-
const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
|
|
1073
|
-
orgCount = list.length;
|
|
1074
|
-
} catch (e) {
|
|
1075
|
-
ctx.logger.warn("[security] failed to count organizations", {
|
|
1076
|
-
error: e.message
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
let replayed = false;
|
|
863
|
+
ql.registerMiddleware(async (opCtx, next) => {
|
|
864
|
+
await next();
|
|
865
|
+
if (opCtx?.object !== "sys_member") return;
|
|
866
|
+
const op = opCtx?.operation;
|
|
867
|
+
if (op !== "insert" && op !== "create" && op !== "update" && op !== "delete" && op !== "remove") {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const pairs = extractMemberPairs(opCtx);
|
|
871
|
+
for (const { userId, orgId } of pairs) {
|
|
1080
872
|
try {
|
|
1081
|
-
|
|
1082
|
-
if (typeof replayer === "function") {
|
|
1083
|
-
const summary = await replayer(newOrgId);
|
|
1084
|
-
const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
|
|
1085
|
-
ctx.logger.info(
|
|
1086
|
-
`[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
|
|
1087
|
-
{
|
|
1088
|
-
organizationId: newOrgId,
|
|
1089
|
-
errors: summary?.errors?.slice?.(0, 5)
|
|
1090
|
-
}
|
|
1091
|
-
);
|
|
1092
|
-
if (total > 0) replayed = true;
|
|
1093
|
-
} else if (datasets) {
|
|
1094
|
-
ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
|
|
1095
|
-
organizationId: newOrgId
|
|
1096
|
-
});
|
|
1097
|
-
}
|
|
873
|
+
await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger });
|
|
1098
874
|
} catch (e) {
|
|
1099
|
-
ctx.logger.warn("[security]
|
|
1100
|
-
|
|
875
|
+
ctx.logger.warn?.("[security] org_admin reconcile failed", {
|
|
876
|
+
userId,
|
|
877
|
+
orgId,
|
|
1101
878
|
error: e.message
|
|
1102
879
|
});
|
|
1103
880
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
if (orgCount > 1) {
|
|
1123
|
-
try {
|
|
1124
|
-
const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
|
|
1125
|
-
if (summary.length > 0) {
|
|
1126
|
-
const total = summary.reduce((s, c) => s + c.count, 0);
|
|
1127
|
-
ctx.logger.info(
|
|
1128
|
-
`[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
|
|
1129
|
-
{ breakdown: summary }
|
|
1130
|
-
);
|
|
1131
|
-
}
|
|
1132
|
-
} catch (e) {
|
|
1133
|
-
ctx.logger.warn("[security] clone-tenant-seed-data failed", {
|
|
1134
|
-
organizationId: newOrgId,
|
|
1135
|
-
error: e.message
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
});
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
const runOrgAdminBackfill = async () => {
|
|
884
|
+
try {
|
|
885
|
+
await backfillOrgAdminGrants(ql, { logger: ctx.logger });
|
|
886
|
+
} catch (e) {
|
|
887
|
+
ctx.logger.warn?.("[security] organization_admin backfill failed", {
|
|
888
|
+
error: e.message
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
if (typeof ctx.hook === "function") {
|
|
893
|
+
ctx.hook("kernel:ready", runOrgAdminBackfill);
|
|
894
|
+
} else {
|
|
895
|
+
void runOrgAdminBackfill();
|
|
1140
896
|
}
|
|
1141
897
|
}
|
|
1142
898
|
async destroy() {
|
|
@@ -1149,7 +905,7 @@ var SecurityPlugin = class {
|
|
|
1149
905
|
for (const ps of permissionSets) {
|
|
1150
906
|
if (ps.rowLevelSecurity) {
|
|
1151
907
|
for (const policy of ps.rowLevelSecurity) {
|
|
1152
|
-
if (!this.
|
|
908
|
+
if (!this.orgScopingEnabled && policy.using && policy.using.includes("current_user.organization_id")) {
|
|
1153
909
|
continue;
|
|
1154
910
|
}
|
|
1155
911
|
allPolicies.push(policy);
|
|
@@ -1220,9 +976,9 @@ export {
|
|
|
1220
976
|
SECURITY_PLUGIN_ID,
|
|
1221
977
|
SECURITY_PLUGIN_VERSION,
|
|
1222
978
|
SecurityPlugin,
|
|
1223
|
-
|
|
1224
|
-
ensureUserHasOrganization,
|
|
979
|
+
backfillOrgAdminGrants,
|
|
1225
980
|
isPermissionDeniedError,
|
|
981
|
+
reconcileOrgAdminGrant,
|
|
1226
982
|
securityDefaultPermissionSets,
|
|
1227
983
|
securityObjects,
|
|
1228
984
|
securityPluginManifestHeader
|