@7365admin1/core 3.53.3 → 3.53.5
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/CHANGELOG.md +26 -0
- package/dist/index.d.ts +197 -1
- package/dist/index.js +519 -477
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +219 -185
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/invite-org-scope.test.mjs +7 -1
- package/test/role-member-org-scope.test.mjs +4 -1
- package/test/sp-approvals-console-gate.test.mjs +117 -0
- package/test/staff-bypass-tenant-scope.test.mjs +38 -0
package/package.json
CHANGED
|
@@ -106,6 +106,12 @@ test("the two service-provider invites still resolve their caller themselves", (
|
|
|
106
106
|
for (const source of [verificationService, verificationServiceV2]) {
|
|
107
107
|
const body = fn(source, "createServiceProviderInvite");
|
|
108
108
|
assert.match(body, /resolveInviteActor\(invitedBy\)/);
|
|
109
|
-
assert.match(
|
|
109
|
+
assert.match(
|
|
110
|
+
body,
|
|
111
|
+
// Widened to the property this pinned — a staff clause resolved from the
|
|
112
|
+
// session, ahead of the caller's own orgs. The staff clause is now the
|
|
113
|
+
// cross-client grant (`staffMayBypass`), not identity alone.
|
|
114
|
+
/staffMayBypass\(actor\)[\s\S]{0,80}actor\.orgIds\.includes\(orgId\)/,
|
|
115
|
+
);
|
|
110
116
|
}
|
|
111
117
|
});
|
|
@@ -103,7 +103,10 @@ test("the membership is authorised before it is written", () => {
|
|
|
103
103
|
|
|
104
104
|
test("reach is staff, or membership, or an invitation — and nothing else", () => {
|
|
105
105
|
const body = fn(authz, "requireOrgReach");
|
|
106
|
-
|
|
106
|
+
// The staff road is now the cross-client GRANT, not identity alone
|
|
107
|
+
// (`staffMayBypass`) — still one road, asked one notch harder. Widened to the
|
|
108
|
+
// property this pinned, never weakened: a staff clause must still be first.
|
|
109
|
+
assert.match(body, /if \(staffMayBypass\(actor\)\) return id;/);
|
|
107
110
|
assert.match(body, /actor\.orgIds\.includes\(org\)/);
|
|
108
111
|
assert.match(body, /await hasOrgInvitation\(id, org\)/);
|
|
109
112
|
assert.match(body, /throw new UnauthorizedError\("Not authorized\."\)/);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SP Approvals — the console module, on the server.
|
|
8
|
+
*
|
|
9
|
+
* `service-provider-invite.service.ts assertSuperAdmin` answers "is this
|
|
10
|
+
* Seven365 staff" and nothing else, so a staff role ticked for Promo Codes
|
|
11
|
+
* alone could approve or reject any client's service-provider application. The
|
|
12
|
+
* four staff-only handlers now also ask which console module the role holds.
|
|
13
|
+
*
|
|
14
|
+
* The decision reads `members`/`roles` and cannot run without Atlas, so what is
|
|
15
|
+
* asserted here is the WIRING, off the source: that each of the four asks, that
|
|
16
|
+
* it asks BEFORE it acts, that the strings it asks for exist in the catalogue,
|
|
17
|
+
* and — the part that matters later — that the SIX org-side handlers still ask
|
|
18
|
+
* nothing, because a property manager reaches those holding no console module.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const controller = readFileSync(
|
|
22
|
+
fileURLToPath(
|
|
23
|
+
new URL(
|
|
24
|
+
"../src/controllers/service-provider-invite.controller.ts",
|
|
25
|
+
import.meta.url,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
"utf8",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** The body of one `async function <name>(` in the controller. */
|
|
32
|
+
const handler = (name) => {
|
|
33
|
+
const start = controller.indexOf(`async function ${name}(`);
|
|
34
|
+
assert.notEqual(start, -1, `handler ${name} is gone — rename or removal`);
|
|
35
|
+
const next = controller.indexOf("\n async function ", start + 1);
|
|
36
|
+
return controller.slice(start, next === -1 ? controller.length : next);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const GATED = {
|
|
40
|
+
approvals: ["sp-approvals", "see-all-sp-approvals"],
|
|
41
|
+
pendingApprovalCount: ["sp-approvals", "see-all-sp-approvals"],
|
|
42
|
+
approve: ["sp-approvals", "approve-sp"],
|
|
43
|
+
reject: ["sp-approvals", "reject-sp"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// The org road. `assertOwnerOrSuperAdmin` lets a property manager act on their
|
|
47
|
+
// own organisation's invitation, so a console module here would lock them out.
|
|
48
|
+
const UNGATED = ["list", "cancel", "resend", "remove", "decline", "getOne"];
|
|
49
|
+
|
|
50
|
+
test("the four staff-only handlers ask for their console module", () => {
|
|
51
|
+
for (const [name, [resource, action]] of Object.entries(GATED)) {
|
|
52
|
+
const body = handler(name);
|
|
53
|
+
assert.ok(
|
|
54
|
+
body.includes("requireConsolePermission"),
|
|
55
|
+
`${name} does not ask at all`,
|
|
56
|
+
);
|
|
57
|
+
assert.ok(body.includes(`"${resource}"`), `${name} misses ${resource}`);
|
|
58
|
+
assert.ok(body.includes(`"${action}"`), `${name} misses ${action}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("it asks before it acts", () => {
|
|
63
|
+
for (const name of Object.keys(GATED)) {
|
|
64
|
+
const body = handler(name);
|
|
65
|
+
const asks = body.indexOf("requireConsolePermission");
|
|
66
|
+
const acts = body.indexOf("res.json(");
|
|
67
|
+
assert.ok(asks !== -1 && acts !== -1, `${name} lost a marker`);
|
|
68
|
+
assert.ok(asks < acts, `${name} acts before it asks`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("every string asked for is one the role editor can grant", async () => {
|
|
73
|
+
const { CONSOLE_PERMISSIONS, consoleRoleAllows } = await import(
|
|
74
|
+
"./.build/utils/console-permission.util.mjs"
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
for (const [resource, action] of Object.values(GATED)) {
|
|
78
|
+
assert.ok(
|
|
79
|
+
CONSOLE_PERMISSIONS[resource]?.includes(action),
|
|
80
|
+
`${resource}:${action} is not in the console catalogue — ungrantable`,
|
|
81
|
+
);
|
|
82
|
+
// A governed role holding the string passes; one holding a sibling module
|
|
83
|
+
// does not. This is the control: without it the test would pass on an
|
|
84
|
+
// always-true gate.
|
|
85
|
+
assert.equal(
|
|
86
|
+
consoleRoleAllows([`${resource}:${action}`], resource, action),
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
assert.equal(
|
|
90
|
+
consoleRoleAllows(["promo-codes:create-promo-code"], resource, action),
|
|
91
|
+
false,
|
|
92
|
+
);
|
|
93
|
+
// The empty list and "*" still reach everything — no role document is
|
|
94
|
+
// written for this to ship.
|
|
95
|
+
assert.equal(consoleRoleAllows([], resource, action), true);
|
|
96
|
+
assert.equal(consoleRoleAllows(["*"], resource, action), true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the org-side handlers stay open to a property manager", () => {
|
|
101
|
+
for (const name of UNGATED) {
|
|
102
|
+
assert.ok(
|
|
103
|
+
!handler(name).includes("requireConsolePermission"),
|
|
104
|
+
`${name} now demands a console module — that locks out property managers`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("the console gate is exported for API-core to reuse", () => {
|
|
110
|
+
const index = readFileSync(
|
|
111
|
+
fileURLToPath(new URL("../src/index.ts", import.meta.url)),
|
|
112
|
+
"utf8",
|
|
113
|
+
);
|
|
114
|
+
assert.ok(index.includes("requireConsolePermission,"));
|
|
115
|
+
assert.ok(index.includes('./utils/console-authz.util"'));
|
|
116
|
+
assert.ok(index.includes('export * from "./utils/console-permission.util";'));
|
|
117
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
const { CROSS_CLIENT_GRANT, staffMayBypass } = await import(
|
|
5
|
+
"./.build/utils/console-permission.util.mjs"
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The bypass is what let platform identity alone skip tenant scoping. These
|
|
10
|
+
* pin the ZERO-LOCKOUT property: every spelling a real staff role carries today
|
|
11
|
+
* still bypasses, so nobody loses access on deploy.
|
|
12
|
+
*/
|
|
13
|
+
const staff = (permissions) => ({ isSuperAdmin: true, staffPermissions: permissions });
|
|
14
|
+
|
|
15
|
+
test("the two spellings a live staff role carries today still bypass", () => {
|
|
16
|
+
// `createDefaultUser` seeds `permissions: []`; the live staff account holds ["*"].
|
|
17
|
+
assert.equal(staffMayBypass(staff([])), true, "empty list must mean everything");
|
|
18
|
+
assert.equal(staffMayBypass(staff(["*"])), true, "wildcard must mean everything");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a partial staff role is governed, and the grant is a real catalogue string", () => {
|
|
22
|
+
assert.equal(staffMayBypass(staff(["promo-codes:create-promo-code"])), false);
|
|
23
|
+
assert.equal(staffMayBypass(staff(["organizations:see-all-organizations"])), true);
|
|
24
|
+
assert.equal(staffMayBypass(staff(["organizations:see-organization-details"])), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a non-staff caller never bypasses, however their list reads", () => {
|
|
28
|
+
assert.equal(staffMayBypass({ isSuperAdmin: false, staffPermissions: [] }), false);
|
|
29
|
+
assert.equal(staffMayBypass({ isSuperAdmin: false, staffPermissions: ["*"] }), false);
|
|
30
|
+
assert.equal(staffMayBypass({ isSuperAdmin: false }), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the grant is the organizations module — the control must not pass", () => {
|
|
34
|
+
assert.equal(CROSS_CLIENT_GRANT.resource, "organizations");
|
|
35
|
+
// Control: a resource that is NOT the grant must be refused for a partial
|
|
36
|
+
// role, or this test would pass against any implementation.
|
|
37
|
+
assert.equal(staffMayBypass(staff(["users:see-all-users"])), false);
|
|
38
|
+
});
|