@7365admin1/core 3.53.4 → 3.53.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.53.4",
4
+ "version": "3.53.6",
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 --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/invite-role-scope.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
  },
@@ -62,7 +62,7 @@ test("every invite handler asks requireInviteReach, and asks first", () => {
62
62
 
63
63
  assert.match(
64
64
  body,
65
- new RegExp(`await requireInviteReach\\(req, ${org}, app\\)`),
65
+ new RegExp(`await requireInviteReach\\(req, ${org}, app, role\\)`),
66
66
  `${handler} must check the organisation it was handed`,
67
67
  );
68
68
 
@@ -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(body, /actor\.isSuperAdmin[\s\S]{0,80}actor\.orgIds\.includes\(orgId\)/);
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
  });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The role an invitation hands out must belong to the organisation it invites
3
+ * into — and must never be a Seven365 console role.
4
+ *
5
+ * Both arms were measured against staging's **942** role-carrying invitations
6
+ * before being written (`_analysis/_invite-role-scope.js`, read-only). The
7
+ * counts in the case names below are that measurement, and they are what makes
8
+ * this shippable: 52 refused, all 52 mis-scoped, 890 untouched.
9
+ *
10
+ * The CONTROL for every refusal case is the pre-fix behaviour: before this
11
+ * change `role` was validated by `Joi.string().hex()` and nothing else, so
12
+ * every input here was accepted. `roleInvitable` not existing at all is what
13
+ * that control looks like, which the wiring assertions at the bottom pin.
14
+ */
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { roleInvitable } from "./.build/utils/invite-role-scope.util.mjs";
21
+
22
+ const CLIENT_A = "68f063160c11062938dadadd";
23
+ const CLIENT_B = "690053d194b3f2e7d74be49b";
24
+ const SITE_A = "6925212188a3a9a52eb53d37";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // REFUSED — the two shapes that are the defect
28
+ // ---------------------------------------------------------------------------
29
+
30
+ test("a role owned by another organisation is refused (51 live rows)", () => {
31
+ // "BC Property Group" Org Admin invited into "ABC Property Group".
32
+ const refusal = roleInvitable(
33
+ { type: "organization", org: CLIENT_A },
34
+ CLIENT_B,
35
+ ["organization"],
36
+ );
37
+
38
+ assert.equal(refusal, "That role belongs to a different organisation.");
39
+ });
40
+
41
+ test("a Seven365 console role is refused on a client invitation (1 live row)", () => {
42
+ // CapitaLand Super Admin, type "admin", on a property-management invite.
43
+ for (const org of ["", null, CLIENT_A]) {
44
+ const refusal = roleInvitable({ type: "admin", org }, org, [
45
+ "property_management_agency",
46
+ ]);
47
+
48
+ assert.equal(
49
+ refusal,
50
+ "That role belongs to the Seven365 console and cannot be given to this invitation.",
51
+ `an org-less staff role must not ride an ordinary invitation (org=${org})`,
52
+ );
53
+ }
54
+ });
55
+
56
+ test("a console role is refused even for the organisation that owns it", () => {
57
+ // The organisation arm would say yes to this; the staff arm is what refuses
58
+ // it, and it is checked FIRST for exactly that reason.
59
+ assert.equal(
60
+ roleInvitable({ type: "admin", org: CLIENT_A }, CLIENT_A, ["organization"]),
61
+ "That role belongs to the Seven365 console and cannot be given to this invitation.",
62
+ );
63
+ });
64
+
65
+ test("an ObjectId-shaped org is compared by value, not by identity", () => {
66
+ const asObjectId = { toString: () => CLIENT_A };
67
+
68
+ assert.equal(
69
+ roleInvitable({ type: "organization", org: asObjectId }, CLIENT_A, []),
70
+ null,
71
+ );
72
+ assert.equal(
73
+ roleInvitable({ type: "organization", org: asObjectId }, CLIENT_B, []),
74
+ "That role belongs to a different organisation.",
75
+ );
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // ALLOWED — the zero-lockout proof. Every legitimate shape that reaches the
80
+ // invitation endpoints today, and still must.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const LEGITIMATE = [
84
+ [
85
+ "super-admin invites a client with a TEMPLATE role (org-less, ~340 live rows)",
86
+ { type: "organization", org: "" },
87
+ CLIENT_A,
88
+ ["property_management_agency"],
89
+ ],
90
+ [
91
+ "the same template role when the role org is absent entirely",
92
+ { type: "organization" },
93
+ CLIENT_A,
94
+ ["security_agency"],
95
+ ],
96
+ [
97
+ "org admin invites a member with the organisation OWN role (the majority)",
98
+ { type: "organization", org: CLIENT_A },
99
+ CLIENT_A,
100
+ ["organization"],
101
+ ],
102
+ [
103
+ "a vertical app invites into a site with that org role",
104
+ { type: "cleaning_services", org: CLIENT_A },
105
+ CLIENT_A,
106
+ ["cleaning_services"],
107
+ ],
108
+ [
109
+ "an ORG-WIDE role, site empty, on a site-scoped invitation",
110
+ { type: "property_management_agency", org: CLIENT_A, site: "" },
111
+ CLIENT_A,
112
+ ["property_management_agency"],
113
+ ],
114
+ [
115
+ "a SITE-PINNED role invited into another site of the SAME org (36 live rows)",
116
+ { type: "property_management_agency", org: CLIENT_A, site: SITE_A },
117
+ CLIENT_A,
118
+ ["property_management_agency"],
119
+ ],
120
+ [
121
+ "an invitation that names no organisation at all (2 live rows)",
122
+ { type: "property_management_agency", org: CLIENT_A },
123
+ "",
124
+ ["Organization"],
125
+ ],
126
+ [
127
+ "and one whose org is null",
128
+ { type: "organization", org: CLIENT_A },
129
+ null,
130
+ ["organization"],
131
+ ],
132
+ [
133
+ "a staff invitation carrying a staff role — the console own flow",
134
+ { type: "admin", org: "" },
135
+ "",
136
+ ["admin"],
137
+ ],
138
+ [
139
+ "a multi-app invitation whose app list includes the staff type",
140
+ { type: "admin", org: "" },
141
+ "",
142
+ ["organization", "admin"],
143
+ ],
144
+ [
145
+ "a role id that resolves to nothing writes no permissions, so it is left alone",
146
+ null,
147
+ CLIENT_A,
148
+ ["organization"],
149
+ ],
150
+ [
151
+ "no app named at all, ordinary client role",
152
+ { type: "organization", org: CLIENT_A },
153
+ CLIENT_A,
154
+ null,
155
+ ],
156
+ ];
157
+
158
+ for (const [name, role, org, apps] of LEGITIMATE) {
159
+ test(`ALLOWED: ${name}`, () => {
160
+ assert.equal(
161
+ roleInvitable(role, org, apps),
162
+ null,
163
+ "this invitation works today and must keep working",
164
+ );
165
+ });
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Wiring — the control. If any of this is removed the decision above is dead
170
+ // code and every refusal case silently reverts to the pre-fix behaviour.
171
+ // ---------------------------------------------------------------------------
172
+
173
+ const read = (rel) =>
174
+ readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8");
175
+
176
+ test("requireInviteReach runs the role check, and runs it last", () => {
177
+ const authz = read("utils/console-authz.util.ts");
178
+
179
+ assert.match(
180
+ authz,
181
+ /const refusal = roleInvitable\(stored, org, apps\);\n\s*if \(refusal\) throw new BadRequestError\(refusal\);/,
182
+ "the refusal must be raised as a 4xx, not swallowed",
183
+ );
184
+
185
+ const gate = authz.indexOf("export async function requireInviteReach(");
186
+ const body = authz.slice(gate);
187
+
188
+ assert.match(
189
+ body,
190
+ /await requireRoleInvitable\(role, org, apps\);/,
191
+ "requireInviteReach must run the role check",
192
+ );
193
+
194
+ assert.ok(
195
+ body.indexOf("await requireOrgReach(req, org)") <
196
+ body.indexOf("await requireRoleInvitable("),
197
+ "the role read must come after the organisation gate, so no existing caller pays for it",
198
+ );
199
+ });
200
+
201
+ test("the role is only ever read from the STORED role, never from the request", () => {
202
+ const authz = read("utils/console-authz.util.ts");
203
+ const start = authz.indexOf("async function requireRoleInvitable(");
204
+ const body = authz.slice(
205
+ start,
206
+ authz.indexOf("export async function requireInviteReach("),
207
+ );
208
+
209
+ assert.match(body, /collection\("roles"\)/);
210
+ assert.match(body, /projection: \{ org: 1, type: 1 \}/);
211
+ assert.doesNotMatch(
212
+ body,
213
+ /req\.body|req\.query/,
214
+ "nothing the caller sends may take part in this decision",
215
+ );
216
+ });
217
+
218
+ test("every invite handler hands its role to the gate", () => {
219
+ const sources = [
220
+ read("controllers/verification.controller.ts"),
221
+ read("controllers/verification-v2.controller.ts"),
222
+ ].join("\n");
223
+
224
+ const calls =
225
+ sources.match(/requireInviteReach\(req, [a-zA-Z]+, app[^)]*\)/g) ?? [];
226
+
227
+ assert.equal(calls.length, 6, "there are six invite handlers");
228
+
229
+ for (const call of calls) {
230
+ assert.match(
231
+ call,
232
+ /, role\)$/,
233
+ `${call} must pass the role it is about to write`,
234
+ );
235
+ }
236
+ });
@@ -121,7 +121,7 @@ test("createUserInvite refuses a staff invitation from a non-staff caller", () =
121
121
  // yourself as staff, then accept your own invitation.
122
122
  const body = fn(verificationController, "createUserInvite");
123
123
 
124
- assert.match(body, /await requireInviteReach\(req, org, app\)/);
124
+ assert.match(body, /await requireInviteReach\(req, org, app, role\)/);
125
125
 
126
126
  const gate = body.indexOf("requireInviteReach");
127
127
  const write = body.indexOf("_createUserInvite(");
@@ -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
- assert.match(body, /if \(actor\.isSuperAdmin\) return id;/);
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,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
+ });