@7365admin1/core 3.57.0 → 3.58.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 +17 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +22 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +22 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/role-template-restore.e2e.test.mjs +369 -0
- package/test/role-scope-separation.test.mjs +149 -0
- package/test/role-scope.test.mjs +15 -3
package/package.json
CHANGED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A platform admin can put a shared client template BACK to allow-all, and can
|
|
3
|
+
* still do nothing else to it.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is proving
|
|
6
|
+
*
|
|
7
|
+
* The guard shipped in core 3.56.0 refuses every write to an org-less
|
|
8
|
+
* `type: "organization"` role — the shared CLIENT TEMPLATES that every client
|
|
9
|
+
* invited with them depends on. It stopped the 2026-09-07 outage recurring and
|
|
10
|
+
* it also stopped it being repaired: production "Org Owner"
|
|
11
|
+
* (`69df7c8034293c971075ab97`) still holds the 34 platform permission strings,
|
|
12
|
+
* 119 members are still locked out, and the guard is what refuses the one write
|
|
13
|
+
* that gives them their modules back.
|
|
14
|
+
*
|
|
15
|
+
* The unit half (`test/role-scope-separation.test.mjs`) proves the predicate.
|
|
16
|
+
* This proves it over real HTTP, through the real routes and the real
|
|
17
|
+
* controller, and — the assertion that matters most — that what LANDS IN THE
|
|
18
|
+
* DATABASE after the restore is `["*"]` and not something else.
|
|
19
|
+
*
|
|
20
|
+
* The template here is seeded holding exactly the 34 strings production holds,
|
|
21
|
+
* so case 2 is today's outage attempted against today's code.
|
|
22
|
+
*
|
|
23
|
+
* Everything is created and thrown away by the harness: an in-process MongoDB
|
|
24
|
+
* replica set, a loopback Redis, a loopback mail sink. No staging or production
|
|
25
|
+
* database, Redis, mailbox or endpoint is touched.
|
|
26
|
+
*
|
|
27
|
+
* Run with: yarn test:e2e
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { after, before, describe, it } from "node:test";
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { ObjectId } from "mongodb";
|
|
33
|
+
|
|
34
|
+
import { startHarness } from "./harness.mjs";
|
|
35
|
+
|
|
36
|
+
const PASSWORD = "RoleTemplateRestore-Passw0rd!";
|
|
37
|
+
const STAFF = "rtr-staff@e2e.example.com"; // Seven365 console
|
|
38
|
+
const CLIENT = "rtr-client@e2e.example.com"; // an ordinary tenant member
|
|
39
|
+
|
|
40
|
+
/** Verbatim `useAdminPermission`: the list the console save wrote. */
|
|
41
|
+
const CONSOLE_SAVE = [
|
|
42
|
+
"organizations:see-all-organizations",
|
|
43
|
+
"organizations:see-organization-details",
|
|
44
|
+
"promo-codes:create-promo-code",
|
|
45
|
+
"promo-codes:see-promo-code-details",
|
|
46
|
+
"promo-codes:edit-promo-code-details",
|
|
47
|
+
"promo-codes:change-promo-code-status",
|
|
48
|
+
"promo-codes:delete-promo-code",
|
|
49
|
+
"users:see-all-users",
|
|
50
|
+
"users:see-user-details",
|
|
51
|
+
"invitations:create-invitation",
|
|
52
|
+
"invitations:view-invitations",
|
|
53
|
+
"invitations:cancel-invitation",
|
|
54
|
+
"subscriptions:see-all-subscriptions",
|
|
55
|
+
"subscriptions:see-subscription-details",
|
|
56
|
+
"subscriptions:manage-subscription",
|
|
57
|
+
"sp-approvals:see-all-sp-approvals",
|
|
58
|
+
"sp-approvals:approve-sp",
|
|
59
|
+
"sp-approvals:reject-sp",
|
|
60
|
+
"sp-approvals:delete-sp-approval",
|
|
61
|
+
"marketplace-vendors:see-all-marketplace-vendors",
|
|
62
|
+
"marketplace-vendors:see-marketplace-vendor-details",
|
|
63
|
+
"platform-terms:see-platform-terms",
|
|
64
|
+
"platform-terms:edit-platform-terms",
|
|
65
|
+
"activity-history:see-activity-history",
|
|
66
|
+
"members:view-members",
|
|
67
|
+
"members:assign-member-role",
|
|
68
|
+
"members:suspend-member",
|
|
69
|
+
"members:activate-member",
|
|
70
|
+
"members:delete-member",
|
|
71
|
+
"roles-and-permissions:add-role",
|
|
72
|
+
"roles-and-permissions:see-all-roles",
|
|
73
|
+
"roles-and-permissions:see-role-details",
|
|
74
|
+
"roles-and-permissions:update-role",
|
|
75
|
+
"roles-and-permissions:delete-role",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const REFUSAL = /shared by every client/;
|
|
79
|
+
|
|
80
|
+
describe("a shared client template can be restored and nothing else", { concurrency: 1 }, () => {
|
|
81
|
+
let h;
|
|
82
|
+
let id;
|
|
83
|
+
let staffSid;
|
|
84
|
+
let clientSid;
|
|
85
|
+
const result = [];
|
|
86
|
+
|
|
87
|
+
const record = (name, fn) =>
|
|
88
|
+
it(name, async () => {
|
|
89
|
+
try {
|
|
90
|
+
await fn();
|
|
91
|
+
result.push(["PASS", name]);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
result.push(["FAIL", name]);
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
before(async () => {
|
|
99
|
+
h = await startHarness();
|
|
100
|
+
id = await seed(h);
|
|
101
|
+
staffSid = await h.login(STAFF, PASSWORD);
|
|
102
|
+
clientSid = await h.login(CLIENT, PASSWORD);
|
|
103
|
+
}, { timeout: 300000 });
|
|
104
|
+
|
|
105
|
+
after(async () => {
|
|
106
|
+
if (h) await h.stop();
|
|
107
|
+
console.log("\n--- role template restore: per-case result ---");
|
|
108
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const permissionsOf = async (roleId) =>
|
|
112
|
+
(await h.db.collection("roles").findOne({ _id: roleId }))?.permissions;
|
|
113
|
+
|
|
114
|
+
const patchPermissions = (roleId, permissions, sid) =>
|
|
115
|
+
h.api("/roles/permissions/id/" + roleId.toString(), {
|
|
116
|
+
method: "PATCH",
|
|
117
|
+
sid,
|
|
118
|
+
body: { permissions },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---- the outage, attempted -----------------------------------------------
|
|
122
|
+
|
|
123
|
+
record("1. the template starts in production's broken state — 34 strings", async () => {
|
|
124
|
+
assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
record("2. THE OUTAGE: a console save of the 34 strings is still refused", async () => {
|
|
128
|
+
const res = await patchPermissions(id.template, CONSOLE_SAVE, staffSid);
|
|
129
|
+
|
|
130
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
131
|
+
assert.match(res.body?.message ?? "", REFUSAL);
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
await permissionsOf(id.template),
|
|
134
|
+
CONSOLE_SAVE,
|
|
135
|
+
"the refused write must not have touched the row",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
record("3. NARROWING: a strict subset of what it holds is refused", async () => {
|
|
140
|
+
const res = await patchPermissions(id.template, CONSOLE_SAVE.slice(0, 10), staffSid);
|
|
141
|
+
|
|
142
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
143
|
+
assert.match(res.body?.message ?? "", REFUSAL);
|
|
144
|
+
assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
record("4. a hand-built superset is refused too — nothing authors a template", async () => {
|
|
148
|
+
const res = await patchPermissions(
|
|
149
|
+
id.template,
|
|
150
|
+
[...CONSOLE_SAVE, "members:view-members-extra"],
|
|
151
|
+
staffSid,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
155
|
+
assert.deepEqual(await permissionsOf(id.template), CONSOLE_SAVE);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---- the repair ----------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
record("5. THE REPAIR: a platform admin sets it to allow-all and it STORES it", async () => {
|
|
161
|
+
const res = await patchPermissions(id.template, ["*"], staffSid);
|
|
162
|
+
|
|
163
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
await permissionsOf(id.template),
|
|
166
|
+
["*"],
|
|
167
|
+
"the whole point: the row must now read allow-all",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
record("6. an EMPTY list is not refused by the template guard — a different rule stops it", async () => {
|
|
172
|
+
/*
|
|
173
|
+
* MEASURED, and it is the reason the repair instruction says `["*"]`.
|
|
174
|
+
*
|
|
175
|
+
* `PATCH /roles/permissions/id/:id` refuses an empty list in the repository
|
|
176
|
+
* (`role.repo.ts:481` — "Permissions cannot be empty."), and has since long
|
|
177
|
+
* before any of this. It is a 400, not the 401 the template guard raises,
|
|
178
|
+
* so the guard did let it through; the storage layer is what says no.
|
|
179
|
+
*
|
|
180
|
+
* Deliberately NOT changed. Loosening it would let an empty list — which
|
|
181
|
+
* the permission model reads as EVERYTHING — be saved on any role by any
|
|
182
|
+
* client's own editor. That is a widening with a blast radius far past this
|
|
183
|
+
* repair, and `["*"]` restores the same thing.
|
|
184
|
+
*/
|
|
185
|
+
const res = await patchPermissions(id.secondTemplate, [], staffSid);
|
|
186
|
+
|
|
187
|
+
assert.equal(res.status, 400, JSON.stringify(res.body));
|
|
188
|
+
assert.match(res.body?.message ?? "", /Permissions cannot be empty/);
|
|
189
|
+
assert.doesNotMatch(
|
|
190
|
+
res.body?.message ?? "",
|
|
191
|
+
REFUSAL,
|
|
192
|
+
"the template guard must NOT be what refused an empty list",
|
|
193
|
+
);
|
|
194
|
+
assert.deepEqual(await permissionsOf(id.secondTemplate), CONSOLE_SAVE);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
record("6b. and where an empty list IS accepted, the guard permits it", async () => {
|
|
198
|
+
// `PATCH /roles/id/:id` stores it (`role.repo.ts:427` keeps a `[]`), so the
|
|
199
|
+
// guard's empty-list arm is exercised end to end and not merely asserted.
|
|
200
|
+
const res = await h.api("/roles/id/" + id.secondTemplate.toString(), {
|
|
201
|
+
method: "PATCH",
|
|
202
|
+
sid: staffSid,
|
|
203
|
+
body: { name: "Org Manager", permissions: [] },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
207
|
+
assert.deepEqual(await permissionsOf(id.secondTemplate), []);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
record("7. PATCH /id/:id — the other write endpoint behaves identically", async () => {
|
|
211
|
+
const refused = await h.api("/roles/id/" + id.thirdTemplate.toString(), {
|
|
212
|
+
method: "PATCH",
|
|
213
|
+
sid: staffSid,
|
|
214
|
+
body: { name: "Renamed Template", permissions: CONSOLE_SAVE },
|
|
215
|
+
});
|
|
216
|
+
assert.equal(refused.status, 401, JSON.stringify(refused.body));
|
|
217
|
+
assert.match(refused.body?.message ?? "", REFUSAL);
|
|
218
|
+
|
|
219
|
+
const restored = await h.api("/roles/id/" + id.thirdTemplate.toString(), {
|
|
220
|
+
method: "PATCH",
|
|
221
|
+
sid: staffSid,
|
|
222
|
+
body: { name: "Renamed Template", permissions: ["*"] },
|
|
223
|
+
});
|
|
224
|
+
assert.equal(restored.status, 200, JSON.stringify(restored.body));
|
|
225
|
+
assert.deepEqual(await permissionsOf(id.thirdTemplate), ["*"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---- what the exception must NOT have opened -----------------------------
|
|
229
|
+
|
|
230
|
+
record("8. a client member cannot use the restore path on a shared template", async () => {
|
|
231
|
+
// Reaching the restore still means passing the console gate. A tenant
|
|
232
|
+
// member holds no staff membership, so this must not be a way in.
|
|
233
|
+
const res = await patchPermissions(id.fourthTemplate, ["*"], clientSid);
|
|
234
|
+
|
|
235
|
+
assert.notEqual(res.status, 200, JSON.stringify(res.body));
|
|
236
|
+
assert.deepEqual(
|
|
237
|
+
await permissionsOf(id.fourthTemplate),
|
|
238
|
+
["members:view-members"],
|
|
239
|
+
"a non-staff caller must not have restored anything",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
record("9. an ORG-SCOPED role is unaffected — it still narrows normally", async () => {
|
|
244
|
+
const res = await patchPermissions(id.clientRole, ["members:view-members"], staffSid);
|
|
245
|
+
|
|
246
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
247
|
+
assert.deepEqual(await permissionsOf(id.clientRole), ["members:view-members"]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
record("10. a client editor still cannot grant a platform resource", async () => {
|
|
251
|
+
const res = await patchPermissions(
|
|
252
|
+
id.clientRole,
|
|
253
|
+
["members:view-members", "organizations:see-all-organizations"],
|
|
254
|
+
staffSid,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
258
|
+
assert.match(res.body?.message ?? "", /Seven365 console permissions/);
|
|
259
|
+
assert.deepEqual(await permissionsOf(id.clientRole), ["members:view-members"]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
record("11. POSITIVE CONTROL: the run really did write, and really did refuse", async () => {
|
|
263
|
+
// If every case above 401'd, cases 5-7 and 9 would be vacuous; if every
|
|
264
|
+
// case 200'd, cases 2-4 and 8 would be. Count both.
|
|
265
|
+
const verdicts = result.map(([v]) => v);
|
|
266
|
+
assert.equal(verdicts.includes("FAIL"), false, JSON.stringify(result));
|
|
267
|
+
|
|
268
|
+
for (const roleId of [id.template, id.thirdTemplate]) {
|
|
269
|
+
assert.deepEqual(await permissionsOf(roleId), ["*"], roleId.toString());
|
|
270
|
+
}
|
|
271
|
+
assert.deepEqual(await permissionsOf(id.secondTemplate), []);
|
|
272
|
+
// and one template that nothing was allowed to move
|
|
273
|
+
assert.deepEqual(await permissionsOf(id.fourthTemplate), ["members:view-members"]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
async function seed(h) {
|
|
278
|
+
const now = new Date().toISOString();
|
|
279
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
280
|
+
|
|
281
|
+
const staffRole = new ObjectId();
|
|
282
|
+
const clientOrg = new ObjectId();
|
|
283
|
+
const clientRole = new ObjectId();
|
|
284
|
+
const template = new ObjectId();
|
|
285
|
+
const secondTemplate = new ObjectId();
|
|
286
|
+
const thirdTemplate = new ObjectId();
|
|
287
|
+
const fourthTemplate = new ObjectId();
|
|
288
|
+
|
|
289
|
+
await h.db.collection("organizations").insertOne({
|
|
290
|
+
_id: clientOrg,
|
|
291
|
+
name: "RTR Client",
|
|
292
|
+
email: "rtr-org@e2e.example.com",
|
|
293
|
+
type: "org",
|
|
294
|
+
nature: "organization",
|
|
295
|
+
status: "active",
|
|
296
|
+
createdAt: now,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await h.db.collection("roles").insertMany([
|
|
300
|
+
// Seven365 staff: `type: "admin"` with `["*"]`, so the console gate passes
|
|
301
|
+
// and the cases measure the TEMPLATE rule, not authorization.
|
|
302
|
+
{ _id: staffRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
|
|
303
|
+
// The client's own role — org-scoped, so the template rule never applies.
|
|
304
|
+
{
|
|
305
|
+
_id: clientRole,
|
|
306
|
+
name: "RTR Client Admin",
|
|
307
|
+
org: clientOrg,
|
|
308
|
+
type: "organization",
|
|
309
|
+
status: "active",
|
|
310
|
+
permissions: ["*"],
|
|
311
|
+
},
|
|
312
|
+
// Four shared templates: org-less, no `org` field at all. The first three
|
|
313
|
+
// hold production's broken list verbatim.
|
|
314
|
+
{
|
|
315
|
+
_id: template,
|
|
316
|
+
name: "Org Owner",
|
|
317
|
+
type: "organization",
|
|
318
|
+
status: "active",
|
|
319
|
+
default: true,
|
|
320
|
+
permissions: CONSOLE_SAVE,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
_id: secondTemplate,
|
|
324
|
+
name: "Org Manager",
|
|
325
|
+
type: "organization",
|
|
326
|
+
status: "active",
|
|
327
|
+
permissions: CONSOLE_SAVE,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
_id: thirdTemplate,
|
|
331
|
+
name: "Agency Owner",
|
|
332
|
+
type: "security_agency",
|
|
333
|
+
status: "active",
|
|
334
|
+
permissions: CONSOLE_SAVE,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
_id: fourthTemplate,
|
|
338
|
+
name: "Org Supervisor",
|
|
339
|
+
type: "organization",
|
|
340
|
+
status: "active",
|
|
341
|
+
permissions: ["members:view-members"],
|
|
342
|
+
},
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
const users = await h.db.collection("users").insertMany([
|
|
346
|
+
{ email: STAFF, password: hashed, name: "RTR Staff", status: "active", createdAt: now },
|
|
347
|
+
{
|
|
348
|
+
email: CLIENT,
|
|
349
|
+
password: hashed,
|
|
350
|
+
name: "RTR Client User",
|
|
351
|
+
status: "active",
|
|
352
|
+
defaultOrg: clientOrg.toString(),
|
|
353
|
+
createdAt: now,
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
await h.db.collection("members").insertMany([
|
|
358
|
+
{ user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" },
|
|
359
|
+
{
|
|
360
|
+
user: users.insertedIds[1],
|
|
361
|
+
org: clientOrg,
|
|
362
|
+
type: "organization",
|
|
363
|
+
role: clientRole,
|
|
364
|
+
status: "active",
|
|
365
|
+
},
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
return { clientOrg, clientRole, template, secondTemplate, thirdTemplate, fourthTemplate };
|
|
369
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import {
|
|
6
6
|
PLATFORM_ONLY_PERMISSIONS,
|
|
7
7
|
platformPermissionsAdded,
|
|
8
|
+
permissionsMeanEverything,
|
|
8
9
|
refuseSharedClientTemplate,
|
|
9
10
|
requireNoPlatformGrant,
|
|
10
11
|
roleScope,
|
|
@@ -193,3 +194,151 @@ test("the lockout check's copy of the platform list cannot drift", () => {
|
|
|
193
194
|
|
|
194
195
|
assert.deepEqual(onDisk, [...PLATFORM_ONLY_PERMISSIONS].sort());
|
|
195
196
|
});
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* ---------------------------------------------------------------------------
|
|
201
|
+
* THE RESTORE PATH
|
|
202
|
+
*
|
|
203
|
+
* 3.56.0 refused every write to a shared template, including the one that
|
|
204
|
+
* repairs the outage. Production "Org Owner" still holds the 34 strings and 119
|
|
205
|
+
* members are still locked out, so a platform admin needs a way BACK to
|
|
206
|
+
* allow-all — and no way to anything else.
|
|
207
|
+
*
|
|
208
|
+
* `BROKEN` is the live production state: the same template, after the save.
|
|
209
|
+
* ---------------------------------------------------------------------------
|
|
210
|
+
*/
|
|
211
|
+
|
|
212
|
+
const BROKEN = { type: "organization", org: "", permissions: CONSOLE_SAVE };
|
|
213
|
+
|
|
214
|
+
test("RESTORE: a platform admin may set a shared template to [\"*\"]", () => {
|
|
215
|
+
assert.doesNotThrow(() => refuseSharedClientTemplate(BROKEN, ["*"]));
|
|
216
|
+
assert.equal(
|
|
217
|
+
refuseSharedClientTemplate(BROKEN, ["*"]),
|
|
218
|
+
true,
|
|
219
|
+
"must report the restore so the controller can record it",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("RESTORE: an empty list is the same restore and is also permitted", () => {
|
|
224
|
+
// `[]` and `["*"]` are the two live spellings of everything.
|
|
225
|
+
assert.equal(refuseSharedClientTemplate(BROKEN, []), true);
|
|
226
|
+
assert.equal(permissionsMeanEverything([]), true);
|
|
227
|
+
assert.equal(permissionsMeanEverything(["*"]), true);
|
|
228
|
+
assert.equal(permissionsMeanEverything(["*", "members:view-members"]), true);
|
|
229
|
+
assert.equal(permissionsMeanEverything(["members:view-members"]), false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("THE OUTAGE STAYS IMPOSSIBLE: the 34 console strings are still refused", () => {
|
|
233
|
+
/*
|
|
234
|
+
* The one case a literal "superset-or-equal" rule would have let through.
|
|
235
|
+
* Production holds exactly this list TODAY, so an equal-set write is the
|
|
236
|
+
* outage being re-saved, not a widening. Refused from both states.
|
|
237
|
+
*/
|
|
238
|
+
assert.throws(
|
|
239
|
+
() => refuseSharedClientTemplate(BROKEN, CONSOLE_SAVE),
|
|
240
|
+
/shared by every client/,
|
|
241
|
+
);
|
|
242
|
+
assert.throws(
|
|
243
|
+
() => refuseSharedClientTemplate(ORG_OWNER, CONSOLE_SAVE),
|
|
244
|
+
/shared by every client/,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("NARROWING: a strict subset of what the template holds is refused", () => {
|
|
249
|
+
assert.throws(
|
|
250
|
+
() => refuseSharedClientTemplate(BROKEN, CONSOLE_SAVE.slice(0, 10)),
|
|
251
|
+
/shared by every client/,
|
|
252
|
+
);
|
|
253
|
+
// and a single-string list, the narrowest write there is
|
|
254
|
+
assert.throws(
|
|
255
|
+
() => refuseSharedClientTemplate(BROKEN, ["members:view-members"]),
|
|
256
|
+
/shared by every client/,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a strict SUPERSET is refused too — nothing may author a template's list", () => {
|
|
261
|
+
// Deliberately tighter than "superset-or-equal": no editor draws the client
|
|
262
|
+
// catalogue for an org-less role, so a hand-built list is a guess.
|
|
263
|
+
assert.throws(
|
|
264
|
+
() =>
|
|
265
|
+
refuseSharedClientTemplate(BROKEN, [
|
|
266
|
+
...CONSOLE_SAVE,
|
|
267
|
+
"members:view-members",
|
|
268
|
+
]),
|
|
269
|
+
/shared by every client/,
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a DELETE presents no list, so the refusal is unchanged", () => {
|
|
274
|
+
// `deleteRole`/`deleteWithReassignments` call the gate with two arguments.
|
|
275
|
+
assert.throws(() => refuseSharedClientTemplate(BROKEN), /shared by every client/);
|
|
276
|
+
assert.throws(
|
|
277
|
+
() => refuseSharedClientTemplate(BROKEN, null),
|
|
278
|
+
/shared by every client/,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("POSITIVE CONTROL: an org-scoped role is untouched in BOTH directions", () => {
|
|
283
|
+
const client = {
|
|
284
|
+
type: "organization",
|
|
285
|
+
org: "6512ab",
|
|
286
|
+
permissions: ["members:view-members", "roles-and-permissions:add-role"],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// widening
|
|
290
|
+
assert.equal(refuseSharedClientTemplate(client, ["*"]), false);
|
|
291
|
+
assert.equal(refuseSharedClientTemplate(client, []), false);
|
|
292
|
+
// narrowing — a client's own editor takes modules away every day
|
|
293
|
+
assert.equal(refuseSharedClientTemplate(client, ["members:view-members"]), false);
|
|
294
|
+
assert.equal(refuseSharedClientTemplate(client, []), false);
|
|
295
|
+
// and a delete
|
|
296
|
+
assert.equal(refuseSharedClientTemplate(client), false);
|
|
297
|
+
|
|
298
|
+
// the platform's own role, same both ways
|
|
299
|
+
const platform = { type: "admin", org: "", permissions: CONSOLE_SAVE };
|
|
300
|
+
assert.equal(refuseSharedClientTemplate(platform, ["*"]), false);
|
|
301
|
+
assert.equal(refuseSharedClientTemplate(platform, CONSOLE_SAVE.slice(0, 3)), false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("POSITIVE CONTROL: the refusal still bites — the exception is not a hole", () => {
|
|
305
|
+
/*
|
|
306
|
+
* If the restore arm were reachable for any list, every assertion above would
|
|
307
|
+
* pass vacuously. Count what is actually refused out of a realistic spread.
|
|
308
|
+
*/
|
|
309
|
+
const writes = [
|
|
310
|
+
CONSOLE_SAVE,
|
|
311
|
+
CONSOLE_SAVE.slice(0, 10),
|
|
312
|
+
["members:view-members"],
|
|
313
|
+
[...CONSOLE_SAVE, "members:view-members"],
|
|
314
|
+
["organizations:see-all-organizations"],
|
|
315
|
+
undefined,
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
let refused = 0;
|
|
319
|
+
for (const next of writes) {
|
|
320
|
+
try {
|
|
321
|
+
refuseSharedClientTemplate(BROKEN, next);
|
|
322
|
+
} catch {
|
|
323
|
+
refused += 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
assert.equal(refused, 6, "every non-restore write must still be refused");
|
|
328
|
+
assert.equal(refuseSharedClientTemplate(BROKEN, ["*"]), true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("a client editor STILL cannot grant a platform resource after all this", () => {
|
|
332
|
+
// The other half of the guard is untouched by the restore path.
|
|
333
|
+
assert.throws(
|
|
334
|
+
() =>
|
|
335
|
+
requireNoPlatformGrant(
|
|
336
|
+
{ type: "organization", org: "6512ab", permissions: [] },
|
|
337
|
+
["organizations:see-all-organizations"],
|
|
338
|
+
),
|
|
339
|
+
/Seven365 console permissions/,
|
|
340
|
+
);
|
|
341
|
+
// and the restore itself carries no platform string, so it passes this gate
|
|
342
|
+
assert.doesNotThrow(() => requireNoPlatformGrant(BROKEN, ["*"]));
|
|
343
|
+
assert.doesNotThrow(() => requireNoPlatformGrant(BROKEN, []));
|
|
344
|
+
});
|
package/test/role-scope.test.mjs
CHANGED
|
@@ -185,14 +185,26 @@ test("the write gate refuses a shared client template before anything else", ()
|
|
|
185
185
|
const helper = controller.slice(
|
|
186
186
|
controller.indexOf("async function requireRoleWrite"),
|
|
187
187
|
);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
188
|
+
// 900, not 500: the gate carries a comment explaining why the restore is
|
|
189
|
+
// reported only after the console check.
|
|
190
|
+
const body = helper.slice(0, 900);
|
|
191
|
+
|
|
192
|
+
// Matched without the closing paren on purpose: the gate now hands the
|
|
193
|
+
// proposed permission list in as a second argument (the restore path), and
|
|
194
|
+
// the property this test exists to hold is the ORDER, not the arity.
|
|
195
|
+
const refuse = body.indexOf("refuseSharedClientTemplate(role");
|
|
191
196
|
const fallthrough = body.indexOf("requireRoleOrg(req");
|
|
192
197
|
|
|
193
198
|
assert.ok(refuse !== -1, "requireRoleWrite must refuse shared templates");
|
|
194
199
|
assert.ok(fallthrough !== -1, "and must still apply the org rule to the rest");
|
|
195
200
|
assert.ok(refuse < fallthrough, "the refusal must come first");
|
|
201
|
+
|
|
202
|
+
// The restore is only reachable AFTER the console gate has run, so a
|
|
203
|
+
// non-staff caller can never take it.
|
|
204
|
+
assert.ok(
|
|
205
|
+
body.indexOf("return isTemplateRestore") > fallthrough,
|
|
206
|
+
"a restore must not be reported before the authorization check",
|
|
207
|
+
);
|
|
196
208
|
});
|
|
197
209
|
|
|
198
210
|
test("the permission-writing paths refuse a platform grant", () => {
|