@objectstack/plugin-security 6.9.0 → 7.1.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 +818 -231
- package/dist/index.d.ts +818 -231
- 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.mjs
CHANGED
|
@@ -339,7 +339,6 @@ function genId(prefix) {
|
|
|
339
339
|
}
|
|
340
340
|
async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {}) {
|
|
341
341
|
const logger = options.logger;
|
|
342
|
-
const multiTenant = options.multiTenant === true;
|
|
343
342
|
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
344
343
|
return { seeded: 0, adminPromoted: false, reason: "objectql_unavailable" };
|
|
345
344
|
}
|
|
@@ -359,6 +358,16 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
359
358
|
description: ps.description ?? null,
|
|
360
359
|
object_permissions: JSON.stringify(ps.objects ?? {}),
|
|
361
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 ?? {}),
|
|
362
371
|
active: true
|
|
363
372
|
});
|
|
364
373
|
if (created?.id) seeded[ps.name] = created.id;
|
|
@@ -401,295 +410,176 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
401
410
|
return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
|
|
402
411
|
}
|
|
403
412
|
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
|
|
404
|
-
|
|
405
|
-
let defaultOrgId;
|
|
406
|
-
if (multiTenant) {
|
|
407
|
-
const memberships = await tryFind(ql, "sys_member", { user_id: target.id }, 1);
|
|
408
|
-
if (memberships.length === 0) {
|
|
409
|
-
const existingDefault = await tryFind(ql, "sys_organization", { slug: "default" }, 1);
|
|
410
|
-
if (existingDefault.length > 0 && existingDefault[0].id) {
|
|
411
|
-
defaultOrgId = String(existingDefault[0].id);
|
|
412
|
-
} else {
|
|
413
|
-
const newOrgId = genId("org");
|
|
414
|
-
const orgRow = await tryInsert(ql, "sys_organization", {
|
|
415
|
-
id: newOrgId,
|
|
416
|
-
name: "Default Organization",
|
|
417
|
-
slug: "default",
|
|
418
|
-
logo: null,
|
|
419
|
-
metadata: null
|
|
420
|
-
});
|
|
421
|
-
if (orgRow) {
|
|
422
|
-
defaultOrgId = orgRow?.id ?? newOrgId;
|
|
423
|
-
defaultOrgCreated = true;
|
|
424
|
-
} else {
|
|
425
|
-
logger?.warn?.("[security] failed to create default organization for platform admin");
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
if (defaultOrgId) {
|
|
429
|
-
const memRow = await tryInsert(ql, "sys_member", {
|
|
430
|
-
id: genId("mem"),
|
|
431
|
-
organization_id: defaultOrgId,
|
|
432
|
-
user_id: target.id,
|
|
433
|
-
role: "owner"
|
|
434
|
-
});
|
|
435
|
-
if (memRow) {
|
|
436
|
-
logger?.info?.(
|
|
437
|
-
`[security] bound platform admin to default organization (${defaultOrgId}): ${target.email ?? target.id}`
|
|
438
|
-
);
|
|
439
|
-
} else {
|
|
440
|
-
logger?.warn?.("[security] failed to bind platform admin to default organization");
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
return { seeded: seededCount, adminPromoted: true, defaultOrgCreated, defaultOrgId };
|
|
413
|
+
return { seeded: seededCount, adminPromoted: true };
|
|
446
414
|
}
|
|
447
415
|
|
|
448
|
-
// src/
|
|
416
|
+
// src/auto-org-admin-grant.ts
|
|
449
417
|
var SYSTEM_CTX2 = { isSystem: true };
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
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}`;
|
|
457
423
|
}
|
|
458
|
-
async function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
return [];
|
|
462
|
-
}
|
|
463
|
-
const registry = ql.registry;
|
|
464
|
-
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
465
|
-
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 {
|
|
466
429
|
return [];
|
|
467
430
|
}
|
|
468
|
-
const schemas = registry.getAllObjects();
|
|
469
|
-
const results = [];
|
|
470
|
-
for (const schema of schemas) {
|
|
471
|
-
if (!schema?.name) continue;
|
|
472
|
-
if (schema.managedBy) continue;
|
|
473
|
-
if (schema.name.startsWith("sys_")) continue;
|
|
474
|
-
if (!hasOrganizationField(schema)) continue;
|
|
475
|
-
try {
|
|
476
|
-
const orphans = await ql.find(
|
|
477
|
-
schema.name,
|
|
478
|
-
{ where: { organization_id: null }, limit: 1e4, fields: ["id"] },
|
|
479
|
-
{ context: SYSTEM_CTX2 }
|
|
480
|
-
);
|
|
481
|
-
const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
|
|
482
|
-
if (list.length === 0) continue;
|
|
483
|
-
let updated = 0;
|
|
484
|
-
for (const row of list) {
|
|
485
|
-
if (!row?.id) continue;
|
|
486
|
-
try {
|
|
487
|
-
await ql.update(
|
|
488
|
-
schema.name,
|
|
489
|
-
{ id: row.id, organization_id: organizationId },
|
|
490
|
-
{ context: SYSTEM_CTX2 }
|
|
491
|
-
);
|
|
492
|
-
updated += 1;
|
|
493
|
-
} catch (e) {
|
|
494
|
-
logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
|
|
495
|
-
error: e.message
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if (updated > 0) {
|
|
500
|
-
results.push({ object: schema.name, count: updated });
|
|
501
|
-
}
|
|
502
|
-
} catch (e) {
|
|
503
|
-
logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
|
|
504
|
-
error: e.message
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
if (results.length > 0) {
|
|
509
|
-
const total = results.reduce((s, r) => s + r.count, 0);
|
|
510
|
-
logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
|
|
511
|
-
breakdown: results
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
return results;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// src/clone-tenant-seed-data.ts
|
|
518
|
-
var SYSTEM_CTX3 = { isSystem: true };
|
|
519
|
-
var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
|
|
520
|
-
"id",
|
|
521
|
-
"created_at",
|
|
522
|
-
"updated_at",
|
|
523
|
-
"organization_id"
|
|
524
|
-
]);
|
|
525
|
-
var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
|
|
526
|
-
function fieldList(schema) {
|
|
527
|
-
const fields = schema?.fields;
|
|
528
|
-
if (!fields) return [];
|
|
529
|
-
if (Array.isArray(fields)) {
|
|
530
|
-
return fields.map((f) => ({
|
|
531
|
-
name: f?.name,
|
|
532
|
-
type: f?.type,
|
|
533
|
-
reference: f?.reference,
|
|
534
|
-
multiple: f?.multiple,
|
|
535
|
-
unique: f?.unique
|
|
536
|
-
}));
|
|
537
|
-
}
|
|
538
|
-
return Object.entries(fields).map(([name, f]) => ({
|
|
539
|
-
name,
|
|
540
|
-
type: f?.type,
|
|
541
|
-
reference: f?.reference,
|
|
542
|
-
multiple: f?.multiple,
|
|
543
|
-
unique: f?.unique
|
|
544
|
-
}));
|
|
545
431
|
}
|
|
546
|
-
function
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
function shortId() {
|
|
553
|
-
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
554
|
-
let out = "";
|
|
555
|
-
for (let i = 0; i < 16; i++) {
|
|
556
|
-
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
432
|
+
async function tryInsert2(ql, object, data) {
|
|
433
|
+
try {
|
|
434
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX2 });
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
557
437
|
}
|
|
558
|
-
return out;
|
|
559
438
|
}
|
|
560
|
-
async function
|
|
439
|
+
async function tryDelete(ql, object, id) {
|
|
561
440
|
try {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
{ orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
|
|
565
|
-
{ context: SYSTEM_CTX3 }
|
|
566
|
-
);
|
|
567
|
-
const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
|
|
568
|
-
return list[0]?.id ?? null;
|
|
441
|
+
await ql.delete(object, id, { context: SYSTEM_CTX2 });
|
|
442
|
+
return true;
|
|
569
443
|
} catch {
|
|
570
|
-
return
|
|
444
|
+
return false;
|
|
571
445
|
}
|
|
572
446
|
}
|
|
573
|
-
|
|
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 = {}) {
|
|
574
468
|
const logger = options.logger;
|
|
575
469
|
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
576
|
-
return
|
|
470
|
+
return { action: "skipped", reason: "objectql_unavailable" };
|
|
577
471
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
|
|
581
|
-
return [];
|
|
472
|
+
if (!userId || !orgId) {
|
|
473
|
+
return { action: "skipped", reason: "missing_keys" };
|
|
582
474
|
}
|
|
583
|
-
const
|
|
584
|
-
if (!
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
475
|
+
const permSetId = await resolvePermissionSetId(ql);
|
|
476
|
+
if (!permSetId) {
|
|
477
|
+
return { action: "skipped", reason: "permission_set_missing" };
|
|
478
|
+
}
|
|
479
|
+
const memberships = await tryFind2(
|
|
480
|
+
ql,
|
|
481
|
+
"sys_member",
|
|
482
|
+
{ user_id: userId, organization_id: orgId },
|
|
483
|
+
10
|
|
588
484
|
);
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
|
|
601
|
-
if (existingList.length > 0) {
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
const donorRows = await ql.find(
|
|
605
|
-
objectName,
|
|
606
|
-
{ where: { organization_id: donorOrgId }, limit: 1e4 },
|
|
607
|
-
{ context: SYSTEM_CTX3 }
|
|
608
|
-
);
|
|
609
|
-
const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
|
|
610
|
-
if (rows.length === 0) continue;
|
|
611
|
-
const fields = fieldList(schema);
|
|
612
|
-
const lookups = fields.filter(isLookupField);
|
|
613
|
-
const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
|
|
614
|
-
const objectRemap = remap[objectName] ?? (remap[objectName] = {});
|
|
615
|
-
let cloned = 0;
|
|
616
|
-
for (const row of rows) {
|
|
617
|
-
const newId = shortId();
|
|
618
|
-
const data = { id: newId, organization_id: targetOrgId };
|
|
619
|
-
for (const f of fields) {
|
|
620
|
-
if (SKIP_COPY_FIELDS.has(f.name)) continue;
|
|
621
|
-
if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
|
|
622
|
-
if (row[f.name] === void 0) continue;
|
|
623
|
-
data[f.name] = row[f.name];
|
|
624
|
-
}
|
|
625
|
-
const suffix = `+${targetOrgId.slice(-6)}`;
|
|
626
|
-
for (const uf of uniqueFields) {
|
|
627
|
-
const v = data[uf.name];
|
|
628
|
-
if (typeof v !== "string" || !v) continue;
|
|
629
|
-
if (uf.type === "email" && v.includes("@")) {
|
|
630
|
-
const [local, domain] = v.split("@");
|
|
631
|
-
data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
|
|
632
|
-
} else {
|
|
633
|
-
data[uf.name] = `${v}${suffix}`;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
try {
|
|
637
|
-
await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
|
|
638
|
-
objectRemap[row.id] = newId;
|
|
639
|
-
inserted.push({ object: objectName, newId, record: data, lookups });
|
|
640
|
-
cloned++;
|
|
641
|
-
} catch (e) {
|
|
642
|
-
logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
|
|
643
|
-
object: objectName,
|
|
644
|
-
error: e.message
|
|
645
|
-
});
|
|
646
|
-
}
|
|
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));
|
|
647
496
|
}
|
|
648
|
-
|
|
649
|
-
} catch (e) {
|
|
650
|
-
logger?.warn?.("[security] cloneTenantSeedData: object failed", {
|
|
651
|
-
object: objectName,
|
|
652
|
-
error: e.message
|
|
653
|
-
});
|
|
497
|
+
return { action: "noop" };
|
|
654
498
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const next = oldVal.map((v) => typeof v === "string" && targetMap?.[v] || null).filter((v) => v != null);
|
|
666
|
-
if (next.length !== oldVal.length || next.some((v, i) => v !== oldVal[i])) {
|
|
667
|
-
patch[f.name] = next.length > 0 ? next : null;
|
|
668
|
-
dirty = true;
|
|
669
|
-
}
|
|
670
|
-
} else if (typeof oldVal === "string") {
|
|
671
|
-
if (targetMap && targetMap[oldVal]) {
|
|
672
|
-
patch[f.name] = targetMap[oldVal];
|
|
673
|
-
dirty = true;
|
|
674
|
-
} else {
|
|
675
|
-
patch[f.name] = null;
|
|
676
|
-
dirty = true;
|
|
677
|
-
}
|
|
678
|
-
}
|
|
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" };
|
|
679
509
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
510
|
+
return { action: "skipped", reason: "insert_failed" };
|
|
511
|
+
}
|
|
512
|
+
if (existingGrants.length === 0) {
|
|
513
|
+
return { action: "noop" };
|
|
514
|
+
}
|
|
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;
|
|
689
519
|
}
|
|
690
520
|
}
|
|
521
|
+
if (removed > 0) {
|
|
522
|
+
logger?.info?.("[security] revoked organization_admin", { userId, orgId, removed });
|
|
523
|
+
return { action: "revoked" };
|
|
524
|
+
}
|
|
525
|
+
return { action: "skipped", reason: "delete_failed" };
|
|
526
|
+
}
|
|
527
|
+
async function backfillOrgAdminGrants(ql, options = {}) {
|
|
528
|
+
const logger = options.logger;
|
|
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
|
|
557
|
+
);
|
|
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);
|
|
691
568
|
return summary;
|
|
692
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());
|
|
582
|
+
}
|
|
693
583
|
|
|
694
584
|
// src/manifest.ts
|
|
695
585
|
import {
|
|
@@ -729,6 +619,15 @@ var SecurityPlugin = class {
|
|
|
729
619
|
this.permissionEvaluator = new PermissionEvaluator();
|
|
730
620
|
this.rlsCompiler = new RLSCompiler();
|
|
731
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;
|
|
732
631
|
/**
|
|
733
632
|
* Per-object field-name cache. Populated lazily from the metadata
|
|
734
633
|
* service / ObjectQL registry on first access per object. Schemas are
|
|
@@ -750,7 +649,6 @@ var SecurityPlugin = class {
|
|
|
750
649
|
this.tenancyDisabledCache = /* @__PURE__ */ new Map();
|
|
751
650
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
752
651
|
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
753
|
-
this.multiTenant = options.multiTenant !== false;
|
|
754
652
|
}
|
|
755
653
|
async init(ctx) {
|
|
756
654
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -786,6 +684,21 @@ var SecurityPlugin = class {
|
|
|
786
684
|
ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
|
|
787
685
|
return;
|
|
788
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
|
+
}
|
|
789
702
|
const dbLoader = ql ? async (names) => {
|
|
790
703
|
let rows;
|
|
791
704
|
try {
|
|
@@ -875,19 +788,12 @@ var SecurityPlugin = class {
|
|
|
875
788
|
}
|
|
876
789
|
}
|
|
877
790
|
}
|
|
878
|
-
if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
const data = opCtx.data;
|
|
885
|
-
if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
|
|
886
|
-
data.organization_id = opCtx.context.tenantId;
|
|
887
|
-
}
|
|
888
|
-
if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
|
|
889
|
-
data.owner_id = opCtx.context.userId;
|
|
890
|
-
}
|
|
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;
|
|
891
797
|
}
|
|
892
798
|
}
|
|
893
799
|
}
|
|
@@ -931,8 +837,7 @@ var SecurityPlugin = class {
|
|
|
931
837
|
const runBootstrap = async () => {
|
|
932
838
|
try {
|
|
933
839
|
const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
|
|
934
|
-
logger: ctx.logger
|
|
935
|
-
multiTenant: this.multiTenant
|
|
840
|
+
logger: ctx.logger
|
|
936
841
|
});
|
|
937
842
|
bootstrapRanOnce = true;
|
|
938
843
|
ctx.logger.info("[security] platform bootstrap complete", report);
|
|
@@ -955,96 +860,39 @@ var SecurityPlugin = class {
|
|
|
955
860
|
}
|
|
956
861
|
}
|
|
957
862
|
});
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
let datasets;
|
|
968
|
-
try {
|
|
969
|
-
const raw = kernel?.getService?.("seed-datasets");
|
|
970
|
-
if (Array.isArray(raw) && raw.length > 0) datasets = raw;
|
|
971
|
-
} catch {
|
|
972
|
-
}
|
|
973
|
-
let orgCount = 0;
|
|
974
|
-
try {
|
|
975
|
-
const allOrgs = await ql.find(
|
|
976
|
-
"sys_organization",
|
|
977
|
-
{ limit: 2, fields: ["id"] },
|
|
978
|
-
{ context: { isSystem: true } }
|
|
979
|
-
);
|
|
980
|
-
const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
|
|
981
|
-
orgCount = list.length;
|
|
982
|
-
} catch (e) {
|
|
983
|
-
ctx.logger.warn("[security] failed to count organizations", {
|
|
984
|
-
error: e.message
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
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) {
|
|
988
872
|
try {
|
|
989
|
-
|
|
990
|
-
if (typeof replayer === "function") {
|
|
991
|
-
const summary = await replayer(newOrgId);
|
|
992
|
-
const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
|
|
993
|
-
ctx.logger.info(
|
|
994
|
-
`[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
|
|
995
|
-
{
|
|
996
|
-
organizationId: newOrgId,
|
|
997
|
-
errors: summary?.errors?.slice?.(0, 5)
|
|
998
|
-
}
|
|
999
|
-
);
|
|
1000
|
-
if (total > 0) replayed = true;
|
|
1001
|
-
} else if (datasets) {
|
|
1002
|
-
ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
|
|
1003
|
-
organizationId: newOrgId
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
873
|
+
await reconcileOrgAdminGrant(ql, userId, orgId, { logger: ctx.logger });
|
|
1006
874
|
} catch (e) {
|
|
1007
|
-
ctx.logger.warn("[security]
|
|
1008
|
-
|
|
875
|
+
ctx.logger.warn?.("[security] org_admin reconcile failed", {
|
|
876
|
+
userId,
|
|
877
|
+
orgId,
|
|
1009
878
|
error: e.message
|
|
1010
879
|
});
|
|
1011
880
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
if (orgCount > 1) {
|
|
1031
|
-
try {
|
|
1032
|
-
const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
|
|
1033
|
-
if (summary.length > 0) {
|
|
1034
|
-
const total = summary.reduce((s, c) => s + c.count, 0);
|
|
1035
|
-
ctx.logger.info(
|
|
1036
|
-
`[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
|
|
1037
|
-
{ breakdown: summary }
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
} catch (e) {
|
|
1041
|
-
ctx.logger.warn("[security] clone-tenant-seed-data failed", {
|
|
1042
|
-
organizationId: newOrgId,
|
|
1043
|
-
error: e.message
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
});
|
|
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();
|
|
1048
896
|
}
|
|
1049
897
|
}
|
|
1050
898
|
async destroy() {
|
|
@@ -1057,7 +905,7 @@ var SecurityPlugin = class {
|
|
|
1057
905
|
for (const ps of permissionSets) {
|
|
1058
906
|
if (ps.rowLevelSecurity) {
|
|
1059
907
|
for (const policy of ps.rowLevelSecurity) {
|
|
1060
|
-
if (!this.
|
|
908
|
+
if (!this.orgScopingEnabled && policy.using && policy.using.includes("current_user.organization_id")) {
|
|
1061
909
|
continue;
|
|
1062
910
|
}
|
|
1063
911
|
allPolicies.push(policy);
|
|
@@ -1128,8 +976,9 @@ export {
|
|
|
1128
976
|
SECURITY_PLUGIN_ID,
|
|
1129
977
|
SECURITY_PLUGIN_VERSION,
|
|
1130
978
|
SecurityPlugin,
|
|
1131
|
-
|
|
979
|
+
backfillOrgAdminGrants,
|
|
1132
980
|
isPermissionDeniedError,
|
|
981
|
+
reconcileOrgAdminGrant,
|
|
1133
982
|
securityDefaultPermissionSets,
|
|
1134
983
|
securityObjects,
|
|
1135
984
|
securityPluginManifestHeader
|