@7365admin1/core 3.49.1-staging.214 → 3.50.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 CHANGED
@@ -1,5 +1,343 @@
1
1
  # @iservice365/core
2
2
 
3
+ ## 3.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ee5e7dd: Scope the access-management routes that name a card, not a site.
8
+
9
+ `access-management-site-scope` closed the routes whose request already carries
10
+ an estate. Seven routes on the same mount carry none — they reach an access card
11
+ (or a unit) by `_id` and nothing else — so there was nothing for that rule to
12
+ read and they were left carrying `requireAuth` alone:
13
+
14
+ - `POST /sign-qr` — mints a working door credential. The card number is
15
+ RSA-encrypted into a QR payload the reader accepts for up to 24 hours when
16
+ `purpose` is `"vms"`. Any signed-in account could hand itself a valid pass for
17
+ any card in any estate on the platform.
18
+ - `PATCH /delete-card` — deactivates a card, which is what stops it opening a
19
+ door.
20
+ - `PATCH /nfc-status` — flips a card's status and activation.
21
+ - `PATCH /visitor-checkout` — releases a visitor's card and writes its history.
22
+ - `POST /card-replacement` — retires a card and hands its replacement to a unit.
23
+ - `POST /assign-user` — hands the cards on a unit to a list of people.
24
+ - `POST /settings` — rewrites an estate's access-management settings, including
25
+ the address of the Entrypass controller it talks to. This one DOES carry the
26
+ estate, in the body, and the repository upserts on it; it was simply never
27
+ checked.
28
+
29
+ For the six that name a record, the scope is now decided on the STORED row via
30
+ `siteReachOf` — the same rule `requireBulletinVideoReach` and
31
+ `requireEntryPassReach` already apply, and the same underlying `entitleSite`
32
+ verdict as `requireSiteReach`. `POST /settings` uses `requireSiteReach` on the
33
+ body's site, exactly as `GET /settings/:site` already does. No new mechanism and
34
+ no new permission string: reaching the estate is the whole rule, so the
35
+ contracted agency at the guardhouse, the org-wide manager and a resident reading
36
+ their own pass are all unaffected.
37
+
38
+ Where a write MOVES a card onto a unit (`card-replacement`, `assign-user`) the
39
+ destination unit is checked as well as the card, so a card cannot be re-parented
40
+ into an estate the caller cannot reach.
41
+
42
+ "Not yours" and "does not exist" answer identically (404), so card ids cannot be
43
+ enumerated. An id matching no row is refused rather than waved through, and a
44
+ row naming no estate fails closed. The guards sit after each handler's Joi
45
+ validation, so malformed input still answers 400 exactly as before.
46
+
47
+ `GET /door-access-levels`, `GET /lift-access-levels` and `GET /access-groups`
48
+ are deliberately NOT changed: they carry no estate and no record, only a
49
+ caller-supplied `acm_url`, so there is nothing to scope against without changing
50
+ their request contract. They are recorded as a separate piece of work.
51
+
52
+ - 042aae6: Scope invitation creation to an organisation the caller can reach, and stop a
53
+ non-staff caller minting a Seven365 staff invitation on the v1 endpoints.
54
+
55
+ Six endpoints took `metadata.org` — and the `app` that becomes the accepted
56
+ membership's `type` — straight from the request body, behind `requireAuth` and
57
+ nothing else:
58
+
59
+ - `POST /api/auth/invite` (`auth.route.ts:29`)
60
+ - `POST /api/auth/invite/user` (`auth.route.ts:30`)
61
+ - `POST /api/auth/invite/member` (`auth.route.ts:31`)
62
+ - `POST /api/auth/invite/service-provider/with-org` (`auth.route.ts:33`)
63
+ - `POST /api/auth/v2/invite` (`auth-v2.route.ts:22`)
64
+ - `POST /api/auth/v2/invite/organization` (`auth-v2.route.ts:23`)
65
+
66
+ Two things followed from that.
67
+
68
+ **The organisation.** The roles/members fix lets somebody holding an invitation
69
+ into an organisation reach it, because the client onboarding wizard needs that.
70
+ An attacker could therefore issue themselves the invitation the gate asks for —
71
+ one authenticated write naming any victim organisation — and walk straight back
72
+ into the chain that fix closed.
73
+
74
+ **`app`.** "admin" is what makes a membership Seven365 staff, and
75
+ `member.service.ts acceptInvite` writes `type: invite.metadata.app` verbatim.
76
+ `createMemberDirect` already refuses a staff membership from a non-staff caller;
77
+ the invitation road to the same place was open on the v1 mounts. The v2 twin
78
+ asked; the v1 mounts, which are the ones the web apps call, did not.
79
+
80
+ All six now call `requireInviteReach` (`utils/console-authz.util.ts`): Seven365
81
+ staff, a live member of the organisation, or somebody holding an invitation into
82
+ it — plus `requirePlatformStaff` when the invitation names the staff app. An
83
+ invitation that names no organisation is unchanged; it grants no organisation,
84
+ exactly as an org-less `GET /api/roles` does. The two service-provider invites
85
+ that already resolved their caller from the session are unchanged.
86
+
87
+ - dc7ab90: Scope the organisation list to the caller, and put the promo-code list behind the staff gate.
88
+
89
+ `GET /api/organizations` and `GET /api/promo-codes` performed no authorization
90
+ at all — `requireAuth` and nothing more — while every sibling handler in the
91
+ same two files guarded. So any signed-in account on the platform, a resident or
92
+ a cleaner included, could page through the name, e-mail, nature and status of
93
+ **every Seven365 client**, and read off **every discount code** we have ever
94
+ minted, with its rate and its expiry, and then redeem whichever is worth most.
95
+
96
+ The two rules are different, because the callers are different.
97
+
98
+ **`GET /api/organizations` is now a SCOPE, not a refusal.** Seven365 staff see
99
+ every organisation exactly as before; anybody else sees the organisations they
100
+ hold a live membership of, plus any registered under their own verified account
101
+ e-mail. The e-mail half is not decoration: `web-app-main
102
+ pages/verify/email/index.vue:322` — the org-owner self-signup — lists
103
+ organisations to find the one it created a line earlier, at which point that
104
+ brand-new client owner is a member of nothing. Refusing them would have traded
105
+ the leak for a broken signup. The comparison is the one
106
+ `getOrgsByEmail` already makes, against the address on the caller's own `users`
107
+ row; nothing the caller sends takes part in it. It also removes a real bug from
108
+ that flow: the page takes `items[0]` of a newest-first list, so two client
109
+ owners signing up in the same moment could be handed each other's organisation
110
+ as their `defaultOrg`.
111
+
112
+ The scope is part of the Redis cache key as well as the query, so one caller's
113
+ narrowed page is never replayed to the next, and `countDocuments` runs the
114
+ scoped query so pagination cannot walk past the scope.
115
+
116
+ **`GET /api/promo-codes` is Seven365 staff only,** together with
117
+ `GET /api/promo-codes/id/:id`, which is the same disclosure one record at a
118
+ time. Both are read solely by the staff console. A client at checkout is
119
+ unaffected: `web-app-org pages/checkout.vue` and
120
+ `subscription/manage-seats.vue` type a code and validate it through
121
+ `GET /api/promo-codes/code`, which stays signed-in-only and is untouched.
122
+
123
+ - c7b57f5: Make revoking somebody's access actually end their session.
124
+
125
+ A session in this product is an opaque `sid` whose only record of truth is a
126
+ Redis key `sid:<sid>` holding a copy of the user document. `requireAuth`
127
+ authenticates a request by finding that key and nothing else — it never reads
128
+ the database — and it re-`EXPIRE`s the key to four hours on every request, so a
129
+ session that is in use never ages out on its own.
130
+
131
+ **Signing out was never the problem.** `logout()` deletes the key, so a
132
+ signed-out session is refused; that half already worked. Every other way access
133
+ is taken away did not.
134
+
135
+ **Suspending somebody changed a column and nothing else.**
136
+ `suspendResidentById` writes `users.status = "suspended"` through
137
+ `updateUserFieldById`, which cleared the user-document cache and left the
138
+ `sid:` key untouched. The person carried on working, and every request they made
139
+ pushed the four-hour expiry out another four hours. On top of that,
140
+ `refreshToken()` minted a **brand new** session through `issueSession` with no
141
+ account-status check at all — the mobile refresh token is seven days and
142
+ rotating, so a suspended account's phone could keep issuing itself fresh
143
+ sessions indefinitely. Suspension was not a delay, it was a no-op for anyone
144
+ already signed in.
145
+
146
+ **Two of the statuses the product writes were never refused at sign-in either.**
147
+ Both `login()`s checked `suspended` and `pending` only, so `rejected` (written by
148
+ `reviewPerson`, whose email is the one that hides the login link) and `deleted`
149
+ (`UserStatus.DELETED`, the soft-delete marker) could sign in fresh with a
150
+ password.
151
+
152
+ The fix is the two halves of that, at the two choke points that already exist:
153
+
154
+ - **Minting refuses a non-live account** — `assertAccountLive` in `issueSession`
155
+ in both auth services, which is the one place every sign-in path in this
156
+ package goes through (login and mobile refresh, v1 and v2), for the same
157
+ reason `assertOrganizationNotSuspended` lives there.
158
+ - **Revoking destroys what already exists** — `revokeAccountAccess` from
159
+ `updateUserFieldById`, the only path in either user repository that writes
160
+ `users.status`. It deletes every `sid:` key the person holds and every refresh
161
+ token in `tokens`, so nothing can mint a replacement.
162
+
163
+ Finding every session a person holds needs an index: `users.sid` only ever
164
+ remembers the most recent sign-in, v2 never writes it, and nothing reads it — a
165
+ fix built on that column would revoke the phone and leave the browser signed in.
166
+ The index is a Redis set (`user-sessions:<userId>`), so there is no schema
167
+ change and no migration; `users.sid` stays as the fallback that catches v1
168
+ sessions minted before the set existed.
169
+
170
+ The blocking statuses are `suspended`, `pending`, `rejected` and `deleted`, as a
171
+ deny-list — `status` is optional on the model and legacy rows carry `""` or no
172
+ field at all, and an allow-list would lock every one of them out. `resubmit` is
173
+ deliberately live: that email keeps the login link, because resubmitting the
174
+ application is done signed in.
175
+
176
+ Nothing is logged out by deploying this — no existing session is touched, and
177
+ the two sign-in messages for `suspended` and `pending` are unchanged, because
178
+ `login()` still checks those two itself before minting. What changes for a
179
+ signed-in user is only that a `rejected` or soft-deleted account can no longer
180
+ obtain a session, and that a suspension now takes effect at once rather than
181
+ never.
182
+
183
+ - 6cbc3fa: Stop a deleted role from conferring Seven365 identity, and protect the seeded
184
+ platform-owner role.
185
+
186
+ Two defects on the roles collection, both of which made an ordinary
187
+ administrative action do the opposite of what it says.
188
+
189
+ **Deleting somebody's admin role did not take their access away.**
190
+ `deleteRole` (`role.repo.ts`) is a SOFT delete: it sets `{ status: "deleted" }`
191
+ and leaves the document in place. `isSuperAdmin` and `isPlatformOwner`
192
+ (`super-admin.util.ts`) read that role by `_id` alone, and so did the second
193
+ staff derivation in `resolveInviteActor` (`invite-actor.util.ts`). So the
194
+ ordinary way to revoke a Seven365 staff account — delete the admin role it sits
195
+ on — left the account platform staff: `requirePlatformStaff` still passed, and
196
+ staff identity is the bypass on `requireOrgAccess`, on `requireSiteReach` and on
197
+ organisation suspension. `getUserPermissions` (`permission.util.ts`) always
198
+ filtered on status, which is why the same account lost every permission and kept
199
+ the identity that makes permissions unnecessary.
200
+
201
+ All three identity derivations now require a live role. "Live" is spelled
202
+ `$ne: "deleted"` — the test already applied to the `members` row in the same
203
+ functions — rather than `status: "active"`. Roles carry exactly two statuses in
204
+ this package: `MRole` has defaulted `status` to `"active"` since the model's
205
+ first commit and every insert goes through `addRole` -> `new MRole()`, and
206
+ `"deleted"` is written by `deleteRole` alone (no API can send `status` on a
207
+ role; `updateRole` takes a name and permissions only). The two spellings
208
+ therefore agree on every row this package can write and differ only on a
209
+ pre-package row carrying no `status` at all, where `$ne` keeps that account
210
+ working and `"active"` would silently strip its console access. No migration is
211
+ implied.
212
+
213
+ **The seeded platform-owner role could be deleted, and nothing put it back.**
214
+ `createDefaultUser()` seeds one org-less `{ type: "admin", default: true }`
215
+ role, and that pair is the whole of `isPlatformOwner`. Deleting it is a one-way
216
+ door: `getRoles` lists active rows only, so it disappears from the roles screen,
217
+ and at the next boot `createDefaultUser` finds the deleted row by name and type
218
+ and reuses it rather than seeding a fresh one — leaving the owner account with
219
+ no permissions and no way to restore the role through the product. The Delete
220
+ and Edit buttons on the roles screen
221
+ (`layer-common components/RolePermissionMain.vue`) both reached it.
222
+
223
+ `role.repo.ts` now refuses to delete, rename or rewrite the permissions of that
224
+ role. The guard sits in the repository because all four write paths pass through
225
+ those three functions: `role.service.deleteRole`,
226
+ `role.service.deleteWithReassignments` (which deletes inside a transaction after
227
+ moving every member off), and the two `role.controller` edit paths that call the
228
+ repository directly — a guard in the service would have covered the deletes and
229
+ missed the edits. A CLIENT's own "owner" role is also `default: true`
230
+ (`subscription.service` writes two per new client) but is typed `organization`
231
+ or the org's nature, so requiring the platform staff type leaves every client's
232
+ own role editable exactly as before.
233
+
234
+ No new authorization mechanism is introduced, and no route's authentication or
235
+ permission requirements change.
236
+
237
+ - ebdfd23: Scope the role list, role creation and direct membership creation to an
238
+ organisation the caller can actually reach.
239
+
240
+ Two endpoints took the organisation straight from the request, and they chained
241
+ into a cross-tenant escalation that any signed-in account could run:
242
+
243
+ 1. `GET /api/roles?org=<any org id>` listed that client's roles with their ids —
244
+ `role.controller.ts getRoles` made no scope call at all and `role.repo.ts`
245
+ put the caller's query string into `{ status: "active", org }`.
246
+ 2. `POST /api/members/direct` wrote a membership into whatever `orgId` the body
247
+ named (`member.controller.ts createMemberDirect` validated the shape and
248
+ nothing else).
249
+ 3. `member.model.ts` writes `status: value.status || "active"`, so that row is
250
+ live on insert.
251
+ 4. `resolveInviteActor` then reports that organisation in the caller's `orgIds`,
252
+ which is exactly what `requireOrgAccess` asks for — the gate that every
253
+ role, member, people, site and camera fix in the 3.49.0 wave depends on.
254
+
255
+ So a resident, a visitor's contact or a cleaner could enrol itself as an active
256
+ member of any client organisation and inherit that client's reach. The
257
+ escalation to Seven365 platform owner was closed earlier; this is the same
258
+ attack scoped down one level, at the tenant.
259
+
260
+ The decision is a new `requireOrgReach` in `console-authz.util.ts`: **Seven365
261
+ staff, a live member of the organisation, or somebody holding an invitation into
262
+ it.** The third clause is not a convenience — three live flows legitimately
263
+ reach an organisation before any membership exists, and a flat membership test
264
+ would break all of them:
265
+
266
+ - the service provider signing in against a `service-provider-invite`, who looks
267
+ up the customer's "Admin Service Provider" role and creates it when the
268
+ customer has none (`web-app-main pages/sign-in.vue:298,311,428`);
269
+ - the admin member finishing sign-up from a `member-invite`
270
+ (`web-app-main pages/verify/email/index.vue:462`);
271
+ - the client owner walking the onboarding wizard
272
+ (`web-app-org pages/onboarding/getting-started/index.vue`).
273
+
274
+ All three hold a verification addressed to their own email whose `metadata.org`
275
+ is the organisation being reached, and that invitation is the relationship the
276
+ rule tests (`hasOrgInvitation` in `invite-actor.util.ts`). The address is read
277
+ from the `users` row for the session id, never from the request; both the
278
+ ObjectId and the older string shape of `metadata.org` are matched, the same pair
279
+ `completePendingInvites` matches on; and only the three invitation types count,
280
+ so a password-reset code opens nothing.
281
+
282
+ Where the check now runs:
283
+
284
+ - `GET /api/roles` — when the caller sends an `org`. With no `org` the query is
285
+ unchanged and still matches only the ORG-LESS platform roles, which is what
286
+ every invitation screen asks for and is not a tenant's data.
287
+ - `POST /api/roles` and `POST /api/roles/v2` — on the `org` the new role is
288
+ filed under. Both mounts, because `/v2` is a separate controller and gating
289
+ one would leave the hole reachable by adding `/v2` to the URL.
290
+ - `POST /api/members/direct` — enrolling YOURSELF needs reach, so an invitation
291
+ counts; enrolling ANYBODY ELSE needs `requireOrgAccess`, real membership. The
292
+ onboarding wizard's second call, which adds the Seven365 support account,
293
+ always follows the caller's own row, so it passes.
294
+
295
+ `requireOrgAccess` itself is untouched. The invitation clause is additive and
296
+ lives in a separate function — widening the shared one would hand all 78 of its
297
+ mounts to anybody holding an invitation.
298
+
299
+ Proved end to end in `test/e2e/org-scoping-roles-members.e2e.test.mjs`: 13 of
300
+ its 23 cases fail on the code before this change and all 23 pass after, while
301
+ every "must keep working" case — the caller's own org, the org-less platform
302
+ list, a member enrolling a colleague, staff reaching any client, and the whole
303
+ invited-newcomer flow — passes on both.
304
+
305
+ ### Patch Changes
306
+
307
+ - 7bf8130: Decide the HRM Labs location server-side, so an entitled caller cannot read
308
+ another site's roster.
309
+
310
+ `requireSiteReach(req, siteId, ATTENDANCE_READ)` guards all four manpower-attendance
311
+ reads, but `siteId` is not what selects the roster — it only picks the local
312
+ `manpower-settings` row. The roster is selected by `locationName`, the field HRM Labs
313
+ is asked for, and that came straight from the request body's `siteName` with nothing
314
+ checking it against `siteId`.
315
+
316
+ So the two could disagree. A caller entitled to site A could send site A's `siteId`,
317
+ pass the guard, and pair it with **another** site's `siteName` — and read that site's
318
+ employee roster back: names, staff numbers, job titles, NRICs
319
+ (`identificationNumber`) and the check-in/check-out photographs. The exposure had been
320
+ narrowed from anyone on the internet to any signed-in account; this closes it.
321
+
322
+ The location now comes from `config.siteName`, the settings row the caller was already
323
+ entitled to, via one `locationOf()` helper called in each of the four functions. That
324
+ is the same source the screen reads from: the security app fills `siteId` and
325
+ `siteName` from one HRM Labs site-list entry (`site.id` / `site.text`),
326
+ `manpowerMonitoringSchema` makes `siteName` required on both write paths, and
327
+ `TManpowerMonitoringUpdate` cannot change it — so for every legitimate row the pair
328
+ already agreed and the server simply stops asking.
329
+
330
+ A row with no stored name is refused rather than falling back to the body; a fallback
331
+ would re-open the hole for exactly the rows an attacker would want to name for
332
+ themselves.
333
+
334
+ Nothing legitimate changes. The Joi schemas still accept `siteName`, so no client needs
335
+ a coordinated release — it is accepted and ignored. The three internal callers
336
+ (`manpower.event.ts` and the two `cron.util.ts` sweeps) get more reliable, not less:
337
+ they were passing `manpower-remarks.siteName`, which that model marks optional.
338
+
339
+ - 0be47d5: Scope the "my memberships" reads and the preloved marketplace to the record's stored owner.
340
+
3
341
  ## 3.49.0
4
342
 
5
343
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.49.1-staging.214",
4
+ "version": "3.50.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -1,48 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Scope the access-management routes that name a card, not a site.
6
-
7
- `access-management-site-scope` closed the routes whose request already carries
8
- an estate. Seven routes on the same mount carry none — they reach an access card
9
- (or a unit) by `_id` and nothing else — so there was nothing for that rule to
10
- read and they were left carrying `requireAuth` alone:
11
-
12
- - `POST /sign-qr` — mints a working door credential. The card number is
13
- RSA-encrypted into a QR payload the reader accepts for up to 24 hours when
14
- `purpose` is `"vms"`. Any signed-in account could hand itself a valid pass for
15
- any card in any estate on the platform.
16
- - `PATCH /delete-card` — deactivates a card, which is what stops it opening a
17
- door.
18
- - `PATCH /nfc-status` — flips a card's status and activation.
19
- - `PATCH /visitor-checkout` — releases a visitor's card and writes its history.
20
- - `POST /card-replacement` — retires a card and hands its replacement to a unit.
21
- - `POST /assign-user` — hands the cards on a unit to a list of people.
22
- - `POST /settings` — rewrites an estate's access-management settings, including
23
- the address of the Entrypass controller it talks to. This one DOES carry the
24
- estate, in the body, and the repository upserts on it; it was simply never
25
- checked.
26
-
27
- For the six that name a record, the scope is now decided on the STORED row via
28
- `siteReachOf` — the same rule `requireBulletinVideoReach` and
29
- `requireEntryPassReach` already apply, and the same underlying `entitleSite`
30
- verdict as `requireSiteReach`. `POST /settings` uses `requireSiteReach` on the
31
- body's site, exactly as `GET /settings/:site` already does. No new mechanism and
32
- no new permission string: reaching the estate is the whole rule, so the
33
- contracted agency at the guardhouse, the org-wide manager and a resident reading
34
- their own pass are all unaffected.
35
-
36
- Where a write MOVES a card onto a unit (`card-replacement`, `assign-user`) the
37
- destination unit is checked as well as the card, so a card cannot be re-parented
38
- into an estate the caller cannot reach.
39
-
40
- "Not yours" and "does not exist" answer identically (404), so card ids cannot be
41
- enumerated. An id matching no row is refused rather than waved through, and a
42
- row naming no estate fails closed. The guards sit after each handler's Joi
43
- validation, so malformed input still answers 400 exactly as before.
44
-
45
- `GET /door-access-levels`, `GET /lift-access-levels` and `GET /access-groups`
46
- are deliberately NOT changed: they carry no estate and no record, only a
47
- caller-supplied `acm_url`, so there is nothing to scope against without changing
48
- their request contract. They are recorded as a separate piece of work.
@@ -1,35 +0,0 @@
1
- ---
2
- "@7365admin1/core": patch
3
- ---
4
-
5
- Decide the HRM Labs location server-side, so an entitled caller cannot read
6
- another site's roster.
7
-
8
- `requireSiteReach(req, siteId, ATTENDANCE_READ)` guards all four manpower-attendance
9
- reads, but `siteId` is not what selects the roster — it only picks the local
10
- `manpower-settings` row. The roster is selected by `locationName`, the field HRM Labs
11
- is asked for, and that came straight from the request body's `siteName` with nothing
12
- checking it against `siteId`.
13
-
14
- So the two could disagree. A caller entitled to site A could send site A's `siteId`,
15
- pass the guard, and pair it with **another** site's `siteName` — and read that site's
16
- employee roster back: names, staff numbers, job titles, NRICs
17
- (`identificationNumber`) and the check-in/check-out photographs. The exposure had been
18
- narrowed from anyone on the internet to any signed-in account; this closes it.
19
-
20
- The location now comes from `config.siteName`, the settings row the caller was already
21
- entitled to, via one `locationOf()` helper called in each of the four functions. That
22
- is the same source the screen reads from: the security app fills `siteId` and
23
- `siteName` from one HRM Labs site-list entry (`site.id` / `site.text`),
24
- `manpowerMonitoringSchema` makes `siteName` required on both write paths, and
25
- `TManpowerMonitoringUpdate` cannot change it — so for every legitimate row the pair
26
- already agreed and the server simply stops asking.
27
-
28
- A row with no stored name is refused rather than falling back to the body; a fallback
29
- would re-open the hole for exactly the rows an attacker would want to name for
30
- themselves.
31
-
32
- Nothing legitimate changes. The Joi schemas still accept `siteName`, so no client needs
33
- a coordinated release — it is accepted and ignored. The three internal callers
34
- (`manpower.event.ts` and the two `cron.util.ts` sweeps) get more reliable, not less:
35
- they were passing `manpower-remarks.siteName`, which that model marks optional.
@@ -1,38 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Scope invitation creation to an organisation the caller can reach, and stop a
6
- non-staff caller minting a Seven365 staff invitation on the v1 endpoints.
7
-
8
- Six endpoints took `metadata.org` — and the `app` that becomes the accepted
9
- membership's `type` — straight from the request body, behind `requireAuth` and
10
- nothing else:
11
-
12
- - `POST /api/auth/invite` (`auth.route.ts:29`)
13
- - `POST /api/auth/invite/user` (`auth.route.ts:30`)
14
- - `POST /api/auth/invite/member` (`auth.route.ts:31`)
15
- - `POST /api/auth/invite/service-provider/with-org` (`auth.route.ts:33`)
16
- - `POST /api/auth/v2/invite` (`auth-v2.route.ts:22`)
17
- - `POST /api/auth/v2/invite/organization` (`auth-v2.route.ts:23`)
18
-
19
- Two things followed from that.
20
-
21
- **The organisation.** The roles/members fix lets somebody holding an invitation
22
- into an organisation reach it, because the client onboarding wizard needs that.
23
- An attacker could therefore issue themselves the invitation the gate asks for —
24
- one authenticated write naming any victim organisation — and walk straight back
25
- into the chain that fix closed.
26
-
27
- **`app`.** "admin" is what makes a membership Seven365 staff, and
28
- `member.service.ts acceptInvite` writes `type: invite.metadata.app` verbatim.
29
- `createMemberDirect` already refuses a staff membership from a non-staff caller;
30
- the invitation road to the same place was open on the v1 mounts. The v2 twin
31
- asked; the v1 mounts, which are the ones the web apps call, did not.
32
-
33
- All six now call `requireInviteReach` (`utils/console-authz.util.ts`): Seven365
34
- staff, a live member of the organisation, or somebody holding an invitation into
35
- it — plus `requirePlatformStaff` when the invitation names the staff app. An
36
- invitation that names no organisation is unchanged; it grants no organisation,
37
- exactly as an org-less `GET /api/roles` does. The two service-provider invites
38
- that already resolved their caller from the session are unchanged.
@@ -1,39 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Scope the organisation list to the caller, and put the promo-code list behind the staff gate.
6
-
7
- `GET /api/organizations` and `GET /api/promo-codes` performed no authorization
8
- at all — `requireAuth` and nothing more — while every sibling handler in the
9
- same two files guarded. So any signed-in account on the platform, a resident or
10
- a cleaner included, could page through the name, e-mail, nature and status of
11
- **every Seven365 client**, and read off **every discount code** we have ever
12
- minted, with its rate and its expiry, and then redeem whichever is worth most.
13
-
14
- The two rules are different, because the callers are different.
15
-
16
- **`GET /api/organizations` is now a SCOPE, not a refusal.** Seven365 staff see
17
- every organisation exactly as before; anybody else sees the organisations they
18
- hold a live membership of, plus any registered under their own verified account
19
- e-mail. The e-mail half is not decoration: `web-app-main
20
- pages/verify/email/index.vue:322` — the org-owner self-signup — lists
21
- organisations to find the one it created a line earlier, at which point that
22
- brand-new client owner is a member of nothing. Refusing them would have traded
23
- the leak for a broken signup. The comparison is the one
24
- `getOrgsByEmail` already makes, against the address on the caller's own `users`
25
- row; nothing the caller sends takes part in it. It also removes a real bug from
26
- that flow: the page takes `items[0]` of a newest-first list, so two client
27
- owners signing up in the same moment could be handed each other's organisation
28
- as their `defaultOrg`.
29
-
30
- The scope is part of the Redis cache key as well as the query, so one caller's
31
- narrowed page is never replayed to the next, and `countDocuments` runs the
32
- scoped query so pagination cannot walk past the scope.
33
-
34
- **`GET /api/promo-codes` is Seven365 staff only,** together with
35
- `GET /api/promo-codes/id/:id`, which is the same disclosure one record at a
36
- time. Both are read solely by the staff console. A client at checkout is
37
- unaffected: `web-app-org pages/checkout.vue` and
38
- `subscription/manage-seats.vue` type a code and validate it through
39
- `GET /api/promo-codes/code`, which stays signed-in-only and is untouched.
@@ -1,5 +0,0 @@
1
- ---
2
- "@7365admin1/core": patch
3
- ---
4
-
5
- Scope the "my memberships" reads and the preloved marketplace to the record's stored owner.
@@ -1,63 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Make revoking somebody's access actually end their session.
6
-
7
- A session in this product is an opaque `sid` whose only record of truth is a
8
- Redis key `sid:<sid>` holding a copy of the user document. `requireAuth`
9
- authenticates a request by finding that key and nothing else — it never reads
10
- the database — and it re-`EXPIRE`s the key to four hours on every request, so a
11
- session that is in use never ages out on its own.
12
-
13
- **Signing out was never the problem.** `logout()` deletes the key, so a
14
- signed-out session is refused; that half already worked. Every other way access
15
- is taken away did not.
16
-
17
- **Suspending somebody changed a column and nothing else.**
18
- `suspendResidentById` writes `users.status = "suspended"` through
19
- `updateUserFieldById`, which cleared the user-document cache and left the
20
- `sid:` key untouched. The person carried on working, and every request they made
21
- pushed the four-hour expiry out another four hours. On top of that,
22
- `refreshToken()` minted a **brand new** session through `issueSession` with no
23
- account-status check at all — the mobile refresh token is seven days and
24
- rotating, so a suspended account's phone could keep issuing itself fresh
25
- sessions indefinitely. Suspension was not a delay, it was a no-op for anyone
26
- already signed in.
27
-
28
- **Two of the statuses the product writes were never refused at sign-in either.**
29
- Both `login()`s checked `suspended` and `pending` only, so `rejected` (written by
30
- `reviewPerson`, whose email is the one that hides the login link) and `deleted`
31
- (`UserStatus.DELETED`, the soft-delete marker) could sign in fresh with a
32
- password.
33
-
34
- The fix is the two halves of that, at the two choke points that already exist:
35
-
36
- - **Minting refuses a non-live account** — `assertAccountLive` in `issueSession`
37
- in both auth services, which is the one place every sign-in path in this
38
- package goes through (login and mobile refresh, v1 and v2), for the same
39
- reason `assertOrganizationNotSuspended` lives there.
40
- - **Revoking destroys what already exists** — `revokeAccountAccess` from
41
- `updateUserFieldById`, the only path in either user repository that writes
42
- `users.status`. It deletes every `sid:` key the person holds and every refresh
43
- token in `tokens`, so nothing can mint a replacement.
44
-
45
- Finding every session a person holds needs an index: `users.sid` only ever
46
- remembers the most recent sign-in, v2 never writes it, and nothing reads it — a
47
- fix built on that column would revoke the phone and leave the browser signed in.
48
- The index is a Redis set (`user-sessions:<userId>`), so there is no schema
49
- change and no migration; `users.sid` stays as the fallback that catches v1
50
- sessions minted before the set existed.
51
-
52
- The blocking statuses are `suspended`, `pending`, `rejected` and `deleted`, as a
53
- deny-list — `status` is optional on the model and legacy rows carry `""` or no
54
- field at all, and an allow-list would lock every one of them out. `resubmit` is
55
- deliberately live: that email keeps the login link, because resubmitting the
56
- application is done signed in.
57
-
58
- Nothing is logged out by deploying this — no existing session is touched, and
59
- the two sign-in messages for `suspended` and `pending` are unchanged, because
60
- `login()` still checks those two itself before minting. What changes for a
61
- signed-in user is only that a `rejected` or soft-deleted account can no longer
62
- obtain a session, and that a suspension now takes effect at once rather than
63
- never.
@@ -1,57 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Stop a deleted role from conferring Seven365 identity, and protect the seeded
6
- platform-owner role.
7
-
8
- Two defects on the roles collection, both of which made an ordinary
9
- administrative action do the opposite of what it says.
10
-
11
- **Deleting somebody's admin role did not take their access away.**
12
- `deleteRole` (`role.repo.ts`) is a SOFT delete: it sets `{ status: "deleted" }`
13
- and leaves the document in place. `isSuperAdmin` and `isPlatformOwner`
14
- (`super-admin.util.ts`) read that role by `_id` alone, and so did the second
15
- staff derivation in `resolveInviteActor` (`invite-actor.util.ts`). So the
16
- ordinary way to revoke a Seven365 staff account — delete the admin role it sits
17
- on — left the account platform staff: `requirePlatformStaff` still passed, and
18
- staff identity is the bypass on `requireOrgAccess`, on `requireSiteReach` and on
19
- organisation suspension. `getUserPermissions` (`permission.util.ts`) always
20
- filtered on status, which is why the same account lost every permission and kept
21
- the identity that makes permissions unnecessary.
22
-
23
- All three identity derivations now require a live role. "Live" is spelled
24
- `$ne: "deleted"` — the test already applied to the `members` row in the same
25
- functions — rather than `status: "active"`. Roles carry exactly two statuses in
26
- this package: `MRole` has defaulted `status` to `"active"` since the model's
27
- first commit and every insert goes through `addRole` -> `new MRole()`, and
28
- `"deleted"` is written by `deleteRole` alone (no API can send `status` on a
29
- role; `updateRole` takes a name and permissions only). The two spellings
30
- therefore agree on every row this package can write and differ only on a
31
- pre-package row carrying no `status` at all, where `$ne` keeps that account
32
- working and `"active"` would silently strip its console access. No migration is
33
- implied.
34
-
35
- **The seeded platform-owner role could be deleted, and nothing put it back.**
36
- `createDefaultUser()` seeds one org-less `{ type: "admin", default: true }`
37
- role, and that pair is the whole of `isPlatformOwner`. Deleting it is a one-way
38
- door: `getRoles` lists active rows only, so it disappears from the roles screen,
39
- and at the next boot `createDefaultUser` finds the deleted row by name and type
40
- and reuses it rather than seeding a fresh one — leaving the owner account with
41
- no permissions and no way to restore the role through the product. The Delete
42
- and Edit buttons on the roles screen
43
- (`layer-common components/RolePermissionMain.vue`) both reached it.
44
-
45
- `role.repo.ts` now refuses to delete, rename or rewrite the permissions of that
46
- role. The guard sits in the repository because all four write paths pass through
47
- those three functions: `role.service.deleteRole`,
48
- `role.service.deleteWithReassignments` (which deletes inside a transaction after
49
- moving every member off), and the two `role.controller` edit paths that call the
50
- repository directly — a guard in the service would have covered the deletes and
51
- missed the edits. A CLIENT's own "owner" role is also `default: true`
52
- (`subscription.service` writes two per new client) but is typed `organization`
53
- or the org's nature, so requiring the platform staff type leaves every client's
54
- own role editable exactly as before.
55
-
56
- No new authorization mechanism is introduced, and no route's authentication or
57
- permission requirements change.
@@ -1,71 +0,0 @@
1
- ---
2
- "@7365admin1/core": minor
3
- ---
4
-
5
- Scope the role list, role creation and direct membership creation to an
6
- organisation the caller can actually reach.
7
-
8
- Two endpoints took the organisation straight from the request, and they chained
9
- into a cross-tenant escalation that any signed-in account could run:
10
-
11
- 1. `GET /api/roles?org=<any org id>` listed that client's roles with their ids —
12
- `role.controller.ts getRoles` made no scope call at all and `role.repo.ts`
13
- put the caller's query string into `{ status: "active", org }`.
14
- 2. `POST /api/members/direct` wrote a membership into whatever `orgId` the body
15
- named (`member.controller.ts createMemberDirect` validated the shape and
16
- nothing else).
17
- 3. `member.model.ts` writes `status: value.status || "active"`, so that row is
18
- live on insert.
19
- 4. `resolveInviteActor` then reports that organisation in the caller's `orgIds`,
20
- which is exactly what `requireOrgAccess` asks for — the gate that every
21
- role, member, people, site and camera fix in the 3.49.0 wave depends on.
22
-
23
- So a resident, a visitor's contact or a cleaner could enrol itself as an active
24
- member of any client organisation and inherit that client's reach. The
25
- escalation to Seven365 platform owner was closed earlier; this is the same
26
- attack scoped down one level, at the tenant.
27
-
28
- The decision is a new `requireOrgReach` in `console-authz.util.ts`: **Seven365
29
- staff, a live member of the organisation, or somebody holding an invitation into
30
- it.** The third clause is not a convenience — three live flows legitimately
31
- reach an organisation before any membership exists, and a flat membership test
32
- would break all of them:
33
-
34
- - the service provider signing in against a `service-provider-invite`, who looks
35
- up the customer's "Admin Service Provider" role and creates it when the
36
- customer has none (`web-app-main pages/sign-in.vue:298,311,428`);
37
- - the admin member finishing sign-up from a `member-invite`
38
- (`web-app-main pages/verify/email/index.vue:462`);
39
- - the client owner walking the onboarding wizard
40
- (`web-app-org pages/onboarding/getting-started/index.vue`).
41
-
42
- All three hold a verification addressed to their own email whose `metadata.org`
43
- is the organisation being reached, and that invitation is the relationship the
44
- rule tests (`hasOrgInvitation` in `invite-actor.util.ts`). The address is read
45
- from the `users` row for the session id, never from the request; both the
46
- ObjectId and the older string shape of `metadata.org` are matched, the same pair
47
- `completePendingInvites` matches on; and only the three invitation types count,
48
- so a password-reset code opens nothing.
49
-
50
- Where the check now runs:
51
-
52
- - `GET /api/roles` — when the caller sends an `org`. With no `org` the query is
53
- unchanged and still matches only the ORG-LESS platform roles, which is what
54
- every invitation screen asks for and is not a tenant's data.
55
- - `POST /api/roles` and `POST /api/roles/v2` — on the `org` the new role is
56
- filed under. Both mounts, because `/v2` is a separate controller and gating
57
- one would leave the hole reachable by adding `/v2` to the URL.
58
- - `POST /api/members/direct` — enrolling YOURSELF needs reach, so an invitation
59
- counts; enrolling ANYBODY ELSE needs `requireOrgAccess`, real membership. The
60
- onboarding wizard's second call, which adds the Seven365 support account,
61
- always follows the caller's own row, so it passes.
62
-
63
- `requireOrgAccess` itself is untouched. The invitation clause is additive and
64
- lives in a separate function — widening the shared one would hand all 78 of its
65
- mounts to anybody holding an invitation.
66
-
67
- Proved end to end in `test/e2e/org-scoping-roles-members.e2e.test.mjs`: 13 of
68
- its 23 cases fail on the code before this change and all 23 pass after, while
69
- every "must keep working" case — the caller's own org, the org-less platform
70
- list, a member enrolling a colleague, staff reaching any client, and the whole
71
- invited-newcomer flow — passes on both.