@7365admin1/core 3.53.2 → 3.53.4
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 +35 -0
- package/dist/index.d.ts +143 -1
- package/dist/index.js +183 -73
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +176 -73
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/console-audit.test.mjs +4 -4
- package/test/console-permission.test.mjs +273 -0
- package/test/e2e/console-permission.e2e.test.mjs +286 -0
- package/test/member-scope.test.mjs +4 -4
- package/test/org-suspension.test.mjs +22 -7
- package/test/resident-ownership-scope.test.mjs +1 -1
- package/test/role-member-org-scope.test.mjs +1 -1
- package/test/role-scope.test.mjs +4 -4
- package/test/sp-approvals-console-gate.test.mjs +117 -0
- package/test/staff-console-authz.test.mjs +25 -25
- package/test/terms-acceptance-list.test.mjs +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.53.
|
|
4
|
+
"version": "3.53.4",
|
|
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 --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 --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
|
},
|
|
@@ -298,10 +298,10 @@ test("suspending and reactivating a client both leave a record", () => {
|
|
|
298
298
|
// The actor is the session the owner guard resolved, never the request body.
|
|
299
299
|
assert.match(
|
|
300
300
|
body,
|
|
301
|
-
/const (\w+) = await requirePlatformOwner\(req
|
|
301
|
+
/const (\w+) = await requirePlatformOwner\(req/,
|
|
302
302
|
"updateStatus no longer takes the actor from the guard",
|
|
303
303
|
);
|
|
304
|
-
const actorVar = body.match(/const (\w+) = await requirePlatformOwner\(req
|
|
304
|
+
const actorVar = body.match(/const (\w+) = await requirePlatformOwner\(req/)[1];
|
|
305
305
|
assert.match(
|
|
306
306
|
body,
|
|
307
307
|
new RegExp(`actor: ${actorVar}`),
|
|
@@ -335,7 +335,7 @@ test("a lost audit row cannot undo a suspension", () => {
|
|
|
335
335
|
// ---------------------------------------------------------------- the read
|
|
336
336
|
|
|
337
337
|
test("the read is staff-gated, paged, and filterable by client and by date", () => {
|
|
338
|
-
assert.match(CONTROLLER, /await
|
|
338
|
+
assert.match(CONTROLLER, /await requireConsolePermission\(req, "activity-history", "see-activity-history"\)/);
|
|
339
339
|
|
|
340
340
|
for (const filter of ["org:", "action:", "from:", "to:", "page:", "limit:"]) {
|
|
341
341
|
assert.match(CONTROLLER, new RegExp(filter), `no ${filter} filter`);
|
|
@@ -343,7 +343,7 @@ test("the read is staff-gated, paged, and filterable by client and by date", ()
|
|
|
343
343
|
|
|
344
344
|
// The gate runs before the read, not after it.
|
|
345
345
|
assert.ok(
|
|
346
|
-
CONTROLLER.
|
|
346
|
+
CONTROLLER.search(/await requireConsolePermission\(req/) <
|
|
347
347
|
CONTROLLER.indexOf("await _list("),
|
|
348
348
|
"the audit trail is read before the caller is judged",
|
|
349
349
|
);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SERVER SIDE OF THE SEVEN365 STAFF CONSOLE GATE.
|
|
3
|
+
*
|
|
4
|
+
* Twelve controllers ask `requirePlatformStaff` before every console write, and
|
|
5
|
+
* until now it read ONE thing: is this session a staff membership. It never read
|
|
6
|
+
* which modules the staff role holds, so a role ticked for Promo Codes alone
|
|
7
|
+
* still reached subscriptions, platform Terms, the client list and every user on
|
|
8
|
+
* the platform. `web-app-org` #186 hides those screens from that role; hiding a
|
|
9
|
+
* screen is drawing, not securing.
|
|
10
|
+
*
|
|
11
|
+
* Two halves are proved here and both matter:
|
|
12
|
+
*
|
|
13
|
+
* - the RULE (`console-permission.util.ts`), executed as the shipped module;
|
|
14
|
+
* - the WIRING -- that all 45 gate call sites across the twelve controllers
|
|
15
|
+
* actually name a module, and that the mapped strings are ones the console's
|
|
16
|
+
* own catalogue can grant. A gate reading a string no role editor can tick is
|
|
17
|
+
* the `visitor:create` defect, and it looks identical to a working gate in a
|
|
18
|
+
* grep.
|
|
19
|
+
*
|
|
20
|
+
* The DECISION itself reads the `members` and `roles` collections and cannot run
|
|
21
|
+
* without a database; it is executed over real HTTP against a throwaway
|
|
22
|
+
* in-memory MongoDB in `test/e2e/console-permission.e2e.test.mjs`.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here opens a socket or a database.
|
|
25
|
+
*/
|
|
26
|
+
import test from "node:test";
|
|
27
|
+
import assert from "node:assert/strict";
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
CONSOLE_PERMISSIONS,
|
|
33
|
+
consoleRoleAllows,
|
|
34
|
+
consoleRoleAllowsAll,
|
|
35
|
+
consoleSpellings,
|
|
36
|
+
} = await import("./.build/utils/console-permission.util.mjs");
|
|
37
|
+
|
|
38
|
+
const read = (rel) =>
|
|
39
|
+
readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
|
|
40
|
+
|
|
41
|
+
// ---- the rule ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
test("an EMPTY permission list reaches everything - the zero-lockout rule", () => {
|
|
44
|
+
// `user.service.ts createDefaultUser()` seeds the platform-staff role with
|
|
45
|
+
// `permissions: []`. If this ever returns false, shipping the gate locks the
|
|
46
|
+
// Seven365 owner out of their own console.
|
|
47
|
+
assert.equal(consoleRoleAllowsAll([]), true);
|
|
48
|
+
for (const resource of Object.keys(CONSOLE_PERMISSIONS)) {
|
|
49
|
+
assert.equal(consoleRoleAllows([], resource), true, resource);
|
|
50
|
+
for (const action of CONSOLE_PERMISSIONS[resource]) {
|
|
51
|
+
assert.equal(
|
|
52
|
+
consoleRoleAllows([], resource, action),
|
|
53
|
+
true,
|
|
54
|
+
`${resource}:${action}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a missing or null list is treated as empty, not as a refusal", () => {
|
|
61
|
+
assert.equal(
|
|
62
|
+
consoleRoleAllows(undefined, "promo-codes", "create-promo-code"),
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
assert.equal(consoleRoleAllows(null, "promo-codes", "create-promo-code"), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("the wildcard reaches everything - the short-circuit staging relies on", () => {
|
|
69
|
+
// Staging's seeded Super Admin holds `["*"]`, not `[]`. Both must allow all,
|
|
70
|
+
// because production's own owner role cannot be read from here.
|
|
71
|
+
assert.equal(consoleRoleAllowsAll(["*"]), true);
|
|
72
|
+
assert.equal(
|
|
73
|
+
consoleRoleAllows(["*"], "activity-history", "see-activity-history"),
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
assert.equal(consoleRoleAllows(["*"], "subscriptions"), true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a one-module role reaches that module and nothing else", () => {
|
|
80
|
+
const held = ["promo-codes:create-promo-code"];
|
|
81
|
+
assert.equal(consoleRoleAllows(held, "promo-codes", "create-promo-code"), true);
|
|
82
|
+
assert.equal(consoleRoleAllows(held, "promo-codes", "delete-promo-code"), false);
|
|
83
|
+
assert.equal(consoleRoleAllows(held, "subscriptions", "manage-subscription"), false);
|
|
84
|
+
assert.equal(
|
|
85
|
+
consoleRoleAllows(held, "activity-history", "see-activity-history"),
|
|
86
|
+
false,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the resource-only question is ANY action on it, exactly as consoleCanSee", () => {
|
|
91
|
+
// Somebody granted only `delete-promo-code` still has to open Promo Codes.
|
|
92
|
+
const held = ["promo-codes:delete-promo-code"];
|
|
93
|
+
assert.equal(consoleRoleAllows(held, "promo-codes"), true);
|
|
94
|
+
assert.equal(consoleRoleAllows(held, "organizations"), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("both shipped spellings of the roles family are accepted - widening, not swapping", () => {
|
|
98
|
+
// `-admin`'s role editor writes `roles-and-permissions:add-role`; eight apps
|
|
99
|
+
// write `roles:add-role`. Picking one would silently un-grant every role
|
|
100
|
+
// holding the other.
|
|
101
|
+
assert.equal(
|
|
102
|
+
consoleRoleAllows(["roles:add-role"], "roles-and-permissions", "add-role"),
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
assert.equal(
|
|
106
|
+
consoleRoleAllows(
|
|
107
|
+
["roles-and-permissions:add-role"],
|
|
108
|
+
"roles-and-permissions",
|
|
109
|
+
"add-role",
|
|
110
|
+
),
|
|
111
|
+
true,
|
|
112
|
+
);
|
|
113
|
+
assert.deepEqual([...consoleSpellings("roles-and-permissions", "add-role")].sort(), [
|
|
114
|
+
"roles-and-permissions:add-role",
|
|
115
|
+
"roles:add-role",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("CONTROL: an unknown resource is refused a governed role, never waved through", () => {
|
|
120
|
+
// If this passes, a typo in a controller silently disables its own gate.
|
|
121
|
+
assert.equal(consoleRoleAllows(["x:y"], "not-a-console-module", "anything"), false);
|
|
122
|
+
assert.equal(
|
|
123
|
+
consoleRoleAllows(["promo-codes:create-promo-code"], "promo-kodes"),
|
|
124
|
+
false,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("CONTROL: an action outside the resource's catalogue is refused", () => {
|
|
129
|
+
assert.equal(
|
|
130
|
+
consoleRoleAllows(
|
|
131
|
+
["organizations:delete-organization"],
|
|
132
|
+
"organizations",
|
|
133
|
+
"delete-organization",
|
|
134
|
+
),
|
|
135
|
+
false,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("CONTROL: holding the string for a DIFFERENT resource does not grant this one", () => {
|
|
140
|
+
assert.equal(consoleRoleAllows(["users:see-all-users"], "members", "view-members"), false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---- the catalogue mirrors the console's own ------------------------------
|
|
144
|
+
|
|
145
|
+
test("the catalogue is the eleven console resources, no more and no less", () => {
|
|
146
|
+
assert.deepEqual(Object.keys(CONSOLE_PERMISSIONS).sort(), [
|
|
147
|
+
"activity-history",
|
|
148
|
+
"invitations",
|
|
149
|
+
"marketplace-vendors",
|
|
150
|
+
"members",
|
|
151
|
+
"organizations",
|
|
152
|
+
"platform-terms",
|
|
153
|
+
"promo-codes",
|
|
154
|
+
"roles-and-permissions",
|
|
155
|
+
"sp-approvals",
|
|
156
|
+
"subscriptions",
|
|
157
|
+
"users",
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---- the wiring -----------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const CONTROLLERS = [
|
|
164
|
+
"console-audit",
|
|
165
|
+
"member",
|
|
166
|
+
"organization-v2",
|
|
167
|
+
"organization",
|
|
168
|
+
"platform-terms",
|
|
169
|
+
"promo-code",
|
|
170
|
+
"role-v2",
|
|
171
|
+
"role",
|
|
172
|
+
"subscription-plan",
|
|
173
|
+
"subscription",
|
|
174
|
+
"user-v2",
|
|
175
|
+
"user",
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const GATE =
|
|
179
|
+
/require(?:ConsolePermission|PlatformStaff|PlatformOwner|SelfOrPlatformStaff|UserFieldWrite)\(req[^\n]*/g;
|
|
180
|
+
|
|
181
|
+
function gateCalls() {
|
|
182
|
+
const calls = [];
|
|
183
|
+
for (const name of CONTROLLERS) {
|
|
184
|
+
const text = read(`controllers/${name}.controller.ts`);
|
|
185
|
+
for (const [call] of text.matchAll(GATE)) calls.push([name, call]);
|
|
186
|
+
}
|
|
187
|
+
return calls;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test("every console gate call site names a module - all 45 of them", () => {
|
|
191
|
+
const calls = gateCalls();
|
|
192
|
+
assert.equal(calls.length, 45, `gate call sites moved: ${calls.length}`);
|
|
193
|
+
|
|
194
|
+
const ungoverned = calls.filter(
|
|
195
|
+
([, call]) =>
|
|
196
|
+
!/requireConsolePermission\(req, "/.test(call) && !/resource: "/.test(call),
|
|
197
|
+
);
|
|
198
|
+
assert.deepEqual(ungoverned, [], "a console gate still asks identity alone");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("every module string a controller names is one the catalogue can grant", () => {
|
|
202
|
+
// The `visitor:create` defect: a server enforcing a string no role editor can
|
|
203
|
+
// tick is a gate nobody can ever satisfy except through `*` or an empty list.
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
for (const [name, call] of gateCalls()) {
|
|
206
|
+
const direct = call.match(
|
|
207
|
+
/requireConsolePermission\(req, "([^"]+)"(?:, "([^"]+)")?/,
|
|
208
|
+
);
|
|
209
|
+
const object = call.match(/resource: "([^"]+)"(?:, action: "([^"]+)")?/);
|
|
210
|
+
const [, resource, action] = direct ?? object ?? [];
|
|
211
|
+
assert.ok(resource, `${name}: ${call}`);
|
|
212
|
+
assert.ok(CONSOLE_PERMISSIONS[resource], `${name}: unknown resource ${resource}`);
|
|
213
|
+
if (action) {
|
|
214
|
+
assert.ok(
|
|
215
|
+
CONSOLE_PERMISSIONS[resource].includes(action),
|
|
216
|
+
`${name}: ${resource} has no action ${action}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
seen.add(resource);
|
|
220
|
+
}
|
|
221
|
+
// Only the resources the twelve controllers actually serve. `sp-approvals`,
|
|
222
|
+
// `marketplace-vendors` and `invitations` live outside this package.
|
|
223
|
+
assert.deepEqual([...seen].sort(), [
|
|
224
|
+
"activity-history",
|
|
225
|
+
"members",
|
|
226
|
+
"organizations",
|
|
227
|
+
"platform-terms",
|
|
228
|
+
"promo-codes",
|
|
229
|
+
"roles-and-permissions",
|
|
230
|
+
"subscriptions",
|
|
231
|
+
"users",
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("the SELF road is left alone - a person editing their own record needs no module", () => {
|
|
236
|
+
const authz = read("utils/console-authz.util.ts");
|
|
237
|
+
const self = authz.slice(
|
|
238
|
+
authz.indexOf("export async function requireSelfOrPlatformStaff("),
|
|
239
|
+
);
|
|
240
|
+
const selfBody = self.slice(0, self.indexOf("\n}\n"));
|
|
241
|
+
const returnSelf = selfBody.indexOf("return id;");
|
|
242
|
+
assert.ok(returnSelf > 0, "the self short-circuit is gone");
|
|
243
|
+
assert.ok(
|
|
244
|
+
!selfBody.slice(0, returnSelf).includes("consoleRoleAllows"),
|
|
245
|
+
"the self road must not consult the console catalogue",
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("the owner tier is still checked BEFORE any module, and cannot be widened by one", () => {
|
|
250
|
+
const authz = read("utils/console-authz.util.ts");
|
|
251
|
+
const owner = authz.slice(
|
|
252
|
+
authz.indexOf("export async function requirePlatformOwner("),
|
|
253
|
+
);
|
|
254
|
+
const body = owner.slice(0, owner.indexOf("\n}\n"));
|
|
255
|
+
assert.ok(
|
|
256
|
+
body.indexOf("isPlatformOwner") < body.indexOf("if (grant)"),
|
|
257
|
+
"owner check must come first",
|
|
258
|
+
);
|
|
259
|
+
assert.ok(body.includes("throw new UnauthorizedError"), "the owner refusal is gone");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("staff identity is still required - the module check is layered, never a swap", () => {
|
|
263
|
+
const authz = read("utils/console-authz.util.ts");
|
|
264
|
+
const staff = authz.slice(
|
|
265
|
+
authz.indexOf("export async function requirePlatformStaff("),
|
|
266
|
+
);
|
|
267
|
+
const body = staff.slice(0, staff.indexOf("\n}\n"));
|
|
268
|
+
assert.ok(body.includes("resolveStaffRole"), "identity is no longer resolved");
|
|
269
|
+
assert.ok(
|
|
270
|
+
body.indexOf("if (!role)") < body.indexOf("consoleRoleAllows"),
|
|
271
|
+
"identity must be decided before the module",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// End-to-end proof that a Seven365 staff role's MODULES are enforced by the
|
|
2
|
+
// server, not only drawn by the console.
|
|
3
|
+
//
|
|
4
|
+
// `requirePlatformStaff` answered one question -- is this session a staff
|
|
5
|
+
// membership -- and twelve controllers asked it before every console write. It
|
|
6
|
+
// never read which modules the staff role holds. So a staff account onboarded
|
|
7
|
+
// onto a role ticked for Promo Codes alone could still mint a subscription,
|
|
8
|
+
// publish platform Terms, read the whole client list and enumerate every user
|
|
9
|
+
// on the platform. `web-app-org` #186 hides those screens from that role;
|
|
10
|
+
// hiding a screen is drawing, not securing, and none of these endpoints is
|
|
11
|
+
// reached only through a screen.
|
|
12
|
+
//
|
|
13
|
+
// THE ZERO-LOCKOUT RULE IS THE THING THAT MAKES THIS SHIPPABLE, so it is
|
|
14
|
+
// asserted first and hardest. `user.service.ts createDefaultUser()` seeds the
|
|
15
|
+
// platform-staff role with `permissions: []`, while staging's seeded role holds
|
|
16
|
+
// `["*"]`. Production's own owner role cannot be read from here, so the rule has
|
|
17
|
+
// to be correct for BOTH: an empty list means everything, and `"*"` means
|
|
18
|
+
// everything. Cases 1-4 are the owner walking the console with each of those two
|
|
19
|
+
// lists and losing nothing.
|
|
20
|
+
//
|
|
21
|
+
// Everything runs through the real Express stack against the harness's
|
|
22
|
+
// in-process MongoDB replica set. No staging or production database is touched
|
|
23
|
+
// and every seeded value is obviously fake.
|
|
24
|
+
//
|
|
25
|
+
// Run with: yarn test:e2e
|
|
26
|
+
|
|
27
|
+
import { after, before, describe, it } from "node:test";
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import { ObjectId } from "mongodb";
|
|
30
|
+
|
|
31
|
+
import { startHarness } from "./harness.mjs";
|
|
32
|
+
|
|
33
|
+
const PASSWORD = "Console-Perm-Passw0rd!";
|
|
34
|
+
const UNGOVERNED = "console-perm-empty@e2e.example.com";
|
|
35
|
+
const WILDCARD = "console-perm-star@e2e.example.com";
|
|
36
|
+
const PROMO_ONLY = "console-perm-promo@e2e.example.com";
|
|
37
|
+
const ROLES_LEGACY = "console-perm-roles@e2e.example.com";
|
|
38
|
+
const TENANT = "console-perm-tenant@e2e.example.com";
|
|
39
|
+
|
|
40
|
+
describe("staff console modules are enforced server-side", { concurrency: 1 }, () => {
|
|
41
|
+
let h;
|
|
42
|
+
let id;
|
|
43
|
+
let ungovernedSid;
|
|
44
|
+
let wildcardSid;
|
|
45
|
+
let promoSid;
|
|
46
|
+
let rolesSid;
|
|
47
|
+
let tenantSid;
|
|
48
|
+
const result = [];
|
|
49
|
+
|
|
50
|
+
const record = (name, fn) =>
|
|
51
|
+
it(name, async () => {
|
|
52
|
+
try {
|
|
53
|
+
await fn();
|
|
54
|
+
result.push(["PASS", name]);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
result.push(["FAIL", name]);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
before(async () => {
|
|
62
|
+
h = await startHarness();
|
|
63
|
+
id = await seed(h);
|
|
64
|
+
ungovernedSid = await h.login(UNGOVERNED, PASSWORD);
|
|
65
|
+
wildcardSid = await h.login(WILDCARD, PASSWORD);
|
|
66
|
+
promoSid = await h.login(PROMO_ONLY, PASSWORD);
|
|
67
|
+
rolesSid = await h.login(ROLES_LEGACY, PASSWORD);
|
|
68
|
+
tenantSid = await h.login(TENANT, PASSWORD);
|
|
69
|
+
}, { timeout: 300000 });
|
|
70
|
+
|
|
71
|
+
after(async () => {
|
|
72
|
+
if (h) await h.stop();
|
|
73
|
+
console.log("\n--- console permission: per-case result ---");
|
|
74
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// `status` is required by the handler's Joi schema, which runs BEFORE the
|
|
78
|
+
// gate -- an invalid query answers 400 for everyone and would hide the 401.
|
|
79
|
+
const promoList = (sid) => h.api("/promo-codes/?status=active", { sid });
|
|
80
|
+
const userList = (sid) => h.api("/users/?status=active", { sid });
|
|
81
|
+
const subscriptionList = (sid) => h.api("/subscriptions/?status=active", { sid });
|
|
82
|
+
const orgList = (sid) => h.api("/organizations/v2/?page=1&limit=10", { sid });
|
|
83
|
+
|
|
84
|
+
const mintPromo = (sid, code) =>
|
|
85
|
+
h.api("/promo-codes/", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
sid,
|
|
88
|
+
body: { code, type: "fixed", fixed_rate: 5, description: "e2e" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const createPlatformRole = (sid, name) =>
|
|
92
|
+
h.api("/roles/", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
sid,
|
|
95
|
+
body: { name, type: "admin", permissions: [] },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---- the zero-lockout rule ---------------------------------------------
|
|
99
|
+
|
|
100
|
+
record("1. EMPTY list: the seeded owner still reaches every console read", async () => {
|
|
101
|
+
// `createDefaultUser()` writes `permissions: []`. If this fails, shipping
|
|
102
|
+
// the gate locks the Seven365 owner out of their own console.
|
|
103
|
+
for (const [label, res] of [
|
|
104
|
+
["promo codes", await promoList(ungovernedSid)],
|
|
105
|
+
["users", await userList(ungovernedSid)],
|
|
106
|
+
["subscriptions", await subscriptionList(ungovernedSid)],
|
|
107
|
+
["organizations", await orgList(ungovernedSid)],
|
|
108
|
+
]) {
|
|
109
|
+
assert.equal(res.status, 200, `${label}: ${JSON.stringify(res.body)}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
record("2. EMPTY list: and every console write", async () => {
|
|
114
|
+
const minted = await mintPromo(ungovernedSid, "E2EEMPTY1");
|
|
115
|
+
assert.equal(minted.status, 201, JSON.stringify(minted.body));
|
|
116
|
+
|
|
117
|
+
const role = await createPlatformRole(ungovernedSid, "E2E Empty Staff Role");
|
|
118
|
+
assert.ok(role.status < 400, JSON.stringify(role.body));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
record('3. "*" reaches everything too -- what staging\'s owner role holds', async () => {
|
|
122
|
+
for (const res of [
|
|
123
|
+
await promoList(wildcardSid),
|
|
124
|
+
await userList(wildcardSid),
|
|
125
|
+
await subscriptionList(wildcardSid),
|
|
126
|
+
]) {
|
|
127
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
128
|
+
}
|
|
129
|
+
const minted = await mintPromo(wildcardSid, "E2ESTAR1");
|
|
130
|
+
assert.equal(minted.status, 201, JSON.stringify(minted.body));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---- a governed role is actually governed --------------------------------
|
|
134
|
+
|
|
135
|
+
record("4. a Promo-Codes-only staff role still mints a promo code", async () => {
|
|
136
|
+
// Proves the refusals below are the module gate, not a broken route.
|
|
137
|
+
const minted = await mintPromo(promoSid, "E2EPROMO1");
|
|
138
|
+
assert.equal(minted.status, 201, JSON.stringify(minted.body));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
record("5. ...and is refused the platform-wide user list", async () => {
|
|
142
|
+
const res = await userList(promoSid);
|
|
143
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
record("6. ...the platform-wide subscription list", async () => {
|
|
147
|
+
const res = await subscriptionList(promoSid);
|
|
148
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
record("7. ...and the client list", async () => {
|
|
152
|
+
const res = await orgList(promoSid);
|
|
153
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
record("8. a refusal discloses nothing about the records behind it", async () => {
|
|
157
|
+
const res = await userList(promoSid);
|
|
158
|
+
const text = JSON.stringify(res.body ?? {});
|
|
159
|
+
for (const secret of [TENANT, UNGOVERNED, id.tenantOrg.toString()]) {
|
|
160
|
+
assert.ok(!text.includes(secret), `the refusal leaked ${secret}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
record("9. the module is per-ACTION, not per-screen", async () => {
|
|
165
|
+
// The role holds `create-promo-code` and nothing else on the resource. The
|
|
166
|
+
// LIST is the resource-only question (any action reaches the screen), so it
|
|
167
|
+
// is allowed; DELETE names an action the role does not hold.
|
|
168
|
+
const list = await promoList(promoSid);
|
|
169
|
+
assert.equal(list.status, 200, JSON.stringify(list.body));
|
|
170
|
+
|
|
171
|
+
const removed = await h.api(`/promo-codes/${id.promo.toString()}`, {
|
|
172
|
+
method: "DELETE",
|
|
173
|
+
sid: promoSid,
|
|
174
|
+
});
|
|
175
|
+
assert.equal(removed.status, 401, JSON.stringify(removed.body));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
record("10. the owner's ungoverned account can still delete it", async () => {
|
|
179
|
+
// Case 9's control: the DELETE route works, so the 401 above is the module.
|
|
180
|
+
const removed = await h.api(`/promo-codes/${id.promo.toString()}`, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
sid: ungovernedSid,
|
|
183
|
+
});
|
|
184
|
+
assert.ok(removed.status < 400, JSON.stringify(removed.body));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---- widening, not swapping ---------------------------------------------
|
|
188
|
+
|
|
189
|
+
record("11. the legacy roles spelling is accepted -- no role migration needed", async () => {
|
|
190
|
+
// This role holds `roles:add-role`. `-admin`'s editor writes
|
|
191
|
+
// `roles-and-permissions:add-role`. Picking one spelling would silently
|
|
192
|
+
// un-grant every role holding the other.
|
|
193
|
+
const role = await createPlatformRole(rolesSid, "E2E Legacy Spelling Role");
|
|
194
|
+
assert.ok(role.status < 400, JSON.stringify(role.body));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
record("12. ...and that role reaches nothing else on the console", async () => {
|
|
198
|
+
const res = await promoList(rolesSid);
|
|
199
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ---- staff identity is unchanged ----------------------------------------
|
|
203
|
+
|
|
204
|
+
record("13. a tenant admin is still refused the console entirely", async () => {
|
|
205
|
+
// The module check is layered ON TOP of the staff identity test, never in
|
|
206
|
+
// place of it. Nobody who was refused before is admitted now.
|
|
207
|
+
for (const res of [
|
|
208
|
+
await promoList(tenantSid),
|
|
209
|
+
await userList(tenantSid),
|
|
210
|
+
await subscriptionList(tenantSid),
|
|
211
|
+
await mintPromo(tenantSid, "E2ETENANT1"),
|
|
212
|
+
]) {
|
|
213
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
record("14. no session at all is still refused", async () => {
|
|
218
|
+
const res = await promoList();
|
|
219
|
+
assert.equal(res.status, 401, JSON.stringify(res.status));
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
async function seed(h) {
|
|
224
|
+
const now = new Date().toISOString();
|
|
225
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
226
|
+
|
|
227
|
+
const tenantOrg = new ObjectId();
|
|
228
|
+
const tenantRole = new ObjectId();
|
|
229
|
+
const emptyRole = new ObjectId();
|
|
230
|
+
const starRole = new ObjectId();
|
|
231
|
+
const promoRole = new ObjectId();
|
|
232
|
+
const rolesRole = new ObjectId();
|
|
233
|
+
const promo = new ObjectId();
|
|
234
|
+
|
|
235
|
+
await h.db.collection("organizations").insertOne({
|
|
236
|
+
_id: tenantOrg,
|
|
237
|
+
name: "Console Perm Tenant",
|
|
238
|
+
email: "console-perm-tenant-org@e2e.example.com",
|
|
239
|
+
type: "property-management",
|
|
240
|
+
status: "active",
|
|
241
|
+
createdAt: now,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await h.db.collection("roles").insertMany([
|
|
245
|
+
// Staff is a MEMBERSHIP of type "admin" whose role is ALSO type "admin".
|
|
246
|
+
// A role merely NAMED "Super Admin" is not staff -- see `super-admin.util.ts`.
|
|
247
|
+
{ _id: emptyRole, name: "Super Admin", type: "admin", default: true, status: "active", permissions: [] },
|
|
248
|
+
{ _id: starRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
|
|
249
|
+
{ _id: promoRole, name: "Staff Promo Only", type: "admin", status: "active", permissions: ["promo-codes:create-promo-code"] },
|
|
250
|
+
// The MINORITY spelling. Eight apps write this; `-admin` writes
|
|
251
|
+
// `roles-and-permissions:add-role`. Both must pass.
|
|
252
|
+
{ _id: rolesRole, name: "Staff Roles Legacy", type: "admin", status: "active", permissions: ["roles:add-role"] },
|
|
253
|
+
{ _id: tenantRole, name: "Organisation Admin", org: tenantOrg, status: "active", permissions: ["*"] },
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
const users = await h.db.collection("users").insertMany([
|
|
257
|
+
{ email: UNGOVERNED, password: hashed, name: "Ungoverned Staff", status: "active", createdAt: now },
|
|
258
|
+
{ email: WILDCARD, password: hashed, name: "Wildcard Staff", status: "active", createdAt: now },
|
|
259
|
+
{ email: PROMO_ONLY, password: hashed, name: "Promo Staff", status: "active", createdAt: now },
|
|
260
|
+
{ email: ROLES_LEGACY, password: hashed, name: "Roles Staff", status: "active", createdAt: now },
|
|
261
|
+
{ email: TENANT, password: hashed, name: "Tenant Admin", status: "active", defaultOrg: tenantOrg.toString(), createdAt: now },
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const [empty, star, promoUser, rolesUser, tenant] = [0, 1, 2, 3, 4].map(
|
|
265
|
+
(i) => users.insertedIds[i],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await h.db.collection("members").insertMany([
|
|
269
|
+
{ user: empty, type: "admin", role: emptyRole, status: "active" },
|
|
270
|
+
{ user: star, type: "admin", role: starRole, status: "active" },
|
|
271
|
+
{ user: promoUser, type: "admin", role: promoRole, status: "active" },
|
|
272
|
+
{ user: rolesUser, type: "admin", role: rolesRole, status: "active" },
|
|
273
|
+
{ user: tenant, org: tenantOrg, role: tenantRole, type: "organization", status: "active" },
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
await h.db.collection("promo-codes").insertOne({
|
|
277
|
+
_id: promo,
|
|
278
|
+
code: "E2ESEEDED1",
|
|
279
|
+
type: "fixed",
|
|
280
|
+
fixed_rate: 5,
|
|
281
|
+
status: "active",
|
|
282
|
+
createdAt: now,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return { tenantOrg, promo };
|
|
286
|
+
}
|
|
@@ -47,7 +47,7 @@ test("the type=admin bypass is closed, and closed to staff only", () => {
|
|
|
47
47
|
|
|
48
48
|
assert.match(
|
|
49
49
|
body,
|
|
50
|
-
/if \(type === "admin"\)[\s\S]{0,120}await
|
|
50
|
+
/if \(type === "admin"\)[\s\S]{0,120}await requireConsolePermission\(req/,
|
|
51
51
|
"type=admin must be staff-only, not merely allowed through",
|
|
52
52
|
);
|
|
53
53
|
});
|
|
@@ -68,14 +68,14 @@ test("asking about your own memberships stays open; about somebody else's does n
|
|
|
68
68
|
const body = fn(controller, "getAll");
|
|
69
69
|
assert.match(
|
|
70
70
|
body,
|
|
71
|
-
/else if \(user && user !== callerId\(req\)\)[\s\S]{0,600}await
|
|
71
|
+
/else if \(user && user !== callerId\(req\)\)[\s\S]{0,600}await requireConsolePermission\(req/,
|
|
72
72
|
);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
test("the scope decision happens before the query runs", () => {
|
|
76
76
|
const body = fn(controller, "getAll");
|
|
77
77
|
const gate = Math.min(
|
|
78
|
-
...["
|
|
78
|
+
...["requireConsolePermission", "requireOrgAccess"]
|
|
79
79
|
.map((s) => body.indexOf(s))
|
|
80
80
|
.filter((i) => i !== -1),
|
|
81
81
|
);
|
|
@@ -107,6 +107,6 @@ test("a membership with no organisation is platform-staff only", () => {
|
|
|
107
107
|
// read as protection while passing everyone — and it would pass them on
|
|
108
108
|
// precisely the most privileged row in the collection.
|
|
109
109
|
const helper = controller.slice(controller.indexOf("async function requireMemberOrg"));
|
|
110
|
-
assert.match(helper.slice(0, 500), /if \(!orgId\)[\s\S]{0,120}
|
|
110
|
+
assert.match(helper.slice(0, 500), /if \(!orgId\)[\s\S]{0,120}requireConsolePermission\(req/);
|
|
111
111
|
assert.match(helper.slice(0, 500), /requireOrgAccess\(req, orgId\)/);
|
|
112
112
|
});
|