@7365admin1/core 3.55.0 → 3.56.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/CHANGELOG.md +37 -0
- package/dist/index.js +4418 -4361
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -73
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/onboarding-org-save-diagnosis.e2e.test.mjs +76 -3
- package/test/role-scope-separation.test.mjs +195 -0
- package/test/role-scope.test.mjs +55 -3
- package/tools/role-scope-lockout-check/check.js +316 -0
- package/tools/role-scope-lockout-check/platform-only-permissions.json +21 -0
- package/tools/role-scope-lockout-check/srv.js +39 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.56.0",
|
|
5
5
|
"author": "7365admin1",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
|
|
14
14
|
"release": "yarn run build && changeset publish",
|
|
15
15
|
"lint": "tsc",
|
|
16
|
-
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
16
|
+
"test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
|
|
17
17
|
"test:camera": "yarn test",
|
|
18
18
|
"test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
|
|
19
19
|
},
|
|
@@ -97,9 +97,11 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
|
|
|
97
97
|
ownerSid = await h.login(OWNER, PASSWORD);
|
|
98
98
|
|
|
99
99
|
assert.equal(
|
|
100
|
-
await h.db
|
|
100
|
+
await h.db
|
|
101
|
+
.collection("members")
|
|
102
|
+
.countDocuments({ user: id.owner, org: id.ownOrg }),
|
|
101
103
|
0,
|
|
102
|
-
"the owner must hold no membership — reach is by `createdBy` (DV-0248)",
|
|
104
|
+
"the owner must hold no membership of THIS organisation — reach is by `createdBy` (DV-0248)",
|
|
103
105
|
);
|
|
104
106
|
assert.equal((await ownOrg()).name, "Ben Org");
|
|
105
107
|
});
|
|
@@ -160,6 +162,49 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
|
|
|
160
162
|
assert.equal(saved.contact, "+63 9569346664");
|
|
161
163
|
});
|
|
162
164
|
|
|
165
|
+
// ---- the second control: the legacy document shape is NOT the cause -------
|
|
166
|
+
|
|
167
|
+
record("5. control — the legacy production document shape saves unchanged", async () => {
|
|
168
|
+
// The 16 rows a lead read out of the production `organizations` collection
|
|
169
|
+
// carry the LEGACY `organization-api` field set — `contactNumber`, `sites`,
|
|
170
|
+
// `logo`, `createdBySpId` — and `createdAt`, `updatedAt` and `createdBy`
|
|
171
|
+
// are EMPTY STRINGS, not dates and not ObjectIds. The obvious suspicion is
|
|
172
|
+
// that saving over a row like that throws on a type: `createdAt: ""` where
|
|
173
|
+
// a Date is expected, `createdBy: ""` where an ObjectId is.
|
|
174
|
+
//
|
|
175
|
+
// It cannot. This repository talks to the raw `mongodb` driver — there is
|
|
176
|
+
// no Mongoose, no model layer and no cast step between `update()` and the
|
|
177
|
+
// server. `$set` names `name`, `email`, `contact` and `updatedAt` and
|
|
178
|
+
// nothing else, and MongoDB replaces a field wholesale whatever BSON type
|
|
179
|
+
// it held before. The legacy fields are never read, never re-validated and
|
|
180
|
+
// never rewritten.
|
|
181
|
+
//
|
|
182
|
+
// This case is the proof: same modal body, same route, a row in exactly
|
|
183
|
+
// that shape, no unique index on it — 200, and the legacy fields survive
|
|
184
|
+
// untouched.
|
|
185
|
+
const res = await h.api(`/organizations/${id.legacyOrg}`, {
|
|
186
|
+
method: "PUT",
|
|
187
|
+
sid: ownerSid,
|
|
188
|
+
body: modalBody("Legacy Shaped Org Renamed"),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
192
|
+
|
|
193
|
+
const saved = await h.db
|
|
194
|
+
.collection("organizations")
|
|
195
|
+
.findOne({ _id: id.legacyOrg });
|
|
196
|
+
|
|
197
|
+
assert.equal(saved.name, "Legacy Shaped Org Renamed");
|
|
198
|
+
assert.equal(saved.contact, "+63 9569346664");
|
|
199
|
+
assert.ok(saved.updatedAt instanceof Date, "updatedAt is replaced by a Date");
|
|
200
|
+
// untouched, still exactly as the legacy service left them
|
|
201
|
+
assert.equal(saved.createdAt, "");
|
|
202
|
+
assert.equal(saved.createdBy, "");
|
|
203
|
+
assert.equal(saved.contactNumber, "+65 61234567");
|
|
204
|
+
assert.deepEqual(saved.sites, []);
|
|
205
|
+
assert.equal(saved.createdBySpId, "");
|
|
206
|
+
});
|
|
207
|
+
|
|
163
208
|
record("4. and the guard is unchanged — no session is still refused", async () => {
|
|
164
209
|
const res = await h.api(`/organizations/${id.ownOrg}`, {
|
|
165
210
|
method: "PUT",
|
|
@@ -175,6 +220,7 @@ async function seed(h) {
|
|
|
175
220
|
const now = new Date().toISOString();
|
|
176
221
|
const otherOrg = new ObjectId();
|
|
177
222
|
const ownOrg = new ObjectId();
|
|
223
|
+
const legacyOrg = new ObjectId();
|
|
178
224
|
|
|
179
225
|
const orgs = h.db.collection("organizations");
|
|
180
226
|
|
|
@@ -202,6 +248,24 @@ async function seed(h) {
|
|
|
202
248
|
contact: "+65 00000000",
|
|
203
249
|
createdAt: now,
|
|
204
250
|
},
|
|
251
|
+
// A row in the exact shape a lead read out of production: the legacy
|
|
252
|
+
// `organization-api` field set, with the three timestamps/ids as EMPTY
|
|
253
|
+
// STRINGS. Deliberately NOT covered by the unique index above — its name is
|
|
254
|
+
// its own — so case 5 measures the document shape and nothing else.
|
|
255
|
+
{
|
|
256
|
+
_id: legacyOrg,
|
|
257
|
+
name: "Legacy Shaped Org",
|
|
258
|
+
email: "os-legacy@e2e.example.com",
|
|
259
|
+
contactNumber: "+65 61234567",
|
|
260
|
+
description: "",
|
|
261
|
+
sites: [],
|
|
262
|
+
status: "active",
|
|
263
|
+
logo: [],
|
|
264
|
+
createdBySpId: "",
|
|
265
|
+
createdAt: "",
|
|
266
|
+
updatedAt: "",
|
|
267
|
+
createdBy: "",
|
|
268
|
+
},
|
|
205
269
|
]);
|
|
206
270
|
|
|
207
271
|
const users = await h.db.collection("users").insertMany([
|
|
@@ -216,5 +280,14 @@ async function seed(h) {
|
|
|
216
280
|
// holds at step 1.
|
|
217
281
|
await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
|
|
218
282
|
|
|
219
|
-
|
|
283
|
+
// The legacy row's `createdBy` is "" — that road cannot reach it, so the
|
|
284
|
+
// owner reaches it the ordinary way, by membership.
|
|
285
|
+
await h.db.collection("members").insertOne({
|
|
286
|
+
user: owner,
|
|
287
|
+
org: legacyOrg,
|
|
288
|
+
type: "organization",
|
|
289
|
+
status: "active",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return { owner, ownOrg, otherOrg, legacyOrg };
|
|
220
293
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PLATFORM_ONLY_PERMISSIONS,
|
|
7
|
+
platformPermissionsAdded,
|
|
8
|
+
refuseSharedClientTemplate,
|
|
9
|
+
requireNoPlatformGrant,
|
|
10
|
+
roleScope,
|
|
11
|
+
} from "./.build/utils/role-scope.util.mjs";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* The 2026-09-07 outage, reproduced as data.
|
|
15
|
+
*
|
|
16
|
+
* "Org Owner": `type: "organization"`, `org: ""`, 117 live members, 321
|
|
17
|
+
* invitations. The console editor pre-ticks its own catalogue for a
|
|
18
|
+
* `default: true` role and saves it -- 34 platform strings, no client module.
|
|
19
|
+
*/
|
|
20
|
+
const ORG_OWNER = { type: "organization", org: "", permissions: ["*"] };
|
|
21
|
+
|
|
22
|
+
/** Exactly what `useAdminPermission` expands to: 24 + members 5 + roles 5. */
|
|
23
|
+
const CONSOLE_SAVE = [
|
|
24
|
+
"organizations:see-all-organizations",
|
|
25
|
+
"organizations:see-organization-details",
|
|
26
|
+
"promo-codes:create-promo-code",
|
|
27
|
+
"promo-codes:see-promo-code-details",
|
|
28
|
+
"promo-codes:edit-promo-code-details",
|
|
29
|
+
"promo-codes:change-promo-code-status",
|
|
30
|
+
"promo-codes:delete-promo-code",
|
|
31
|
+
"users:see-all-users",
|
|
32
|
+
"users:see-user-details",
|
|
33
|
+
"invitations:create-invitation",
|
|
34
|
+
"invitations:view-invitations",
|
|
35
|
+
"invitations:cancel-invitation",
|
|
36
|
+
"subscriptions:see-all-subscriptions",
|
|
37
|
+
"subscriptions:see-subscription-details",
|
|
38
|
+
"subscriptions:manage-subscription",
|
|
39
|
+
"sp-approvals:see-all-sp-approvals",
|
|
40
|
+
"sp-approvals:approve-sp",
|
|
41
|
+
"sp-approvals:reject-sp",
|
|
42
|
+
"sp-approvals:delete-sp-approval",
|
|
43
|
+
"marketplace-vendors:see-all-marketplace-vendors",
|
|
44
|
+
"marketplace-vendors:see-marketplace-vendor-details",
|
|
45
|
+
"platform-terms:see-platform-terms",
|
|
46
|
+
"platform-terms:edit-platform-terms",
|
|
47
|
+
"activity-history:see-activity-history",
|
|
48
|
+
"members:view-members",
|
|
49
|
+
"members:assign-member-role",
|
|
50
|
+
"members:suspend-member",
|
|
51
|
+
"members:activate-member",
|
|
52
|
+
"members:delete-member",
|
|
53
|
+
"roles-and-permissions:add-role",
|
|
54
|
+
"roles-and-permissions:see-all-roles",
|
|
55
|
+
"roles-and-permissions:see-role-details",
|
|
56
|
+
"roles-and-permissions:update-role",
|
|
57
|
+
"roles-and-permissions:delete-role",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
test("the save that caused the outage is 34 strings", () => {
|
|
61
|
+
assert.equal(CONSOLE_SAVE.length, 34);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a role's scope is read off type and org, and nothing else", () => {
|
|
65
|
+
assert.equal(roleScope({ type: "admin", org: "" }), "platform");
|
|
66
|
+
assert.equal(roleScope({ type: "admin", org: "6512ab" }), "platform");
|
|
67
|
+
assert.equal(roleScope(ORG_OWNER), "client-template");
|
|
68
|
+
assert.equal(roleScope({ type: "security_agency", org: "" }), "client-template");
|
|
69
|
+
assert.equal(roleScope({ type: "organization", org: "6512ab" }), "client");
|
|
70
|
+
assert.equal(roleScope({ type: "", org: "6512ab" }), "client");
|
|
71
|
+
// ObjectId, not a string
|
|
72
|
+
assert.equal(
|
|
73
|
+
roleScope({ type: "organization", org: { toString: () => "6512ab" } }),
|
|
74
|
+
"client",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("THE OUTAGE: a console save cannot strip a shared template", () => {
|
|
79
|
+
assert.throws(
|
|
80
|
+
() => refuseSharedClientTemplate(ORG_OWNER),
|
|
81
|
+
/shared by every client/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("POSITIVE CONTROL: the writes that must still work are not refused", () => {
|
|
86
|
+
// a real platform staff role -- the console's own
|
|
87
|
+
assert.doesNotThrow(() =>
|
|
88
|
+
refuseSharedClientTemplate({ type: "admin", org: "", permissions: [] }),
|
|
89
|
+
);
|
|
90
|
+
// a client's own role, in its own org
|
|
91
|
+
assert.doesNotThrow(() =>
|
|
92
|
+
refuseSharedClientTemplate({ type: "organization", org: "6512ab" }),
|
|
93
|
+
);
|
|
94
|
+
assert.doesNotThrow(() =>
|
|
95
|
+
refuseSharedClientTemplate({ type: "security_agency", org: "6512ab" }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a client editor cannot grant a platform resource", () => {
|
|
100
|
+
assert.throws(
|
|
101
|
+
() =>
|
|
102
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, [
|
|
103
|
+
"members:view-members",
|
|
104
|
+
"organizations:see-all-organizations",
|
|
105
|
+
]),
|
|
106
|
+
/Seven365 console permissions/,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the console CAN still grant them on a platform role", () => {
|
|
111
|
+
assert.doesNotThrow(() =>
|
|
112
|
+
requireNoPlatformGrant({ type: "admin", org: "", permissions: [] }, CONSOLE_SAVE),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("NEVER refuses a save that works today: held strings pass through", () => {
|
|
117
|
+
// 114 org-owned roles hold a `subscriptions:*` string. Re-saving one is not
|
|
118
|
+
// an addition, so it is allowed even though the resource name collides.
|
|
119
|
+
const held = ["subscriptions:see-all-subscriptions", "members:view-members"];
|
|
120
|
+
assert.doesNotThrow(() =>
|
|
121
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: held }, held),
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(platformPermissionsAdded(held, held), []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the ORG spelling of subscriptions is not a platform grant", () => {
|
|
127
|
+
// `useOrgPermission` spells them differently. Refusing the bare resource
|
|
128
|
+
// would have locked 114 roles out of their own editor.
|
|
129
|
+
const org = [
|
|
130
|
+
"subscriptions:management-subscription",
|
|
131
|
+
"subscriptions:view-subscription-details",
|
|
132
|
+
];
|
|
133
|
+
assert.deepEqual(platformPermissionsAdded(org, []), []);
|
|
134
|
+
assert.doesNotThrow(() =>
|
|
135
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab", permissions: [] }, org),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the four shared resource names are deliberately NOT refused", () => {
|
|
140
|
+
for (const permission of [
|
|
141
|
+
"users:see-all-users",
|
|
142
|
+
"invitations:view-invitations",
|
|
143
|
+
"members:view-members",
|
|
144
|
+
"roles-and-permissions:add-role",
|
|
145
|
+
"roles:add-role",
|
|
146
|
+
]) {
|
|
147
|
+
assert.equal(
|
|
148
|
+
PLATFORM_ONLY_PERMISSIONS.has(permission),
|
|
149
|
+
false,
|
|
150
|
+
permission + " must stay grantable on a client role",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("POSITIVE CONTROL: the refusal set is not empty and does bite", () => {
|
|
156
|
+
// 19, not the console catalogue's 34: `users` (2), `invitations` (3),
|
|
157
|
+
// `members` (5) and `roles-and-permissions` (5) are shared resource names and
|
|
158
|
+
// are deliberately left grantable on a client role.
|
|
159
|
+
assert.equal(PLATFORM_ONLY_PERMISSIONS.size, 19);
|
|
160
|
+
const added = platformPermissionsAdded(CONSOLE_SAVE, []);
|
|
161
|
+
assert.equal(added.length, 19);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("an empty or absent permission list is never a platform grant", () => {
|
|
165
|
+
assert.doesNotThrow(() =>
|
|
166
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, []),
|
|
167
|
+
);
|
|
168
|
+
assert.doesNotThrow(() =>
|
|
169
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, null),
|
|
170
|
+
);
|
|
171
|
+
assert.doesNotThrow(() =>
|
|
172
|
+
requireNoPlatformGrant({ type: "organization", org: "6512ab" }, ["*"]),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the lockout check's copy of the platform list cannot drift", () => {
|
|
177
|
+
/*
|
|
178
|
+
* `tools/role-scope-lockout-check/check.js` is plain CommonJS run against a
|
|
179
|
+
* live database, so it cannot import the TypeScript guard. It reads a JSON
|
|
180
|
+
* copy instead. Two copies of a permission catalogue is what drifted last
|
|
181
|
+
* time (`useAdminPermission` was 107 lines apart between two apps), so the
|
|
182
|
+
* copy is asserted here rather than trusted.
|
|
183
|
+
*/
|
|
184
|
+
const onDisk = JSON.parse(
|
|
185
|
+
readFileSync(
|
|
186
|
+
new URL(
|
|
187
|
+
"../tools/role-scope-lockout-check/platform-only-permissions.json",
|
|
188
|
+
import.meta.url,
|
|
189
|
+
),
|
|
190
|
+
"utf8",
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(onDisk, [...PLATFORM_ONLY_PERMISSIONS].sort());
|
|
195
|
+
});
|
package/test/role-scope.test.mjs
CHANGED
|
@@ -112,6 +112,21 @@ test("/api/roles/v2 is gated too — the same hole with /v2 on the URL", () => {
|
|
|
112
112
|
);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The four true WRITES now go through `requireRoleWrite`, which is
|
|
117
|
+
* `requireRoleOrg` plus one refusal: a role with no `org` and a non-admin type
|
|
118
|
+
* is a template every client shares, and the console must not rewrite it. That
|
|
119
|
+
* is the 2026-09-07 outage. `getDeletionPreview` is a read and deliberately
|
|
120
|
+
* stays on `requireRoleOrg` — see `role-scope-separation.test.mjs`.
|
|
121
|
+
*/
|
|
122
|
+
const WRITE_GATE = {
|
|
123
|
+
updateRole: /await requireRoleWrite\(req, existing/,
|
|
124
|
+
updatePermissionsById: /await requireRoleWrite\(req, existing/,
|
|
125
|
+
deleteRole: /await requireRoleWrite\(req, existing/,
|
|
126
|
+
deleteWithReassignments: /await requireRoleWrite\(req, existing/,
|
|
127
|
+
getDeletionPreview: /await requireRoleOrg\(req, existing\.org\)/,
|
|
128
|
+
};
|
|
129
|
+
|
|
115
130
|
test("every role write scopes on the STORED role's organisation", () => {
|
|
116
131
|
for (const name of MUST_SCOPE) {
|
|
117
132
|
const body = fn(controller, name);
|
|
@@ -123,14 +138,14 @@ test("every role write scopes on the STORED role's organisation", () => {
|
|
|
123
138
|
);
|
|
124
139
|
assert.match(
|
|
125
140
|
body,
|
|
126
|
-
|
|
141
|
+
WRITE_GATE[name],
|
|
127
142
|
`${name} must scope on the stored role's org`,
|
|
128
143
|
);
|
|
129
144
|
|
|
130
145
|
// and it must not take the organisation from the caller instead
|
|
131
146
|
assert.doesNotMatch(
|
|
132
147
|
body,
|
|
133
|
-
/
|
|
148
|
+
/requireRole(Org|Write)\(req, (req\.body|req\.query|req\.params)/,
|
|
134
149
|
`${name} must not scope on an org the caller supplied`,
|
|
135
150
|
);
|
|
136
151
|
}
|
|
@@ -147,7 +162,7 @@ test("the gate runs before the write, not after it", () => {
|
|
|
147
162
|
|
|
148
163
|
for (const [name, call] of Object.entries(writes)) {
|
|
149
164
|
const body = fn(controller, name);
|
|
150
|
-
const gate = body.
|
|
165
|
+
const gate = body.search(/requireRole(Org|Write)\(req/);
|
|
151
166
|
const write = body.indexOf(call);
|
|
152
167
|
assert.ok(gate !== -1 && write !== -1 && gate < write,
|
|
153
168
|
`${name}: the gate must run before ${call}`);
|
|
@@ -163,6 +178,43 @@ test("an org-less role is platform-staff only", () => {
|
|
|
163
178
|
assert.match(helper.slice(0, 500), /requireOrgAccess\(req, orgId\)/);
|
|
164
179
|
});
|
|
165
180
|
|
|
181
|
+
test("the write gate refuses a shared client template before anything else", () => {
|
|
182
|
+
// The whole of the 2026-09-07 fix. `requireRoleWrite` must refuse a template
|
|
183
|
+
// FIRST; if it fell through to `requireRoleOrg` an org-less role would reach
|
|
184
|
+
// the staff console gate again, which is what let the console rewrite it.
|
|
185
|
+
const helper = controller.slice(
|
|
186
|
+
controller.indexOf("async function requireRoleWrite"),
|
|
187
|
+
);
|
|
188
|
+
const body = helper.slice(0, 500);
|
|
189
|
+
|
|
190
|
+
const refuse = body.indexOf("refuseSharedClientTemplate(role)");
|
|
191
|
+
const fallthrough = body.indexOf("requireRoleOrg(req");
|
|
192
|
+
|
|
193
|
+
assert.ok(refuse !== -1, "requireRoleWrite must refuse shared templates");
|
|
194
|
+
assert.ok(fallthrough !== -1, "and must still apply the org rule to the rest");
|
|
195
|
+
assert.ok(refuse < fallthrough, "the refusal must come first");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("the permission-writing paths refuse a platform grant", () => {
|
|
199
|
+
for (const name of ["createRole", "updateRole", "updatePermissionsById"]) {
|
|
200
|
+
assert.match(
|
|
201
|
+
fn(controller, name),
|
|
202
|
+
/requireNoPlatformGrant\(/,
|
|
203
|
+
`${name} must refuse console permissions on a client role`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("READS are NOT routed through the write gate", () => {
|
|
209
|
+
// 117 live members read the org-less "Org Owner" on every session. Refusing
|
|
210
|
+
// them is the same outage from the other direction.
|
|
211
|
+
const helper = controller.slice(
|
|
212
|
+
controller.indexOf("async function requireRoleRead"),
|
|
213
|
+
);
|
|
214
|
+
assert.doesNotMatch(helper.slice(0, 400), /requireRoleWrite|refuseSharedClientTemplate/);
|
|
215
|
+
assert.match(helper.slice(0, 400), /holdsRole\(callerId\(req\), roleId\)/);
|
|
216
|
+
});
|
|
217
|
+
|
|
166
218
|
test("no new handler arrives on this controller without a decision", () => {
|
|
167
219
|
const known = new Set([...MUST_SCOPE, ...OPEN_BY_DESIGN]);
|
|
168
220
|
const unknown = exported(controller).filter((name) => !known.has(name));
|