@7365admin1/core 3.48.1-staging.211 → 3.49.1-staging.212
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 +1707 -0
- package/package.json +1 -1
- package/.changeset/access-management-site-scope.md +0 -59
- package/.changeset/attendance-settings-site-scope.md +0 -5
- package/.changeset/attendance-site-and-identity-scope.md +0 -32
- package/.changeset/bulletin-board-site-scope.md +0 -19
- package/.changeset/bulletin-video-site-scope.md +0 -5
- package/.changeset/camera-type-permission-classification.md +0 -46
- package/.changeset/customer-site-org-scope.md +0 -48
- package/.changeset/estate-structure-write-scope.md +0 -51
- package/.changeset/event-management-site-scope.md +0 -16
- package/.changeset/facility-booking-site-scope.md +0 -10
- package/.changeset/feedback-site-scope.md +0 -51
- package/.changeset/form-entry-scope.md +0 -29
- package/.changeset/hid-configuration-write-allowlist.md +0 -22
- package/.changeset/hrmlabs-attendance-site-scope.md +0 -5
- package/.changeset/incident-report-site-scope.md +0 -40
- package/.changeset/invoice-subscription-owner-scope.md +0 -23
- package/.changeset/manpower-designation-site-scope.md +0 -11
- package/.changeset/manpower-monitoring-site-scope.md +0 -37
- package/.changeset/manpower-remark-site-scope.md +0 -41
- package/.changeset/manpower-sites-empty-sync-guard.md +0 -5
- package/.changeset/max-seats-zero-unlimited.md +0 -11
- package/.changeset/member-read-and-status-scope.md +0 -40
- package/.changeset/member-type-platform-staff-gate.md +0 -71
- package/.changeset/member-write-scope.md +0 -60
- package/.changeset/nfc-patrol-log-site-scope.md +0 -56
- package/.changeset/occurrence-book-site-scope.md +0 -50
- package/.changeset/occurrence-entry-site-scope.md +0 -58
- package/.changeset/occurrence-subject-site-scope.md +0 -58
- package/.changeset/online-form-site-scope.md +0 -15
- package/.changeset/org-anon-lookup-projection.md +0 -33
- package/.changeset/organization-read-scope.md +0 -44
- package/.changeset/patrol-email-site-scope.md +0 -5
- package/.changeset/patrol-log-site-scope.md +0 -29
- package/.changeset/patrol-question-site-scope.md +0 -22
- package/.changeset/patrol-route-site-scope.md +0 -27
- package/.changeset/people-read-scope.md +0 -40
- package/.changeset/person-lookup-scope.md +0 -44
- package/.changeset/person-search-filter-collision.md +0 -26
- package/.changeset/person-write-scope.md +0 -38
- package/.changeset/provider-billing-org-scope.md +0 -21
- package/.changeset/resident-self-signup-field-pinning.md +0 -53
- package/.changeset/robot-site-scope.md +0 -5
- package/.changeset/role-scope-and-admin-type.md +0 -58
- package/.changeset/service-provider-record-scope.md +0 -39
- package/.changeset/site-billing-configuration-scope.md +0 -5
- package/.changeset/site-billing-item-scope.md +0 -5
- package/.changeset/site-entrypass-settings-scope.md +0 -53
- package/.changeset/site-facility-site-scope.md +0 -12
- package/.changeset/site-org-scope.md +0 -46
- package/.changeset/site-soa-scope.md +0 -30
- package/.changeset/site-unit-billing-scope.md +0 -25
- package/.changeset/staff-camera-status.md +0 -10
- package/.changeset/user-email-lookup-no-credentials.md +0 -52
- package/.changeset/user-self-scope.md +0 -45
- package/.changeset/vehicle-approve-permission-prefix.md +0 -22
- package/.changeset/verification-status-transition.md +0 -35
- package/.changeset/visitor-guest-scope.md +0 -12
- package/.changeset/work-order-site-scope.md +0 -77
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1712 @@
|
|
|
1
1
|
# @iservice365/core
|
|
2
2
|
|
|
3
|
+
## 3.49.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8581896: Scope the access-management routes to a site the caller can reach.
|
|
8
|
+
|
|
9
|
+
Every route on `/api/access-management` carried `requireAuth` and nothing more.
|
|
10
|
+
`requireAuth` proves a session exists; it makes no organisation or site
|
|
11
|
+
decision. So any signed-in account on the platform — a resident of a different
|
|
12
|
+
estate, a cleaner, anybody with a login — could read another client's
|
|
13
|
+
access-card inventory, its card holders, the residents behind a unit and the
|
|
14
|
+
block/level/unit structure, and could ISSUE a physical NFC card into that
|
|
15
|
+
estate, generate visitor QR passes for it and stamp QR tags onto its cards.
|
|
16
|
+
These are the credentials that open that estate's doors and lifts.
|
|
17
|
+
|
|
18
|
+
Twenty-eight handlers now decide from the site the request already names,
|
|
19
|
+
before any work is done:
|
|
20
|
+
|
|
21
|
+
- from the body — `addPhysicalCard`, `addNonPhysicalCard`, `acknowlegdeCard`,
|
|
22
|
+
`replaceCard`, `assignAccessCardToUnit`, `vmsgenerateQrCodes`,
|
|
23
|
+
`addVisitorAccessCard`;
|
|
24
|
+
- from the query — `allAccessCardsCounts`, `availableAccessCards`,
|
|
25
|
+
`userTypeAccessCards`, `assignedAccessCards`, `assignedAccessCardsByUnit`,
|
|
26
|
+
`accessandLiftCards`, `doorAndLiftDropdown`, `visitorAccessCards`,
|
|
27
|
+
`bulkPhysicalAccessCard`, `getCardDetails`, `allQrTag`,
|
|
28
|
+
`availableCardContractors`, `getBlockLevelAndUnitList`, `getResidents`,
|
|
29
|
+
`userAccessCards`, `uploadTemplate`, `removeTemplate`, `qrCodeList`;
|
|
30
|
+
- from the URL — `getCardReplacement`, `getAccessManagementSettings`, `addQrTag`.
|
|
31
|
+
|
|
32
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`: a membership
|
|
33
|
+
pinned to the site, an org-wide membership at the site's owning organisation, or
|
|
34
|
+
an ACTIVE `customer.sites` engagement. The engagement branch is required, not
|
|
35
|
+
incidental — access cards are administered from the security and
|
|
36
|
+
property-management apps, whose users hold no membership in the estate's own
|
|
37
|
+
organisation.
|
|
38
|
+
|
|
39
|
+
The guard writes its own response instead of throwing. Every handler in this
|
|
40
|
+
controller wraps its body in `try { ... } catch { res.status(400|500) }` rather
|
|
41
|
+
than calling `next(error)`, so a thrown `UnauthorizedError`/`NotFoundError`
|
|
42
|
+
would never reach the error handler and would have surfaced as a 400 with the
|
|
43
|
+
guard's message inside it. It is placed AFTER each handler's Joi validation, so
|
|
44
|
+
a malformed or missing site still answers 400 exactly as before, and only a
|
|
45
|
+
well-formed site the caller cannot reach is refused. That refusal is 404 — what
|
|
46
|
+
a site that does not exist already answers — so site ids cannot be enumerated by
|
|
47
|
+
watching the difference.
|
|
48
|
+
|
|
49
|
+
Six routes on the mount are deliberately NOT changed, because they carry no
|
|
50
|
+
site at all and need per-record resolution:
|
|
51
|
+
|
|
52
|
+
- `POST /sign-qr` and `PATCH /visitor-checkout` — both are called by shipped
|
|
53
|
+
mobile clients that cannot update in lockstep
|
|
54
|
+
(`resident-mobile-app src/actions/entrypass.ts:66`, `isecure365-mobile-app
|
|
55
|
+
src/features/visitors/entry-pass.service.ts:175` and `visitor.service.ts:194`).
|
|
56
|
+
- `PATCH /nfc-status`, `PATCH /delete-card`, `POST /card-replacement`,
|
|
57
|
+
`POST /assign-user` — each identifies a card by id and would need the card's
|
|
58
|
+
own site loaded first.
|
|
59
|
+
- `GET /door-access-levels`, `GET /lift-access-levels`, `GET /access-groups` and
|
|
60
|
+
`POST /settings` take an `acm_url` — an access-control device URL — so they
|
|
61
|
+
are a separate question from site reach and were not touched here.
|
|
62
|
+
|
|
63
|
+
- 01ea9fc: Take the attendance caller's identity from the session, and scope every attendance
|
|
64
|
+
route to its estate.
|
|
65
|
+
|
|
66
|
+
Two separate defects, both on every route of `/api/attendances`.
|
|
67
|
+
|
|
68
|
+
**The caller's identity came from a cookie the caller writes.** Every handler read
|
|
69
|
+
`user` out of the raw `Cookie` header. That cookie is set by the browser itself
|
|
70
|
+
(`layer-common/composables/useLocalAuth.ts`, `setSession`) and by the mobile
|
|
71
|
+
clients from their own stored id; the server never compared it to the session.
|
|
72
|
+
`requireAuth` validates `sid` against Redis and puts the real caller on `req.user`
|
|
73
|
+
— which this controller ignored. A signed-in account could send
|
|
74
|
+
`Cookie: user=<somebody else>` and clock that person in, read their shift history
|
|
75
|
+
and check-in photo, or clock them out; and because check-out `$set`s `user` from
|
|
76
|
+
the request, checking somebody out also re-attributed their shift. The identity
|
|
77
|
+
now comes from `callerId(req)`, as the rest of the codebase resolves it.
|
|
78
|
+
|
|
79
|
+
**The estate was never checked.** All five routes now go through
|
|
80
|
+
`requireSiteReach`, the rule `/api/vehicles`, `/api/documents`,
|
|
81
|
+
`/api/service-providers` and `/api/incident-reports` already use. The four that
|
|
82
|
+
name a site in the URL check it before using it; `GET /id/:id` and
|
|
83
|
+
`PUT /id/:id/check-out` name only a record, so the site is read off the **stored**
|
|
84
|
+
row. The `customer.sites` branch matters here — the contracted agency whose staff
|
|
85
|
+
actually work these shifts holds no membership in the estate's organisation.
|
|
86
|
+
|
|
87
|
+
Every existing client already sends its own id in that cookie, so nothing
|
|
88
|
+
legitimate changes. A bare `Authorization: Bearer` call that sends no cookie at
|
|
89
|
+
all used to fail Joi's `user is required` with a 400 and now succeeds, which is
|
|
90
|
+
the only behaviour that gets looser rather than tighter.
|
|
91
|
+
|
|
92
|
+
- 159a56f: Scope `/api/customer-sites` to the organisations on the record.
|
|
93
|
+
|
|
94
|
+
Every route on this module except the pre-login invite acceptance carried
|
|
95
|
+
`requireAuth` and nothing more, and took the organisation straight out of the
|
|
96
|
+
query string or the body (`customer-site.controller.ts`). Two things followed
|
|
97
|
+
from that, and both are closed here.
|
|
98
|
+
|
|
99
|
+
`POST /api/customer-sites` is not merely an engagement record: it CREATES A SITE
|
|
100
|
+
inside whatever `siteOrg` the body names, and writes that site onto the
|
|
101
|
+
organisation as its default when it has none
|
|
102
|
+
(`customer-site.service.ts:77-111`). Any signed-in account on the platform — a
|
|
103
|
+
resident, a cleaner, a guard — could therefore plant a site inside any client.
|
|
104
|
+
|
|
105
|
+
A `customer.sites` row is also the cross-organisation access grant itself: the
|
|
106
|
+
"engaged agency" branch of `resolveSiteAccess` reads it to decide who may reach
|
|
107
|
+
a site's cameras, HID readers and residents
|
|
108
|
+
(`camera-view.service.ts:325-341`). Being able to write, edit or delete those
|
|
109
|
+
rows by id was being able to grant yourself reach into another client's estate,
|
|
110
|
+
or revoke a contractor's.
|
|
111
|
+
|
|
112
|
+
The caller is now resolved from the session and the decision made before
|
|
113
|
+
anything is read or written:
|
|
114
|
+
|
|
115
|
+
- `POST /` requires `requireOrgAccess` on the body's `org`, and on `siteOrg`
|
|
116
|
+
too when it names a different organisation — because that is where the new
|
|
117
|
+
site lands. The Add / Edit Site form sends the same id for both
|
|
118
|
+
(`web-app-org components/SiteForm.vue:397-402`).
|
|
119
|
+
- `GET /` requires `requireOrgAccess` on the `org` filter the caller sends.
|
|
120
|
+
- `GET /:id`, `PUT /:id` and `DELETE /:id` load the row first and decide from
|
|
121
|
+
the row's own `org` and `siteOrg` — Seven365 staff, or a live member of
|
|
122
|
+
EITHER side of the engagement. Either, not one: the estate's manager reaches
|
|
123
|
+
the row through `siteOrg` and the contracted agency reaches the same row
|
|
124
|
+
through `org`, so scoping to one side would break the other's screen.
|
|
125
|
+
- `GET /service-provider/:id` takes a SITE id, and uses `entitleSite` — the
|
|
126
|
+
same site-reach rule the camera, HID and people modules already use, so an
|
|
127
|
+
agency contracted to a site still lists who else works there.
|
|
128
|
+
|
|
129
|
+
`POST /invite/:id` is unchanged and still reachable with no session: an invited
|
|
130
|
+
organisation accepts before it has an account (`web-app-main pages/sign-in.vue:182`).
|
|
131
|
+
|
|
132
|
+
No new authorization mechanism is introduced. `requireOrgAccess` and
|
|
133
|
+
`entitleSite` are the helpers the member, role, people, camera and HID paths
|
|
134
|
+
already use, and the "either side" test is `resolveInviteActor` asked about two
|
|
135
|
+
organisations in one round trip rather than two.
|
|
136
|
+
|
|
137
|
+
- 6c9b40c: Scope the estate-structure writes to the site they act on.
|
|
138
|
+
|
|
139
|
+
`POST/PATCH/PUT` on `/api/buildings`, `/api/building-levels` and
|
|
140
|
+
`/api/building-units` all carried `requireAuth` and nothing more. The site came
|
|
141
|
+
from the request body on the creates and the record id came from the URL on the
|
|
142
|
+
edits and deletes, and neither was checked against the caller. So any signed-in
|
|
143
|
+
account on the platform could add a block to another client's estate, rename its
|
|
144
|
+
levels in bulk, or delete its units — the records residents, visitor passes,
|
|
145
|
+
billing and access rights are all hung off.
|
|
146
|
+
|
|
147
|
+
Ten routes now decide from the site:
|
|
148
|
+
|
|
149
|
+
- `POST /api/buildings/`, `POST /api/building-levels/`,
|
|
150
|
+
`POST /api/building-units/` — from the site named in the body, which is where
|
|
151
|
+
the new record lands.
|
|
152
|
+
- `PATCH /api/buildings/id/:id`, `PUT /api/buildings/:id`,
|
|
153
|
+
`PUT /api/building-levels/id/:id`, `PUT /api/building-levels/:id`,
|
|
154
|
+
`PATCH /api/building-units/id/:id`, `PUT /api/building-units/:id` — the stored
|
|
155
|
+
record is loaded first and its own site decides. The id in the URL selects the
|
|
156
|
+
record; it never authorises it.
|
|
157
|
+
- `PATCH /api/building-levels/batch` — EVERY id in the batch is checked, not
|
|
158
|
+
just the first, and only the distinct sites are looked up so renaming twelve
|
|
159
|
+
levels of one block stays one check.
|
|
160
|
+
|
|
161
|
+
The rule is `entitleSite`, the site-reach rule the camera, HID and people
|
|
162
|
+
modules already use: a membership pinned to the site, an org-wide membership at
|
|
163
|
+
the site's owning organisation, or an ACTIVE `customer.sites` engagement. The
|
|
164
|
+
engagement branch is required, not incidental — the estate structure is
|
|
165
|
+
maintained from the property-management and service-provider apps, whose users
|
|
166
|
+
may hold no membership in the estate's own organisation.
|
|
167
|
+
|
|
168
|
+
The shared four lines live in a new `src/utils/site-reach.util.ts` so the same
|
|
169
|
+
check is not written out in three controllers. It introduces no second
|
|
170
|
+
mechanism: it resolves the caller with `resolveInviteActor` and defers to
|
|
171
|
+
`entitleSite`, exactly as `person.controller.ts` does.
|
|
172
|
+
|
|
173
|
+
A site the caller cannot reach answers as a site that does not exist, so site
|
|
174
|
+
ids cannot be enumerated by watching the difference. A record with no site at
|
|
175
|
+
all is refused rather than waved through.
|
|
176
|
+
|
|
177
|
+
The READS on these three routers are deliberately unchanged. Six of them share
|
|
178
|
+
a controller function with a pre-login signup alias
|
|
179
|
+
(`/buildings/list/site/resident/:siteId`, `/building-levels/list/site/resident/:siteId`,
|
|
180
|
+
`/building-units/resident/site/:site/block/:block/level/:level` and the three
|
|
181
|
+
`resident` variants), which the resident mobile app calls before an account
|
|
182
|
+
exists. Guarding the handler would break registration on a shipped client, so
|
|
183
|
+
those need the alias split first.
|
|
184
|
+
|
|
185
|
+
- b6c1490: Scope the feedback routes to the estate the feedback was raised at, and take the
|
|
186
|
+
author's identity from the session.
|
|
187
|
+
|
|
188
|
+
A feedback row is a resident's complaint about their own estate: the subject,
|
|
189
|
+
the free-text description, the location inside the block, the attachments and
|
|
190
|
+
the name of the person who raised it. Every route on `/api/feedbacks` carried
|
|
191
|
+
`requireAuth` and nothing more, and the controller held no authorization
|
|
192
|
+
identifier of any kind, so being signed in anywhere on the platform was enough
|
|
193
|
+
to read another client's complaints, rewrite them, re-route them to a different
|
|
194
|
+
service line, close them, or delete them.
|
|
195
|
+
|
|
196
|
+
`DELETE /:id` is the sharp one, twice over. It is the **only** route on this
|
|
197
|
+
mount with a live caller — `layer-common`'s `useFeedback().deleteFeedback`,
|
|
198
|
+
wired to the delete button in `FeedbackDetail.vue` and `FeedbackMain.vue`, which
|
|
199
|
+
every web app draws — and it does not write to the v1 table at all. It writes to
|
|
200
|
+
`feedbacks2`, the authoritative table every client reads. So the one reachable
|
|
201
|
+
route on the legacy mount was an unguarded delete against live data. It is now
|
|
202
|
+
checked against the `feedbacks2` row it actually touches, not the v1 row that
|
|
203
|
+
happens to share the id.
|
|
204
|
+
|
|
205
|
+
All eight routes go through `requireSiteReach`, the rule
|
|
206
|
+
`/api/manpower-monitoring`, `/api/attendances`, `/api/remarks`,
|
|
207
|
+
`/api/vehicles`, `/api/documents` and `/api/incident-reports` already use:
|
|
208
|
+
|
|
209
|
+
- `GET /site/:site/status/:status` names the site in the URL, so it is checked
|
|
210
|
+
before the read.
|
|
211
|
+
- The six record routes name only a record, so the site is read off the
|
|
212
|
+
**stored** row and a `site` in the caller's own body cannot stand in for it.
|
|
213
|
+
Two readers are needed, because the two tables are not the same shape: v1
|
|
214
|
+
`feedbacks` rows keep the site at `metadata.site`, `feedbacks2` rows keep it
|
|
215
|
+
at the top level.
|
|
216
|
+
- `POST /` took the author from a `user` cookie the caller writes and never
|
|
217
|
+
compared it to the session, so a complaint could be filed in somebody else's
|
|
218
|
+
name. It comes from `callerId(req)` now, which also removes a latent crash:
|
|
219
|
+
`cookies?.["user"].toString()` short-circuits only on `cookies` being nullish
|
|
220
|
+
and threw on any request carrying cookies but no `user` cookie.
|
|
221
|
+
|
|
222
|
+
`requireSiteReach` matters here because the contracted service provider that
|
|
223
|
+
answers these complaints holds no membership in the estate's organisation and
|
|
224
|
+
reaches it through `customer.sites`.
|
|
225
|
+
|
|
226
|
+
Not fixed here, and reported separately: `POST /api/feedbacks` cannot succeed
|
|
227
|
+
and could not before this change either. `feedback.service.ts` replaces
|
|
228
|
+
`createdBy` with an `ObjectId` and `MFeedback` then re-validates the document
|
|
229
|
+
against a schema where `createdBy` is `Joi.string().hex()`, so the create always
|
|
230
|
+
answers 400. No client calls it — every client creates through
|
|
231
|
+
`/api/feedbacks2`.
|
|
232
|
+
|
|
233
|
+
- 9a0df17: Scope the incident report routes to the estate the report was written at.
|
|
234
|
+
|
|
235
|
+
Every route on `/api/incident-reports` carried `requireAuth` and nothing more, so
|
|
236
|
+
being signed in anywhere on the platform was enough to page another estate's
|
|
237
|
+
incident log — complainant name, contact, NRIC, unit, the guard's narrative and
|
|
238
|
+
the photographs — and to open, rewrite, approve, reject or delete any single
|
|
239
|
+
report, or file one at any estate.
|
|
240
|
+
|
|
241
|
+
All eight now go through `requireSiteReach`, the rule `/api/vehicles`,
|
|
242
|
+
`/api/documents` and `/api/service-providers` already use:
|
|
243
|
+
|
|
244
|
+
- the two lists (`GET /`, `GET /incident-report`) and the two creates
|
|
245
|
+
(`POST /`, `POST /summary`) name a site in the request; that site is checked
|
|
246
|
+
before it is used.
|
|
247
|
+
- the four id routes (`GET /id/:id`, `PUT /id/:id`, `PUT /review/:id`,
|
|
248
|
+
`PUT /:id`) name only a record, so the site is read off the **stored** record.
|
|
249
|
+
`PUT /id/:id` carries a `site` of its own in the body, and authorising on that
|
|
250
|
+
would have let a caller quote their own estate beside another estate's report
|
|
251
|
+
id — the shape already found on `/api/vehicles`.
|
|
252
|
+
|
|
253
|
+
`requireSiteReach` matters more here than anywhere else: a contracted security
|
|
254
|
+
agency holds no membership in the estate's organisation and reaches its own site
|
|
255
|
+
through `customer.sites` — and the agency's guards are who **write** these
|
|
256
|
+
reports. A flat organisation check would have locked them out of their own work.
|
|
257
|
+
|
|
258
|
+
A report the caller cannot reach answers exactly as one that does not exist
|
|
259
|
+
(404), so ids cannot be enumerated from the difference. Guards sit after each
|
|
260
|
+
handler's Joi validation, so a malformed id still answers 400 exactly as today.
|
|
261
|
+
|
|
262
|
+
Note on the count: the route inventory listed six handlers on this mount as
|
|
263
|
+
unguarded. There are eight, and `POST /` and `GET /` were open too — the
|
|
264
|
+
controller contains no authorization identifier of any kind at the `staging` tip.
|
|
265
|
+
All eight are closed here.
|
|
266
|
+
|
|
267
|
+
Nothing changes for a permitted caller: same response shape, same fields, same
|
|
268
|
+
status.
|
|
269
|
+
|
|
270
|
+
- b62c6a4: Scope the manpower monitoring settings routes to the site they belong to.
|
|
271
|
+
|
|
272
|
+
Every route on `/api/manpower-monitoring` carried `requireAuth` and nothing more,
|
|
273
|
+
so being signed in anywhere on the platform was enough to read another client's
|
|
274
|
+
shift pattern and alert email recipients, to re-time their late-check-in alert, or
|
|
275
|
+
to switch their monitoring off entirely — which silences the alert that tells a
|
|
276
|
+
client nobody turned up for a shift.
|
|
277
|
+
|
|
278
|
+
Five of the six now go through `requireSiteReach`, the rule `/api/vehicles`,
|
|
279
|
+
`/api/documents`, `/api/service-providers` and `/api/incident-reports` already
|
|
280
|
+
use:
|
|
281
|
+
|
|
282
|
+
- `POST /`, `GET /sites/:id/:serviceProviderId` and `POST /multiple` name a site
|
|
283
|
+
in the request; that site is checked before it is used. `POST /multiple`
|
|
284
|
+
upserts **by** each item's own `siteId`, so every item is checked before any of
|
|
285
|
+
them is written.
|
|
286
|
+
- `PUT /:id` names only a record, so the site is read off the **stored** row —
|
|
287
|
+
a `siteId` in the caller's own body cannot stand in for it.
|
|
288
|
+
- `GET /search` names no site at all and returned every client's row on one page.
|
|
289
|
+
It is narrowed after the read, the way the people and vehicle lookups are.
|
|
290
|
+
|
|
291
|
+
`requireSiteReach` matters here because the contracted agency that actually runs
|
|
292
|
+
these shifts holds no membership in the estate's organisation and reaches its own
|
|
293
|
+
site through `customer.sites`.
|
|
294
|
+
|
|
295
|
+
`GET /sites/active` is deliberately unchanged. It reads nothing from our database
|
|
296
|
+
— it authenticates to the external HRM Labs directory and returns that system's
|
|
297
|
+
own site tree — and the only web caller sends a hard-coded service provider id
|
|
298
|
+
rather than the signed-in one, so scoping it on that id would close the screen for
|
|
299
|
+
its real users. It needs a product decision, not a guard chosen here.
|
|
300
|
+
|
|
301
|
+
A settings row the caller cannot reach answers exactly as one that does not exist,
|
|
302
|
+
so site ids cannot be enumerated by watching the difference.
|
|
303
|
+
|
|
304
|
+
- 638320e: Scope the manpower remarks routes to the site the remarks belong to.
|
|
305
|
+
|
|
306
|
+
A remarks row is the shift-attendance exception record for one site on one day:
|
|
307
|
+
which named guards were late or absent, and whether the estate has acknowledged
|
|
308
|
+
it. Every route on `/api/remarks` carried `requireAuth` and nothing more — the
|
|
309
|
+
controller held no authorization identifier of any kind — so being signed in
|
|
310
|
+
anywhere on the platform was enough to read another client's daily manning
|
|
311
|
+
failures, to write an acknowledgement into their record in a name of the
|
|
312
|
+
caller's choosing, or to switch a row to `inactive` so the estate's own console
|
|
313
|
+
stops listing it.
|
|
314
|
+
|
|
315
|
+
All five routes now go through `requireSiteReach`, the rule
|
|
316
|
+
`/api/manpower-monitoring`, `/api/attendances`, `/api/vehicles`,
|
|
317
|
+
`/api/documents` and `/api/incident-reports` already use:
|
|
318
|
+
|
|
319
|
+
- `GET /:id/:serviceProviderId` names the site in the URL, so it is checked
|
|
320
|
+
before the read.
|
|
321
|
+
- `PUT /:id` and `PATCH /:id` name only a record, so the site is read off the
|
|
322
|
+
**stored** row — a `siteId` in the caller's own body cannot stand in for it.
|
|
323
|
+
- `GET /` names no site at all. Its only filter is a `serviceProviderId` the
|
|
324
|
+
caller supplies, which is not a claim about the caller, and one agency works
|
|
325
|
+
for several clients — so the page came back holding every one of those
|
|
326
|
+
clients' rows. It is narrowed after the read, the way
|
|
327
|
+
`/api/manpower-monitoring`'s own `GET /search` is. The count in the envelope
|
|
328
|
+
stays the unfiltered one, as it does there; both callers read it only for the
|
|
329
|
+
pager.
|
|
330
|
+
- `POST /` writes a row for whatever `siteId` the body carries. It has no caller
|
|
331
|
+
in any web app or mobile app — the rows are written internally by
|
|
332
|
+
`manpower-monitoring.service.ts` when a site's monitoring settings are created
|
|
333
|
+
— and it is guarded all the same rather than left open.
|
|
334
|
+
|
|
335
|
+
`requireSiteReach` matters here because the contracted agency that actually runs
|
|
336
|
+
these shifts holds no membership in the estate's organisation and reaches its
|
|
337
|
+
own site through `customer.sites`.
|
|
338
|
+
|
|
339
|
+
A row the caller cannot reach answers exactly as one that does not exist, so
|
|
340
|
+
site ids cannot be enumerated by watching the difference.
|
|
341
|
+
|
|
342
|
+
- c01b07e: Scope the member list and the member suspend/reactivate write to their own organisation.
|
|
343
|
+
|
|
344
|
+
`GET /api/members` took its `org` and `siteId` straight out of the query string,
|
|
345
|
+
and `PUT /api/members/:id/:status` took the member id straight out of the URL.
|
|
346
|
+
Both carried `requireAuth` and nothing more. So any signed-in account on the
|
|
347
|
+
platform could list another client's staff, and suspend or reactivate any member
|
|
348
|
+
of any client.
|
|
349
|
+
|
|
350
|
+
`GET /api/members` also had a hole in its own scope requirement. Line 170 refused
|
|
351
|
+
a query that named neither an organisation nor a user — **unless**
|
|
352
|
+
`type === "admin"`, which then listed Seven365's own staff to anybody signed in.
|
|
353
|
+
|
|
354
|
+
Both now decide from the session:
|
|
355
|
+
|
|
356
|
+
- `type=admin` requires Seven365 staff. That query belongs to the staff console
|
|
357
|
+
(`web-app-org pages/super-admin`), so it is gated rather than removed.
|
|
358
|
+
- a caller-supplied `org` is checked with `requireOrgAccess` before it is used —
|
|
359
|
+
Seven365 staff, or a live member of that organisation.
|
|
360
|
+
- `user` on its own stays open for the caller's OWN id, because "which
|
|
361
|
+
organisations am I a member of" is every service app's landing page
|
|
362
|
+
(`web-app-security pages/index.vue:118` and its siblings). Asking it about
|
|
363
|
+
somebody else now requires staff.
|
|
364
|
+
- `PUT /api/members/:id/:status` loads the stored membership and authorises on
|
|
365
|
+
the organisation recorded on it — never one from the request. A membership with
|
|
366
|
+
no organisation is a platform staff row, and only Seven365 staff may touch it;
|
|
367
|
+
a missing org is refused, not waved through.
|
|
368
|
+
|
|
369
|
+
Service providers are not special-cased and do not need to be: a provider holds a
|
|
370
|
+
membership in the organisation whose site it works on — that is how
|
|
371
|
+
`GET /api/members/user/:id/app/:type?org=` finds its own row at sign-in — so the
|
|
372
|
+
guard app's member list (`isecure365-mobile-app
|
|
373
|
+
src/features/patrol/use-patrol.ts:180`, which sends `org: site.org` with a
|
|
374
|
+
`siteId`) passes on the ordinary rule.
|
|
375
|
+
|
|
376
|
+
Nothing changes for a permitted caller: same response shape, same fields, same
|
|
377
|
+
status.
|
|
378
|
+
|
|
379
|
+
- e76658f: Only Seven365 staff can create a Seven365 staff membership.
|
|
380
|
+
|
|
381
|
+
`members.type` decides who is platform staff. `isSuperAdmin` answers true for a
|
|
382
|
+
live `members` row of type `"admin"` whose role is also of type `"admin"`, and
|
|
383
|
+
that field was validated as `Joi.string().required()` — a free-form string with
|
|
384
|
+
no allow-list. Every request path that writes a membership takes the value
|
|
385
|
+
straight from the caller:
|
|
386
|
+
|
|
387
|
+
- `POST /api/members/direct` writes the body's `app` verbatim as `type`
|
|
388
|
+
(`member.service.ts:137`);
|
|
389
|
+
- `POST /api/auth/invite` stores the body's `app` on the invitation, and
|
|
390
|
+
accepting it writes that value as `type` (`member.service.ts:73`);
|
|
391
|
+
- `PUT /api/members/id/:id/role/:role/type/:type/org/:org` re-pointed a
|
|
392
|
+
membership at any role, including the seeded platform one, checking only that
|
|
393
|
+
an owner role existed and that the target was not the last owner — never who
|
|
394
|
+
was asking.
|
|
395
|
+
|
|
396
|
+
All three are `requireAuth`-only. So any signed-in account — a resident, a
|
|
397
|
+
cleaner, a guard — could post `{ app: "admin" }`, or invite itself as `admin`
|
|
398
|
+
and accept its own invitation, and be Seven365 platform staff on the next
|
|
399
|
+
request. That defeats every gate built on `isSuperAdmin`: the staff console,
|
|
400
|
+
service-provider approvals, the notification broadcast and client suspension.
|
|
401
|
+
Executed end to end against a throwaway in-memory database, the chain succeeded
|
|
402
|
+
at every step.
|
|
403
|
+
|
|
404
|
+
Three changes, all fail-closed:
|
|
405
|
+
|
|
406
|
+
- `members.type` now validates against `MEMBER_TYPES`, enumerated from the
|
|
407
|
+
values this package actually writes — `admin` (`user.service.ts`
|
|
408
|
+
`createDefaultUser` and the console's own invitations), `organization` (the
|
|
409
|
+
default in `member.service.ts`, `user.service.ts`, `user-v2.service.ts` and
|
|
410
|
+
`subscription.service.ts`), `resident` (`person.service.ts`), and the eight
|
|
411
|
+
`AppServiceType` service memberships, the same list `organization.model.ts`
|
|
412
|
+
already accepts as an organisation's `nature`.
|
|
413
|
+
- `createMemberDirect` refuses `app: "admin"`, and refuses a role of type
|
|
414
|
+
`"admin"`, unless the caller is already platform staff. The caller is resolved
|
|
415
|
+
from the session by the controller and passed in; it is never read from a body
|
|
416
|
+
or a URL.
|
|
417
|
+
- `createUserInvite` refuses `app: "admin"` unless the caller is already
|
|
418
|
+
platform staff, so the invitation road to the same place is shut too.
|
|
419
|
+
|
|
420
|
+
`updateRoleById` refuses to move a membership onto a role of type `"admin"` for
|
|
421
|
+
the same reason — that was step 6 of the chain, the one that turned
|
|
422
|
+
`isPlatformOwner` true by pointing a membership at the seeded owner role.
|
|
423
|
+
|
|
424
|
+
The seeded role marker `default: true` was re-checked rather than assumed and is
|
|
425
|
+
still not forgeable: Joi rejects unknown keys on all three role schemas.
|
|
426
|
+
|
|
427
|
+
Every legitimate caller across the sibling repos was checked and still works.
|
|
428
|
+
`web-app-main pages/sign-in.vue` and `pages/verify/email/index.vue`, and
|
|
429
|
+
`web-app-org pages/onboarding/getting-started/index.vue`, all pass
|
|
430
|
+
`app: "organization"` or a service type — none reaches a gate. The one caller
|
|
431
|
+
that does send `"admin"` is the staff console itself
|
|
432
|
+
(`web-app-org pages/super-admin/invitations/index.vue:403`), operated by an
|
|
433
|
+
account that already is staff.
|
|
434
|
+
|
|
435
|
+
One transitional case is kept deliberately. `web-app-org
|
|
436
|
+
pages/org/[organization]/invitations/invite.vue:122` and `web-app-hygiene
|
|
437
|
+
pages/[org]/[site]/invitations/invite.vue:135` send the ORGANISATION ID as the
|
|
438
|
+
invitation's `app`, so accepting one of those invitations writes an org id into
|
|
439
|
+
`members.type`. That is a defect in those two screens, but it is live: the
|
|
440
|
+
validator therefore also accepts a 24-character hex string, with the two file
|
|
441
|
+
references named at the line, to be removed once both screens send a real
|
|
442
|
+
membership type. It cannot be used to escalate — `"admin"` is not 24 hex
|
|
443
|
+
characters, so the allow-list still decides that.
|
|
444
|
+
|
|
445
|
+
No response shape, status code or field changed for a permitted caller.
|
|
446
|
+
|
|
447
|
+
- 43dc4bc: Scope the three remaining `/api/members` writes to the membership they name.
|
|
448
|
+
|
|
449
|
+
`PUT /api/members/id/:id/role/:role/type/:type/org/:org`,
|
|
450
|
+
`PUT /api/members/site` and `PUT /api/members/:id/onboarding/complete` all
|
|
451
|
+
carried `requireAuth` and nothing more, and each took the membership id straight
|
|
452
|
+
out of the request.
|
|
453
|
+
|
|
454
|
+
**The role write is the sharp one.** A role IS the permission set, so
|
|
455
|
+
re-pointing a membership at another role is an access grant. The service already
|
|
456
|
+
refused to hand a _Seven365 staff_ role to a non-staff caller, but a **tenant**
|
|
457
|
+
role was still free: quoting another client's membership id re-pointed it at any
|
|
458
|
+
of that client's roles, their **owner** role included — and equally could demote
|
|
459
|
+
that client's own admin. The `org` in the URL was also what the service used to
|
|
460
|
+
look up owner roles for its "last owner" precondition, so a mismatched one ran
|
|
461
|
+
that safety check against the wrong client's roles entirely.
|
|
462
|
+
|
|
463
|
+
`PUT /api/members/site` took the membership id **and** the target site from the
|
|
464
|
+
body, so nothing said whose membership, or whose site, either one was — any
|
|
465
|
+
signed-in account could move any client's member onto any estate.
|
|
466
|
+
|
|
467
|
+
`PUT /api/members/:id/onboarding/complete` took the id from the URL and nothing
|
|
468
|
+
else, so any signed-in account could mark any client's membership onboarded and
|
|
469
|
+
skip that client's onboarding gate.
|
|
470
|
+
|
|
471
|
+
All three now decide from the **stored** membership, through the
|
|
472
|
+
`requireMemberOrg` helper this controller already uses for
|
|
473
|
+
`PUT /api/members/:id/:status`:
|
|
474
|
+
|
|
475
|
+
- the organisation recorded on the membership is authorised with
|
|
476
|
+
`requireOrgAccess` — Seven365 staff, or a live member of that organisation.
|
|
477
|
+
- a membership with **no** organisation is a platform staff row; only Seven365
|
|
478
|
+
staff may touch it. A missing org is refused, not waved through.
|
|
479
|
+
- the role write additionally refuses when the `org` in the URL disagrees with
|
|
480
|
+
the one on the membership, so the service's owner-role lookup can no longer be
|
|
481
|
+
pointed at another client.
|
|
482
|
+
- `PUT /api/members/site` puts the incoming site through `requireSiteReach`, the
|
|
483
|
+
same rule `/api/vehicles` and `/api/documents` use, so a member cannot be moved
|
|
484
|
+
onto an estate the caller cannot reach. An unreachable site answers as one that
|
|
485
|
+
does not exist (404), so site ids cannot be enumerated from the difference.
|
|
486
|
+
|
|
487
|
+
Guards sit **after** each handler's Joi validation, so a malformed id still
|
|
488
|
+
answers 400 exactly as it does today.
|
|
489
|
+
|
|
490
|
+
Scope note — deliberately NOT changed here: the four read-by-user routes
|
|
491
|
+
(`GET /api/members/user/:id`, `/users/:id`, `/user/:id/app/:type`,
|
|
492
|
+
`/user/:id/app/:type/org`). `layer-common ServiceProviderMain.vue:922,1320` and
|
|
493
|
+
`InvitationClientForm.vue:324` legitimately read the memberships of a user who
|
|
494
|
+
belongs to a **service provider's** organisation, not to the caller's, and no
|
|
495
|
+
existing helper expresses "an organisation my organisation has engaged". That
|
|
496
|
+
needs a product decision rather than a guess. Every other caller passes its own
|
|
497
|
+
user id. `POST /api/members/direct` and `POST /api/members/verification/:id` are
|
|
498
|
+
also unchanged — the latter is driven entirely by a single-use, expiring
|
|
499
|
+
invitation record and grants the caller nothing.
|
|
500
|
+
|
|
501
|
+
Nothing changes for a permitted caller: same response shape, same fields, same
|
|
502
|
+
status.
|
|
503
|
+
|
|
504
|
+
- 5a46062: Scope the NFC patrol log routes to the estate the round was walked at, and take
|
|
505
|
+
the author from the session.
|
|
506
|
+
|
|
507
|
+
An NFC patrol log is the proof that a round was actually walked: the route, the
|
|
508
|
+
day, each checkpoint the guard tapped a tag against, the ones that were skipped
|
|
509
|
+
and why, and the signature that closes the round off. It is the document a
|
|
510
|
+
client is shown when they ask whether their estate was patrolled.
|
|
511
|
+
|
|
512
|
+
Every route on `/api/nfc-patrol-log` carried `requireAuth` and nothing more, and
|
|
513
|
+
`nfc-patrol-log.controller.ts` held no authorization identifier of any kind.
|
|
514
|
+
`requireAuth` proves a session exists; it makes no organisation or site
|
|
515
|
+
decision. So being signed in anywhere on the platform was enough to read another
|
|
516
|
+
client's patrol history, **mark a checkpoint on another estate's round as
|
|
517
|
+
completed**, or **sign that round off as done** — falsifying the patrol proof,
|
|
518
|
+
not merely reading it.
|
|
519
|
+
|
|
520
|
+
All five handlers now decide before any work is done. The route count was
|
|
521
|
+
verified at the registration site — `nfc-patrol-log.route.ts` mounts **five**
|
|
522
|
+
routes, not the four the earlier inventory recorded.
|
|
523
|
+
|
|
524
|
+
- `POST /` and `GET /` already REQUIRE a site (`schemaCreateNfcPatrolLog` and
|
|
525
|
+
the query schema both have `site` required), so the guard checks a site the
|
|
526
|
+
request already names and no caller's contract changes. The repository does
|
|
527
|
+
genuinely apply that `site` — it is the first term of the aggregation match,
|
|
528
|
+
not a parameter it accepts and ignores.
|
|
529
|
+
- `PATCH /:id/checkpoint`, `GET /:id` and `PATCH /:id/sign` name only a record,
|
|
530
|
+
so the site is read off the STORED log through `requireNfcPatrolLogReach`.
|
|
531
|
+
`getById` on this repository is NOT cached, so no separate raw reader was
|
|
532
|
+
needed here, unlike the occurrence mounts.
|
|
533
|
+
- `POST /`'s `createdBy` came from the request body and was never compared to
|
|
534
|
+
the session. It comes from `callerId(req)` now. The field is still accepted,
|
|
535
|
+
so no caller starts to 400; it is simply ignored.
|
|
536
|
+
|
|
537
|
+
None of the three record routes can MOVE a log: `updateCheckpoint` sets only the
|
|
538
|
+
checkpoint fields and `sign` sets only the signature fields, so there is no
|
|
539
|
+
destination to check.
|
|
540
|
+
|
|
541
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
|
|
542
|
+
`customer.sites` branch is load-bearing here more than on any other mount: the
|
|
543
|
+
people who walk these rounds are the contracted security agency's guards and
|
|
544
|
+
hold no membership in the estate's own organisation.
|
|
545
|
+
|
|
546
|
+
`GET /:id` previously answered **200 with `null`** for an id matching nothing,
|
|
547
|
+
because the handler passed the repository's `null` straight to `res.json`. It
|
|
548
|
+
now refuses — a log that does not exist has no site, and `requireSiteReach`
|
|
549
|
+
refuses a missing site.
|
|
550
|
+
|
|
551
|
+
Sibling mounts on the same family — `/nfc-patrol-tag`, `/nfc-patrol-settings`,
|
|
552
|
+
`/nfc-patrol-route` and `/patrol-routes` — are NOT touched here. The first three
|
|
553
|
+
carry `/public` routes behind `requireSessionOrLegacyToken`, which is inert
|
|
554
|
+
while `ENFORCE_NFC_PUBLIC_AUTH` is off, so guarding their shared handlers would
|
|
555
|
+
refuse a live legacy screen. That needs the SEC-028 decision first.
|
|
556
|
+
|
|
557
|
+
- ef9a540: Scope the daily occurrence book routes to the estate the book belongs to.
|
|
558
|
+
|
|
559
|
+
The daily occurrence book is one estate's security log for one day — the record
|
|
560
|
+
the guards write every shift, and the record an estate is asked to produce when
|
|
561
|
+
something goes wrong. Every route on `/api/occurrence-books` carried
|
|
562
|
+
`requireAuth` and nothing more, and `occurrence-book.controller.ts` held no
|
|
563
|
+
authorization identifier of any kind. `requireAuth` proves a session exists; it
|
|
564
|
+
makes no organisation or site decision. So being signed in anywhere on the
|
|
565
|
+
platform was enough to read another client's log, re-date it, re-open or close
|
|
566
|
+
it, or delete the day outright.
|
|
567
|
+
|
|
568
|
+
All five handlers now decide before any work is done. The route count was
|
|
569
|
+
verified at the registration site — `occurrence-book.route.ts` mounts **five**
|
|
570
|
+
routes, not the three the earlier inventory recorded.
|
|
571
|
+
|
|
572
|
+
- `POST /` and `GET /` already REQUIRE a site (in the body and the query
|
|
573
|
+
respectively), so the guard checks a site the request already names and no
|
|
574
|
+
caller's contract changes.
|
|
575
|
+
- `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
|
|
576
|
+
site is read off the STORED row through the new `requireOccurrenceBookReach`.
|
|
577
|
+
A `site` in the caller's own body or query cannot stand in for it.
|
|
578
|
+
|
|
579
|
+
`requireOccurrenceBookReach` is built on a new raw reader,
|
|
580
|
+
`getRawOccurrenceBookById`. The existing `getOccurrenceBookById` is CACHED and
|
|
581
|
+
throws `NotFoundError`, so a reach check built on it would have decided who may
|
|
582
|
+
touch a book from a cache entry, and would have answered 404 with the wrong
|
|
583
|
+
message before the guard ever ran.
|
|
584
|
+
|
|
585
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`, already used
|
|
586
|
+
by `/api/feedbacks`, `/api/work-orders`, `/api/manpower-monitoring`,
|
|
587
|
+
`/api/attendances`, `/api/remarks`, `/api/vehicles`, `/api/documents` and
|
|
588
|
+
`/api/incident-reports`. The `customer.sites` branch is load-bearing here: the
|
|
589
|
+
guards who WRITE this book are the contracted security agency's staff and hold
|
|
590
|
+
no membership in the estate's own organisation, so a flat organisation check
|
|
591
|
+
would lock the security app out of the log it exists to keep.
|
|
592
|
+
|
|
593
|
+
Each guard sits AFTER the handler's own Joi validation, so a malformed id still
|
|
594
|
+
answers 400 exactly as before. A row that does not exist has no site, and
|
|
595
|
+
`requireSiteReach` refuses a missing site, so an id matching nothing is refused
|
|
596
|
+
rather than falling through.
|
|
597
|
+
|
|
598
|
+
`PUT /id/:id` cannot MOVE a book — `schemaUpdateOccurrenceBook` does not accept
|
|
599
|
+
`site` at all — so there is no destination to check.
|
|
600
|
+
|
|
601
|
+
`/api/occurrence-entries` and `/api/occurrence-subjects` are separate mounts and
|
|
602
|
+
are not touched here; each gets its own change.
|
|
603
|
+
|
|
604
|
+
- cb7bcd3: Scope the occurrence entry routes to the estate the entry belongs to.
|
|
605
|
+
|
|
606
|
+
An occurrence entry is a single line the guards write into an estate's daily
|
|
607
|
+
security log — the time, the subject, the free-text account of what happened, the
|
|
608
|
+
signature of the guard who wrote it, and any incident report attached to it.
|
|
609
|
+
Every route on `/api/occurrence-entries` carried `requireAuth` and nothing more,
|
|
610
|
+
and `occurrence-entry.controller.ts` held no authorization identifier of any
|
|
611
|
+
kind. `requireAuth` proves a session exists; it makes no organisation or site
|
|
612
|
+
decision. So being signed in anywhere on the platform was enough to read another
|
|
613
|
+
client's log line by line, rewrite what an entry says, or delete an entry from
|
|
614
|
+
the record.
|
|
615
|
+
|
|
616
|
+
All six handlers now decide before any work is done. The route count was
|
|
617
|
+
verified at the registration site — `occurrence-entry.route.ts` mounts **six**
|
|
618
|
+
routes, not the four the earlier inventory recorded.
|
|
619
|
+
|
|
620
|
+
- `POST /` and `GET /` already REQUIRE a site (in the body and the query
|
|
621
|
+
respectively), so the guard checks a site the request already names and no
|
|
622
|
+
caller's contract changes.
|
|
623
|
+
- `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
|
|
624
|
+
site is read off the STORED row through the new `requireOccurrenceEntryReach`,
|
|
625
|
+
built on a new raw reader `getRawOccurrenceEntryById` (the existing
|
|
626
|
+
`getOccurrenceEntryById` is CACHED).
|
|
627
|
+
- `GET /count/bookId/:id` is the one shape not seen on the earlier mounts: the
|
|
628
|
+
id identifies a **book**, not an entry. Its scope therefore comes from the
|
|
629
|
+
BOOK's row, through `requireOccurrenceBookSiteReach`.
|
|
630
|
+
|
|
631
|
+
That last one matters. Deriving reach from "some entry carrying this book id"
|
|
632
|
+
would have refused the FIRST entry of every new day, because a freshly opened
|
|
633
|
+
book has no entries to read — the security app calls this route to work out the
|
|
634
|
+
next serial number before writing that first line. There is a regression case
|
|
635
|
+
for exactly that.
|
|
636
|
+
|
|
637
|
+
`requireOccurrenceBookSiteReach` deliberately uses the book repository's
|
|
638
|
+
ordinary (cached) reader. A book's `site` is immutable — no route on
|
|
639
|
+
`/api/occurrence-books` accepts `site` on update — so a cached row cannot carry
|
|
640
|
+
a site that differs from the stored one, and the usual objection to deciding
|
|
641
|
+
reach from a cache does not apply here.
|
|
642
|
+
|
|
643
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
|
|
644
|
+
`customer.sites` branch is load-bearing: the guards who WRITE these entries are
|
|
645
|
+
the contracted security agency's staff and hold no membership in the estate's
|
|
646
|
+
own organisation.
|
|
647
|
+
|
|
648
|
+
Each guard sits AFTER the handler's own Joi validation, so a malformed id still
|
|
649
|
+
answers 400 exactly as before. A row that does not exist has no site, and
|
|
650
|
+
`requireSiteReach` refuses a missing site, so an id matching nothing is refused
|
|
651
|
+
rather than falling through.
|
|
652
|
+
|
|
653
|
+
`PUT /id/:id` cannot MOVE an entry — `schemaUpdateOccurrenceEntry` does not
|
|
654
|
+
accept `site` at all — so there is no destination to check.
|
|
655
|
+
|
|
656
|
+
`/api/occurrence-books` and `/api/occurrence-subjects` are separate mounts and
|
|
657
|
+
are not touched here; each gets its own change.
|
|
658
|
+
|
|
659
|
+
- 60049e1: Scope the occurrence subject routes to the estate the subject belongs to, and
|
|
660
|
+
take the author from the session.
|
|
661
|
+
|
|
662
|
+
An occurrence subject is one of the named headings the guards pick from when
|
|
663
|
+
they write a line into an estate's daily security log — "Lift breakdown",
|
|
664
|
+
"Trespasser", "Fire alarm". The list belongs to one site. Every route on
|
|
665
|
+
`/api/occurrence-subjects` carried `requireAuth` and nothing more, and
|
|
666
|
+
`occurrence-subject.controller.ts` held no authorization identifier of any kind.
|
|
667
|
+
`requireAuth` proves a session exists; it makes no organisation or site
|
|
668
|
+
decision. So being signed in anywhere on the platform was enough to read another
|
|
669
|
+
client's subject list, add a heading to it, rename one, or delete one out from
|
|
670
|
+
under the guards who are picking from it.
|
|
671
|
+
|
|
672
|
+
Two defects, not one:
|
|
673
|
+
|
|
674
|
+
1. **No scope.** All five handlers now decide before any work is done. The route
|
|
675
|
+
count was verified at the registration site — `occurrence-subject.route.ts`
|
|
676
|
+
mounts **five** routes, not the three the earlier inventory recorded.
|
|
677
|
+
2. **Identity from the caller's body.** `POST /` took `addedBy` straight out of
|
|
678
|
+
the request body and never compared it to the session, so a heading could be
|
|
679
|
+
filed under somebody else's name. It comes from `callerId(req)` now. The
|
|
680
|
+
shipped mobile client's own comment records the old behaviour — "`addedBy` is
|
|
681
|
+
sent because the model stores it; the server does not take it from the
|
|
682
|
+
session". Same defect already fixed on `/api/attendances`,
|
|
683
|
+
`/api/site-entrypass-settings` and `/api/feedbacks`.
|
|
684
|
+
|
|
685
|
+
- `POST /` and `GET /` already REQUIRE a site (`schemaOccurrenceSubject` and the
|
|
686
|
+
query schema both have `site` required), so the guard checks a site the
|
|
687
|
+
request already names and no caller's contract changes.
|
|
688
|
+
- `GET /id/:id`, `PUT /id/:id` and `DELETE /id/:id` name only a record, so the
|
|
689
|
+
site is read off the STORED row through the new
|
|
690
|
+
`requireOccurrenceSubjectReach`, built on a new raw reader
|
|
691
|
+
`getRawOccurrenceSubjectById` (the existing `getOccurrenceSubjectById` is
|
|
692
|
+
CACHED, so a guard built on it would decide reach from a cache entry).
|
|
693
|
+
|
|
694
|
+
`GET /`'s `site` is genuinely applied by the repository — it is part of the
|
|
695
|
+
`baseQuery` and of the cache key — so the list needed a reach check on the site
|
|
696
|
+
it names, not a narrowing pass after the read.
|
|
697
|
+
|
|
698
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`. The
|
|
699
|
+
`customer.sites` branch is load-bearing: the guards who maintain this list are
|
|
700
|
+
the contracted security agency's staff and hold no membership in the estate's
|
|
701
|
+
own organisation.
|
|
702
|
+
|
|
703
|
+
Each guard sits AFTER the handler's own Joi validation, so a malformed id still
|
|
704
|
+
answers 400 exactly as before. A row that does not exist has no site, and
|
|
705
|
+
`requireSiteReach` refuses a missing site, so an id matching nothing is refused
|
|
706
|
+
rather than falling through.
|
|
707
|
+
|
|
708
|
+
`PUT /id/:id` cannot MOVE a subject — `schemaUpdateOccurrenceSubject` accepts
|
|
709
|
+
only `_id` and `subject` — so there is no destination to check.
|
|
710
|
+
|
|
711
|
+
This closes the occurrence-book family: `/occurrence-books`,
|
|
712
|
+
`/occurrence-entries` and `/occurrence-subjects`.
|
|
713
|
+
|
|
714
|
+
- 1e93185: Scope the organisation lookups so they stop mapping one client to another.
|
|
715
|
+
|
|
716
|
+
Four reads on `/api/organizations` carried `requireAuth` and nothing more, and
|
|
717
|
+
every one took its key straight from the URL:
|
|
718
|
+
|
|
719
|
+
- `GET /api/organizations/user/:user` — which organisations ANY person belongs to
|
|
720
|
+
- `GET /api/organizations/orgs/:email` — the same, keyed on ANY email address
|
|
721
|
+
- `GET /api/organizations/name/:name` — any organisation, by name
|
|
722
|
+
- `GET /api/organizations/email/:email` — any organisation, by email address
|
|
723
|
+
|
|
724
|
+
Together those are a cross-client directory and an account-enumeration oracle: a
|
|
725
|
+
resident of one estate could map which clients an address or a user id belongs
|
|
726
|
+
to across the whole platform.
|
|
727
|
+
|
|
728
|
+
Each is now decided from the session:
|
|
729
|
+
|
|
730
|
+
- `user/:user` — the caller's own id, or Seven365 staff. Every live caller asks
|
|
731
|
+
about itself: the seven service-provider app layouts, the org app, and the
|
|
732
|
+
resident mobile app all pass the signed-in user's own id.
|
|
733
|
+
- `orgs/:email` — the caller's own address, compared case-insensitively after
|
|
734
|
+
being resolved from the session, or Seven365 staff. All three live callers
|
|
735
|
+
pass the signed-in user's own address.
|
|
736
|
+
- `name/:name` — Seven365 staff. Its only caller is the staff console's client
|
|
737
|
+
list.
|
|
738
|
+
- `email/:email` — Seven365 staff. No caller exists in any repository in the
|
|
739
|
+
organisation.
|
|
740
|
+
|
|
741
|
+
A key that does not exist is refused exactly as one that does, so the refusal
|
|
742
|
+
itself is not an oracle.
|
|
743
|
+
|
|
744
|
+
`GET /api/organizations/id/:id` is deliberately NOT guarded here: the same
|
|
745
|
+
controller function is mounted a second time, unauthenticated, at
|
|
746
|
+
`/api/organizations/org/id/:id`, which the sign-up-invite screens call before an
|
|
747
|
+
account exists. Guarding the handler would guard both mounts and break
|
|
748
|
+
registration. That one needs the alias split first.
|
|
749
|
+
|
|
750
|
+
`GET /api/organizations/` (the full client list) is also unchanged and is
|
|
751
|
+
recorded as a decision for the leads: it is reached from a live signup path that
|
|
752
|
+
must move onto the organisation `POST /api/organizations/onboarding` already
|
|
753
|
+
returns before the list can be staff-gated.
|
|
754
|
+
|
|
755
|
+
- 07ff8c9: Scope `GET /api/people` to a site the caller can actually reach.
|
|
756
|
+
|
|
757
|
+
The endpoint returns resident and visitor records — name, NRIC, contact number,
|
|
758
|
+
unit, plate numbers — and filters on NRIC, contact and plate number. It carried
|
|
759
|
+
`requireAuth` and nothing more, and took its `org` and `site` from the query
|
|
760
|
+
string (`person.controller.ts:79-80, 93`). So any signed-in account on the
|
|
761
|
+
platform could page through another estate's residents, or look a person up by
|
|
762
|
+
NRIC across every client at once.
|
|
763
|
+
|
|
764
|
+
The caller is now resolved from the session and the scope decided before
|
|
765
|
+
anything is read:
|
|
766
|
+
|
|
767
|
+
- a named `site` is checked with `entitleSite` — the same site-reach rule the
|
|
768
|
+
camera and HID modules already use: a membership at the site, an org-wide
|
|
769
|
+
membership at the site's owning organisation, or an ACTIVE `customer.sites`
|
|
770
|
+
engagement. Reusing it matters rather than being tidy: an agency contracted to
|
|
771
|
+
an estate holds no membership in that estate's organisation, so an
|
|
772
|
+
org-membership check would have locked every guard out of the site they work
|
|
773
|
+
at. A site the caller cannot reach answers exactly as a site that does not
|
|
774
|
+
exist, so site ids cannot be enumerated.
|
|
775
|
+
- a named `org` is checked against the caller's own memberships.
|
|
776
|
+
- naming neither — the NRIC, contact and plate lookups — is narrowed to the
|
|
777
|
+
caller's own organisations instead of searching the whole platform.
|
|
778
|
+
- Seven365 staff keep cross-tenant reach, unchanged.
|
|
779
|
+
|
|
780
|
+
Every live caller passes a site: `layer-common` `VisitorForm.vue`,
|
|
781
|
+
`MemberInformation.vue` and `VehicleForm.vue` all pass the site the screen is
|
|
782
|
+
open on, and the People list passes org and site from its route. The two
|
|
783
|
+
composable functions that pass neither — `usePeople().findPersonByContact` and
|
|
784
|
+
`findUsersByPlateNumber` — have no caller in any repository.
|
|
785
|
+
|
|
786
|
+
`person.repo.getAll` gains an optional `orgs` filter for the last case. It
|
|
787
|
+
applies only when no explicit `org` was given, so it cannot fight one, and its
|
|
788
|
+
absence means "no restriction", which is what staff get.
|
|
789
|
+
|
|
790
|
+
No response shape, status code or field changed for a permitted caller.
|
|
791
|
+
|
|
792
|
+
- edae7cb: Scope the single-value people lookups to a site the caller can reach.
|
|
793
|
+
|
|
794
|
+
Seven endpoints on `/api/people` carried `requireAuth` and nothing else and were
|
|
795
|
+
not scoped at all:
|
|
796
|
+
|
|
797
|
+
| Route | What it answered |
|
|
798
|
+
| ------------------------------- | ------------------------------------------------- |
|
|
799
|
+
| `GET /nric/:nric` | a `findOne` on NRIC across the **whole platform** |
|
|
800
|
+
| `GET /contact/:contact` | the same, on contact number |
|
|
801
|
+
| `GET /plateNumber/:plateNumber` | every person with that plate, anywhere |
|
|
802
|
+
| `GET /unit/:unit` | every person at that unit id |
|
|
803
|
+
| `GET /user/:userId` | the person record behind any user id |
|
|
804
|
+
| `GET /company` | company names aggregated across every client |
|
|
805
|
+
| `GET /all-nric` | a paged NRIC list at a caller-named site |
|
|
806
|
+
|
|
807
|
+
So any signed-in account could type an NRIC or a car plate and read the matching
|
|
808
|
+
resident of any estate — name, unit, contact number, plates. #1907 scoped the
|
|
809
|
+
paged list (`GET /api/people`); these are the lookups beside it.
|
|
810
|
+
|
|
811
|
+
None of them takes a site — `layer-common usePeople.findPersonByNRIC`,
|
|
812
|
+
`searchCompanyList` and `getPeopleByUnit` pass only the value being looked up —
|
|
813
|
+
so the scope is decided **after** the read, on the record, with the same
|
|
814
|
+
site-reach rule the camera, HID and people-list already use. A record the caller
|
|
815
|
+
cannot reach is left out, which for these endpoints is exactly what "no such
|
|
816
|
+
person" already looked like: `null`, or an empty list. **No client sees a new
|
|
817
|
+
response shape or a new status code.** `entitleSite` is asked once per distinct
|
|
818
|
+
site, not once per record.
|
|
819
|
+
|
|
820
|
+
`/all-nric` requires a site in the query, so that one is checked before the read
|
|
821
|
+
and answers `404` for a site the caller cannot reach — the same answer a site
|
|
822
|
+
that does not exist gives, so ids cannot be probed.
|
|
823
|
+
|
|
824
|
+
`/company` has no site or unit to scope on, so it is narrowed to the caller's
|
|
825
|
+
own organisations — the fallback `getAll` already uses. `person.repo.getCompany`
|
|
826
|
+
gains an optional `orgs` filter; absent means no restriction, which is what
|
|
827
|
+
Seven365 staff get.
|
|
828
|
+
|
|
829
|
+
`GET /user/:userId` also always answers about **your own** record, whatever your
|
|
830
|
+
memberships say: the resident app reads it during sign-up and when resubmitting
|
|
831
|
+
a rejected registration.
|
|
832
|
+
|
|
833
|
+
- 6526669: Scope the resident and visitor WRITES to a site the caller can actually reach.
|
|
834
|
+
|
|
835
|
+
`PUT /api/people/id/:id` (edit), `PUT /api/people/:id` (delete),
|
|
836
|
+
`PATCH /api/people/:id` (approve / reject / ask to resubmit) and the exported
|
|
837
|
+
`suspendResidentById` identify the person by an id in the URL and carried
|
|
838
|
+
`requireAuth` and nothing more. Any signed-in account on the platform — a
|
|
839
|
+
resident, a cleaner, a guard at a different estate — could rename, delete,
|
|
840
|
+
approve or reject any other client's resident. #1907 closed the read
|
|
841
|
+
(`GET /api/people`); these are the writes behind it.
|
|
842
|
+
|
|
843
|
+
The scope is resolved from the **stored record**, never from the request, and
|
|
844
|
+
that is not a stylistic choice: **not one live caller sends a site.**
|
|
845
|
+
`layer-common usePeople.deleteById` sends no body at all,
|
|
846
|
+
`reviewResidentPerson` sends only `{ status, remarks }`, and both
|
|
847
|
+
`web-app-property-management pages/[org]/[site]/people-mgmt/index.vue:915-917`
|
|
848
|
+
and `components/PeopleFormMgmt.vue:618-620` explicitly `delete payload.site`
|
|
849
|
+
before calling `updateById`. A guard that read the site out of the body would
|
|
850
|
+
have refused every real caller and protected nobody.
|
|
851
|
+
|
|
852
|
+
The rule is the site-reach rule the camera, HID and people-list already use —
|
|
853
|
+
membership of the site, an org-wide membership at the site's owner, or an active
|
|
854
|
+
`customer.sites` engagement — so an agency contracted to an estate keeps
|
|
855
|
+
working. A record carrying an organisation but no site falls back to that
|
|
856
|
+
organisation. Seven365 staff are unchanged.
|
|
857
|
+
|
|
858
|
+
A resident resubmitting their own registration from the mobile app still works:
|
|
859
|
+
`person.service.ts:155-166` writes a `members` row pinned to the site at
|
|
860
|
+
sign-up, before any approval, so a rejected resident still reaches their own
|
|
861
|
+
site. There is an end-to-end case for exactly that.
|
|
862
|
+
|
|
863
|
+
Also: **who approved a resident now comes from the session.** It was read out of
|
|
864
|
+
the `user` cookie the browser sends, falling back to whatever `approvedBy` the
|
|
865
|
+
request body claimed, so an approval could be attributed to anybody. No caller
|
|
866
|
+
sends `approvedBy`, so nothing is lost.
|
|
867
|
+
|
|
868
|
+
- c7f368b: Pin what a logged-out caller may set on the resident sign-up.
|
|
869
|
+
|
|
870
|
+
`POST /api/people/resident/create` (`person.route.ts:37`) has no guard, and it
|
|
871
|
+
shares the `add` handler with the guarded `POST /api/people/`. The handler
|
|
872
|
+
spread the request body into `schemaPerson` and inserted it.
|
|
873
|
+
|
|
874
|
+
`site.people` is the collection every site-reach decision on the platform reads
|
|
875
|
+
its verdict from. On that schema `status` was a free-form string, and `_id`,
|
|
876
|
+
`user`, `approvedBy`, `type`, `site` and `org` were all caller-supplied. So an
|
|
877
|
+
anonymous caller could insert an APPROVED row at any client's site, pointing at
|
|
878
|
+
an account they control, and be reachable into that estate from then on. That is
|
|
879
|
+
the manufacture-access path, not a theoretical one.
|
|
880
|
+
|
|
881
|
+
It cannot be closed with a session check: the only caller anywhere in the
|
|
882
|
+
organisation is the resident app's registration wizard
|
|
883
|
+
(`app/(auth)/createUser.tsx:449`), which by definition has no session yet. So
|
|
884
|
+
the route is field-pinned instead.
|
|
885
|
+
|
|
886
|
+
With no session the body is now validated against a new
|
|
887
|
+
`schemaResidentSelfSignUp` — an explicit ALLOW-list of exactly what
|
|
888
|
+
`createUser.tsx` sends, with `stripUnknown`, so `_id`, `user`, `approvedBy` and
|
|
889
|
+
anything else are dropped rather than written. Two fields are then set
|
|
890
|
+
server-side rather than accepted:
|
|
891
|
+
|
|
892
|
+
- `status` is forced to `"pending"`, the literal the wizard hard-codes at
|
|
893
|
+
`createUser.tsx:151` and re-sets at `:237`.
|
|
894
|
+
- `platform` is forced to `"mobile"`. This matters as much as `status`:
|
|
895
|
+
`person.service.ts:130` reads it to decide whether the USER ACCOUNT it creates
|
|
896
|
+
starts `"pending"` or `"active"`, so a caller who sent `platform: "web"` got a
|
|
897
|
+
live account with nobody reviewing them.
|
|
898
|
+
|
|
899
|
+
`type` is narrowed to `resident` or `tenant` — the only two the wizard derives
|
|
900
|
+
(`createUser.tsx:441`) — with `.valid(...)` and `.trim().lowercase()`, never
|
|
901
|
+
`.allow(...)`, which accepts any non-empty string and would restrict nothing.
|
|
902
|
+
This closes the `user` hole properly: the service only overwrites a
|
|
903
|
+
caller-supplied `user` on the resident/tenant branch, so sending
|
|
904
|
+
`type: "contractor"` was what left a nominated `user` standing.
|
|
905
|
+
|
|
906
|
+
The guarded `POST /api/people/` is UNCHANGED and still accepts the full
|
|
907
|
+
`schemaPerson` — staff legitimately create contractors and visitors, set their
|
|
908
|
+
own statuses, and record `approvedBy`.
|
|
909
|
+
|
|
910
|
+
`site`, `org`, `block`, `level`, `unit`, `isOwner`, `email`, `password`, `name`,
|
|
911
|
+
`nric`, `contact` and the files are NOT pinnable: a genuine self-registration
|
|
912
|
+
chooses its own estate and unit, so pinning any of them would block real
|
|
913
|
+
residents.
|
|
914
|
+
|
|
915
|
+
Covered by `test/e2e/resident-self-signup-pinning.e2e.test.mjs`. Both create
|
|
916
|
+
routes are now mounted in the e2e harness, mirroring `person.route.ts`.
|
|
917
|
+
|
|
918
|
+
- f6cd1b9: Scope role writes to the role's own organisation, and reserve platform roles for Seven365 staff.
|
|
919
|
+
|
|
920
|
+
Every write on `role.controller.ts` carried `requireAuth` and nothing more, and
|
|
921
|
+
the role id comes from the URL. So any signed-in account on the platform — a
|
|
922
|
+
resident, a cleaner, a guard — could reach any organisation's roles:
|
|
923
|
+
|
|
924
|
+
- `PATCH /api/roles/permissions/id/:id` rewrote another client's role, including
|
|
925
|
+
its owner role. Executed against a throwaway database during the audit, an
|
|
926
|
+
outsider rewrote a second organisation's owner role and got a 200.
|
|
927
|
+
- `PATCH /api/roles/id/:id` renamed it and replaced its permissions.
|
|
928
|
+
- `PUT /api/roles/deleted/role` and `PUT /api/roles/:id/delete-with-reassignments`
|
|
929
|
+
deleted it, the second one moving its members onto other roles on the way.
|
|
930
|
+
- `GET /api/roles/:id/deletion-preview` listed the members holding it — another
|
|
931
|
+
client's staff list by another name.
|
|
932
|
+
- `POST /api/roles` and `POST /api/roles/v2` minted a role of `type: "admin"` —
|
|
933
|
+
the half of `isSuperAdmin` that lives in the roles collection.
|
|
934
|
+
|
|
935
|
+
Each of the five writes now loads the role first and authorises on the
|
|
936
|
+
organisation stored ON THAT ROLE, never on one the caller supplied:
|
|
937
|
+
|
|
938
|
+
- a role carrying an `org` is a tenant's, and goes through `requireOrgAccess` —
|
|
939
|
+
Seven365 staff, or a live member of that organisation. That is the rule the
|
|
940
|
+
invitation and subscription paths already use; no new mechanism is introduced.
|
|
941
|
+
- a role with NO `org` is a platform role — the staff console's own, and the one
|
|
942
|
+
`createDefaultUser` seeds — and only Seven365 staff may touch it. A missing org
|
|
943
|
+
is refused, not waved through.
|
|
944
|
+
|
|
945
|
+
`createRole` on both `/api/roles` and `/api/roles/v2` requires the caller to be
|
|
946
|
+
platform staff before it will accept `type: "admin"`. `/api/roles/v2` is a
|
|
947
|
+
separate mount onto a separate controller, so gating one and not the other would
|
|
948
|
+
have left the hole reachable by adding `/v2` to the URL.
|
|
949
|
+
|
|
950
|
+
A role the caller may not reach is reported the same as a role that does not
|
|
951
|
+
exist, so role ids cannot be enumerated by watching the difference.
|
|
952
|
+
|
|
953
|
+
Two things are deliberately NOT changed, and are asserted in the tests so they
|
|
954
|
+
read as decisions rather than oversights. `createRole` is not scoped to the
|
|
955
|
+
caller's own organisation, and neither are the role reads: `web-app-main
|
|
956
|
+
pages/sign-in.vue:311,439` and `pages/verify/email/index.vue:474` look up and
|
|
957
|
+
create the "Admin Service Provider" role in the INVITING organisation, which the
|
|
958
|
+
caller has not joined yet — that is what accepting a service-provider invitation
|
|
959
|
+
is, and an org-membership check there would break sign-in for every invited
|
|
960
|
+
provider. Scoping those needs the invitation to be consulted as well as the
|
|
961
|
+
membership, and is a separate change.
|
|
962
|
+
|
|
963
|
+
Every legitimate caller was checked and still works: the tenant screens
|
|
964
|
+
(`web-app-org pages/org/[organization]/roles-permissions.vue`, and the
|
|
965
|
+
`[org]/[site]/roles-permissions` screens in hygiene, security and
|
|
966
|
+
property-management, all through `layer-common RolePermissionMain.vue` and
|
|
967
|
+
`RolePermissionFormPreviewUpdate.vue`) edit roles in an organisation the operator
|
|
968
|
+
is a member of; the staff console (`web-app-org pages/super-admin/*` and the
|
|
969
|
+
retired admin app) is operated by staff and keeps cross-tenant reach.
|
|
970
|
+
|
|
971
|
+
No response shape or field changed for a permitted caller.
|
|
972
|
+
|
|
973
|
+
- 4de4685: Scope the service provider record routes to the site the engagement works on.
|
|
974
|
+
|
|
975
|
+
A `site.service-providers` row is an ENGAGEMENT: a provider organisation attached
|
|
976
|
+
to one client's site. Two routes took its id straight out of the URL with
|
|
977
|
+
`requireAuth` and nothing more, so any signed-in account could use them against
|
|
978
|
+
any client:
|
|
979
|
+
|
|
980
|
+
- `GET /api/service-providers/:id` returned any client's engagement — provider
|
|
981
|
+
name, category, contact email and the site it works on.
|
|
982
|
+
- `PATCH /api/service-providers/:id/status` suspended or reactivated it.
|
|
983
|
+
Suspending an engagement stops that provider's guards and cleaners reaching the
|
|
984
|
+
estate; reactivating one puts them back.
|
|
985
|
+
|
|
986
|
+
Both now decide from the **site stored on the row**, through the existing
|
|
987
|
+
`requireSiteReach` rule that `/api/vehicles` and `/api/documents` already use.
|
|
988
|
+
That rule admits both sides of the engagement: the client, and the contracted
|
|
989
|
+
provider whose reach comes from `customer.sites` rather than from a membership in
|
|
990
|
+
the client's organisation.
|
|
991
|
+
|
|
992
|
+
An engagement the caller cannot reach answers exactly as one that does not exist
|
|
993
|
+
(404), so ids cannot be enumerated from the difference. A row carrying no site at
|
|
994
|
+
all is refused, not waved through. Guards sit after each handler's Joi
|
|
995
|
+
validation, so a malformed id or status still answers 400 exactly as today.
|
|
996
|
+
|
|
997
|
+
Scope note — deliberately NOT changed here: the four list routes
|
|
998
|
+
(`GET /api/service-providers`, `/name`, `/category`,
|
|
999
|
+
`/org/:serviceProviderOrgId/type/:type`) and `GET /provider/:provider/org/:org`.
|
|
1000
|
+
Their `orgId`, `siteId` and `serviceProviderOrgId` are optional filters the
|
|
1001
|
+
caller sends, and `layer-common useServiceProvider.ts` defaults every one of them
|
|
1002
|
+
to an empty string, so it is not yet established which callers depend on the
|
|
1003
|
+
unfiltered list. That is the same open question as the site-less
|
|
1004
|
+
`GET /api/vehicles` and needs a product decision rather than a guess.
|
|
1005
|
+
|
|
1006
|
+
Nothing changes for a permitted caller: same response shape, same fields, same
|
|
1007
|
+
status.
|
|
1008
|
+
|
|
1009
|
+
- 89c3640: Scope the entry pass settings routes to their site, and take the writer's
|
|
1010
|
+
identity from the session.
|
|
1011
|
+
|
|
1012
|
+
These rows decide how a site issues visitor passes: whether passes are on at
|
|
1013
|
+
all, whether a physical or NFC pass is used, which USB printer model the
|
|
1014
|
+
gatehouse drives, and the header, disclaimer and URL printed on the pass a
|
|
1015
|
+
visitor is handed at the gate. All seven routes on
|
|
1016
|
+
`/api/site-entrypass-settings` carried `requireAuth` and nothing more — the
|
|
1017
|
+
controller held no authorization identifier of any kind.
|
|
1018
|
+
|
|
1019
|
+
Two defects were fixed, not one.
|
|
1020
|
+
|
|
1021
|
+
**No scope.** `GET /` returned every row on the platform: it accepts `org` and
|
|
1022
|
+
`site` query parameters and the repository never applies them, so a filter that
|
|
1023
|
+
looked like scoping was not one. The record routes took an id and no site.
|
|
1024
|
+
`GET|PUT /site/:site` took a site straight off the URL. All seven now go through
|
|
1025
|
+
`requireSiteReach`, the rule `/api/access-management`,
|
|
1026
|
+
`/api/manpower-monitoring`, `/api/attendances`, `/api/vehicles`,
|
|
1027
|
+
`/api/documents` and `/api/incident-reports` already use:
|
|
1028
|
+
|
|
1029
|
+
- `POST /` and `GET|PUT /site/:site` name a site in the request; it is checked
|
|
1030
|
+
before it is used.
|
|
1031
|
+
- `GET|PUT|DELETE /:id` name only a record, so the site is read off the
|
|
1032
|
+
**stored** row — a `site` in the caller's own body cannot stand in for it.
|
|
1033
|
+
- `PUT /:id` and `PUT /site/:site` can also **move** a row, because the body's
|
|
1034
|
+
`site` is `$set`, so the destination is checked as well as the origin.
|
|
1035
|
+
- `GET /` is narrowed after the read. It cannot be narrowed in the repository:
|
|
1036
|
+
the page is cached under a key built only from page/limit/status/sort, so a
|
|
1037
|
+
per-caller filter there would be served to the next caller. The count in the
|
|
1038
|
+
envelope stays the unfiltered one, as it does on the sibling lists.
|
|
1039
|
+
|
|
1040
|
+
**The writer's identity came from a cookie the caller writes.** `add`,
|
|
1041
|
+
`updateEntryPassSettingsById` and `updateEntryPassSettingsBySiteId` each parsed
|
|
1042
|
+
the raw `Cookie` header for a `user` value and stored it as `createdBy` /
|
|
1043
|
+
`updatedBy` without ever comparing it to the session, so the audit trail on
|
|
1044
|
+
these rows was whatever the caller typed. It now comes from `callerId(req)`.
|
|
1045
|
+
That also removes a latent crash: `cookies?.["user"].toString()` short-circuits
|
|
1046
|
+
only on `cookies` being nullish, so any request carrying cookies but no `user`
|
|
1047
|
+
cookie called `.toString()` on `undefined` and threw.
|
|
1048
|
+
|
|
1049
|
+
`requireSiteReach` matters here because the contracted agency that runs the
|
|
1050
|
+
gatehouse holds no membership in the estate's organisation and reaches its own
|
|
1051
|
+
site through `customer.sites`.
|
|
1052
|
+
|
|
1053
|
+
Nothing on this mount has a live caller: `layer-common`'s
|
|
1054
|
+
`useSiteEntryPassSettings` binds only `GET /:id` and no component calls it, and
|
|
1055
|
+
the entry-pass screens read and write `/api/access-management/settings`, a
|
|
1056
|
+
different mount over a different collection. The routes are guarded rather than
|
|
1057
|
+
removed; whether this mount should exist at all is a product decision.
|
|
1058
|
+
|
|
1059
|
+
- 67969e9: Scope `/api/sites` to the organisation that owns the site.
|
|
1060
|
+
|
|
1061
|
+
Every route on this router except the pre-login resident picker carried
|
|
1062
|
+
`requireAuth` and nothing more (`site.controller.ts`), and took the
|
|
1063
|
+
organisation from the query string or the site id from the URL. `requireAuth`
|
|
1064
|
+
proves a session exists and makes no organisation or site decision, so any
|
|
1065
|
+
signed-in account on the platform — a resident, a cleaner, an agency guard —
|
|
1066
|
+
could `DELETE /api/sites/:id` any site belonging to any client, rename it,
|
|
1067
|
+
re-block it, move its guard posts, rewrite its information page, create a site
|
|
1068
|
+
inside any organisation, and page through any client's estate list.
|
|
1069
|
+
|
|
1070
|
+
The caller is now resolved from the session and the decision made before
|
|
1071
|
+
anything is read or written:
|
|
1072
|
+
|
|
1073
|
+
- `POST /` uses `requireOrgAccess` on the body's `orgId` — that is where the new
|
|
1074
|
+
site lands.
|
|
1075
|
+
- `GET /` uses `requireOrgAccess` on the `org` filter the caller sends.
|
|
1076
|
+
- `PUT /:id/block`, `PATCH /id/:id`, `PATCH /guard-post/id/:id`,
|
|
1077
|
+
`PATCH /information/id/:id` and `DELETE /:id` use `entitleSite` — a
|
|
1078
|
+
membership pinned to the site, an org-wide membership at the site's owning
|
|
1079
|
+
organisation, or an ACTIVE `customer.sites` engagement. The engagement branch
|
|
1080
|
+
is required, not incidental: Site Settings lives in the SERVICE-PROVIDER apps
|
|
1081
|
+
(`web-app-security` and the five module apps, each
|
|
1082
|
+
`pages/[org]/[site]/settings.vue` via `layer-common useSiteSettings.ts`), and
|
|
1083
|
+
those users hold no membership in the estate's organisation at all.
|
|
1084
|
+
- `GET /:id` is deliberately one notch wider than the writes: it also passes
|
|
1085
|
+
for any live membership in the site's OWNING organisation. Three shipped
|
|
1086
|
+
mobile clients read this route, and the resident app reads
|
|
1087
|
+
`org.defaultSite` — which in a multi-site organisation need not be the
|
|
1088
|
+
resident's own site, and a resident's membership carries a `siteId`. The
|
|
1089
|
+
wider read closes the cross-CLIENT read, which is the defect, and leaves
|
|
1090
|
+
site-to-site partitioning inside one client where it is today.
|
|
1091
|
+
|
|
1092
|
+
A site the caller cannot reach answers exactly as a site that does not exist,
|
|
1093
|
+
so site ids cannot be enumerated by watching the difference.
|
|
1094
|
+
|
|
1095
|
+
`GET /site/resident` is unchanged and still pre-login: the resident mobile app
|
|
1096
|
+
needs a site picker before an account exists.
|
|
1097
|
+
|
|
1098
|
+
No new authorization mechanism is introduced — `requireOrgAccess` and
|
|
1099
|
+
`entitleSite` are the helpers the member, role, people, camera and HID paths
|
|
1100
|
+
already use.
|
|
1101
|
+
|
|
1102
|
+
- d27ed16: Stop the e-mail account lookup handing out the password hash and session id.
|
|
1103
|
+
|
|
1104
|
+
`GET /api/users/user/:email` (`user.route.ts:25`) has no guard on it at all, and
|
|
1105
|
+
`GET /api/users/email/:email` next to it is the same handler behind
|
|
1106
|
+
`requireAuth`. Both answered `res.json(user)` on the raw stored document, and
|
|
1107
|
+
the users collection stores `password` — the hash — and `sid` — that account's
|
|
1108
|
+
CURRENT session id.
|
|
1109
|
+
|
|
1110
|
+
So anyone at all, with no session, who knew or guessed an e-mail address got
|
|
1111
|
+
that account's password hash back in the response body, to crack offline at
|
|
1112
|
+
leisure, plus a live session id to reuse. On the authenticated twin, any signed
|
|
1113
|
+
in account could read the same two fields for any other account, including an
|
|
1114
|
+
administrator's.
|
|
1115
|
+
|
|
1116
|
+
The unguarded route cannot simply be guarded. Its only callers anywhere in the
|
|
1117
|
+
organisation are pre-login signup screens — the resident app's e-mail check
|
|
1118
|
+
(`user-registration.ts:103`) and the four `web-app-main/pages/sign-up-invite/*`
|
|
1119
|
+
pages through `layer-common`'s `getUserByEmailV2` — so a session check would
|
|
1120
|
+
break registration. The fix is therefore not authentication, it is not handing
|
|
1121
|
+
out more than signup asks for.
|
|
1122
|
+
|
|
1123
|
+
All five of those callers read NO field off the reply. The mobile check does
|
|
1124
|
+
`res !== null`; the four web pages do `if (response)`. So without a session the
|
|
1125
|
+
handler no longer loads the document: a new `userExistsByEmail` does a
|
|
1126
|
+
`findOne` with an explicit allow-list projection of `_id` alone and returns a
|
|
1127
|
+
boolean, and the route answers `{ exists: true }` or `null` exactly as before.
|
|
1128
|
+
An allow-list, deliberately — a deny-list would silently start leaking the next
|
|
1129
|
+
credential-shaped field somebody adds to the collection.
|
|
1130
|
+
|
|
1131
|
+
`getUserByEmail` itself is UNCHANGED and still returns the whole document,
|
|
1132
|
+
because signing in genuinely needs the hash to compare against. Putting the
|
|
1133
|
+
projection on that function instead of beside it would have broken every login.
|
|
1134
|
+
|
|
1135
|
+
With a session the reply keeps its existing shape, minus `password` and `sid`,
|
|
1136
|
+
via a small `withoutCredentials`. `_id` is the only field its callers read
|
|
1137
|
+
(`web-app-main pages/verify/email`, `web-app-org onboarding/getting-started`)
|
|
1138
|
+
and it survives. That path is narrowed by removal rather than by allow-list on
|
|
1139
|
+
purpose: it is consumed by screens across web and mobile that cannot all be
|
|
1140
|
+
enumerated from here, and removing two credential fields cannot break a
|
|
1141
|
+
legitimate one, because no screen renders a password hash or a session id.
|
|
1142
|
+
|
|
1143
|
+
The v2 twin `GET /api/users/v2/email/:email` had the same leak — it is behind
|
|
1144
|
+
`requireAuth`, so it was never anonymous, but "signed in" is not "entitled to
|
|
1145
|
+
another account's credentials" — and is stripped the same way. v2 has no
|
|
1146
|
+
anonymous `/user/:email` route and no pre-login caller.
|
|
1147
|
+
|
|
1148
|
+
Covered by `test/e2e/user-email-lookup-leak.e2e.test.mjs`, which asserts on the
|
|
1149
|
+
absence of the key and never on its value.
|
|
1150
|
+
|
|
1151
|
+
- 83b0b43: Scope the user record routes to the person themselves, or Seven365 staff — on both `/api/users` and `/api/users/v2`.
|
|
1152
|
+
|
|
1153
|
+
Five routes on each mount carried `requireAuth` and nothing more, with the target
|
|
1154
|
+
user id taken straight out of the URL:
|
|
1155
|
+
|
|
1156
|
+
- `GET /api/users/id/:id` returned any account's name, email, contact and NRIC.
|
|
1157
|
+
- `GET /api/users/organization` took the organisation from the caller's own query
|
|
1158
|
+
string, so any signed-in account could page through any client's whole people
|
|
1159
|
+
directory.
|
|
1160
|
+
- `PATCH /api/users/birthday/:id` rewrote any account's date of birth.
|
|
1161
|
+
- `PATCH /api/users/field/:id` is the sharp one. Its allow-list includes `email`,
|
|
1162
|
+
`nric`, `status` and `defaultOrg`, so pointing it at somebody else's id rewrote
|
|
1163
|
+
**their sign-in address** — and the forgot-password flow then delivers to the
|
|
1164
|
+
new one. That is account takeover, and `status` on the same route disables an
|
|
1165
|
+
account.
|
|
1166
|
+
- `PATCH /api/users/password/:id` verifies the current password inside the
|
|
1167
|
+
service, so it was an online **guessing oracle** against any account: a wrong
|
|
1168
|
+
current password and a mismatched confirmation answered differently.
|
|
1169
|
+
|
|
1170
|
+
`/api/users/v2` is a separate controller file (`user-v2.controller.ts`) with the
|
|
1171
|
+
same routes and the same five holes, so the guard was added to
|
|
1172
|
+
`console-authz.util.ts` as `requireSelfOrPlatformStaff` and **both** controllers
|
|
1173
|
+
call it — rather than fixing v1 and leaving its twin open.
|
|
1174
|
+
|
|
1175
|
+
- the four id-in-URL routes require the id to be the caller's own, or the caller
|
|
1176
|
+
to be Seven365 staff.
|
|
1177
|
+
- `GET /api/users/organization` puts the caller-supplied organisation through
|
|
1178
|
+
`requireOrgAccess` before it is used, the same way `GET /api/members` does.
|
|
1179
|
+
|
|
1180
|
+
Guards sit after each handler's Joi validation, so a malformed id or a malformed
|
|
1181
|
+
value still answers 400 exactly as it does today.
|
|
1182
|
+
|
|
1183
|
+
Every caller in the org already satisfies this rule: the account app's
|
|
1184
|
+
personal-info screens, the resident mobile app's profile editor, and the sign-in
|
|
1185
|
+
and verify-email flows all pass the caller's own id. The one screen that edits
|
|
1186
|
+
another person, `layer-common ClientDetailForm.vue:370`, is reached only from
|
|
1187
|
+
`web-app-org pages/super-admin/client-list.vue` — the Seven365 staff console —
|
|
1188
|
+
which the staff branch covers.
|
|
1189
|
+
|
|
1190
|
+
Nothing changes for a permitted caller: same response shape, same fields, same
|
|
1191
|
+
status.
|
|
1192
|
+
|
|
1193
|
+
- 284c83d: Restrict the unauthenticated invitation-status write to the one transition its
|
|
1194
|
+
callers actually perform.
|
|
1195
|
+
|
|
1196
|
+
`PATCH /api/auth/verification/status` (`API-core auth.route.ts:36`) carries no
|
|
1197
|
+
middleware, and it cannot be given any: every caller is an invitation-landing
|
|
1198
|
+
page opened from an emailed link **before an account exists** —
|
|
1199
|
+
`web-app-main` `pages/verify/invitation/[id].vue`,
|
|
1200
|
+
`pages/verify/service-provider/[id].vue`,
|
|
1201
|
+
`pages/verify/service-provider-invite/[id].vue` and
|
|
1202
|
+
`pages/sign-up-invite/service-provider/index.vue`, all through
|
|
1203
|
+
`layer-common composables/useLocalAuth.ts:130`. Adding `requireAuth` would break
|
|
1204
|
+
invitation acceptance for everybody, including the shipped clients.
|
|
1205
|
+
|
|
1206
|
+
What is restricted instead is what an anonymous caller may _do_. Each of those
|
|
1207
|
+
pages calls `GET /auth/verify/:id` first, and `verify`
|
|
1208
|
+
(`verification.service.ts:661-690`) already refuses an invitation that is
|
|
1209
|
+
expired, cancelled or complete — so the only transition a real caller ever
|
|
1210
|
+
performs is `pending -> complete`. Accepting the other three states let anyone
|
|
1211
|
+
who learned an invitation id:
|
|
1212
|
+
|
|
1213
|
+
- set a used invitation back to `pending` and replay it,
|
|
1214
|
+
- revive a `cancelled` or `expired` invitation, or
|
|
1215
|
+
- burn somebody else's pending invitation by marking it `complete` before they
|
|
1216
|
+
opened the email.
|
|
1217
|
+
|
|
1218
|
+
The handler now accepts only `complete`, loads the invitation and requires it to
|
|
1219
|
+
be `pending`. Completing an already-complete invitation still answers 200 so a
|
|
1220
|
+
page that calls this twice is not broken. Cancelling an invitation on purpose
|
|
1221
|
+
keeps its own session-protected endpoint,
|
|
1222
|
+
`PUT /api/verifications/:id/cancel` (`verification.route.ts:13`), so no
|
|
1223
|
+
capability is removed.
|
|
1224
|
+
|
|
1225
|
+
- ed0557c: Scope the work order routes to the estate the job was raised at, and take the
|
|
1226
|
+
author's identity from the session.
|
|
1227
|
+
|
|
1228
|
+
A work order is a job at one estate: the subject, the description, the block and
|
|
1229
|
+
unit it sits in, the attachments, the assigned provider, and the name and
|
|
1230
|
+
signature captured when it is signed off. Every route on `/api/work-orders`
|
|
1231
|
+
carried `requireAuth` and nothing more, and `work-order.controller.ts` held no
|
|
1232
|
+
authorization identifier of any kind. `requireAuth` proves a session exists; it
|
|
1233
|
+
makes no organisation or site decision. So being signed in ANYWHERE on the
|
|
1234
|
+
platform was enough to read another client's jobs, rewrite them, drive them
|
|
1235
|
+
between columns, sign them off in a stranger's name, or delete them.
|
|
1236
|
+
|
|
1237
|
+
`DELETE /:id` is the sharp one, and for the same reason it was sharp on
|
|
1238
|
+
`/api/feedbacks`: **it does not write to the v1 table at all.** `deleteWorkOrder`
|
|
1239
|
+
in `work-order.repo.ts` updates `workOrders2Collection` — `work-orders2`, the
|
|
1240
|
+
authoritative table every client actually reads. A guard written against the v1
|
|
1241
|
+
row would have been the wrong guard, so this one is written against the row the
|
|
1242
|
+
write really touches. `DELETE /:id` and `PUT /:id` are also the only two routes
|
|
1243
|
+
on this mount with a live caller: `layer-common`'s
|
|
1244
|
+
`useWorkOrder().deleteWorkOrder` and `updateWorkOrder`, behind the shared
|
|
1245
|
+
`WorkOrder/Main.vue` and `WorkOrder/Detail.vue` screens that every web app draws.
|
|
1246
|
+
|
|
1247
|
+
All seven handlers now decide before any work is done:
|
|
1248
|
+
|
|
1249
|
+
- `GET /site/:site/status/:status` names the site in the URL, so it is checked
|
|
1250
|
+
directly with `requireSiteReach`.
|
|
1251
|
+
- `GET /:id`, `PUT /:id`, `PUT /:id/:status` and `PATCH /:id/completed` name only
|
|
1252
|
+
a record, so the site is read off the STORED row through the new
|
|
1253
|
+
`requireWorkOrderReach`. A `site` in the caller's own body or query cannot
|
|
1254
|
+
stand in for it.
|
|
1255
|
+
- `DELETE /:id` uses `requireWorkOrder2Reach`, against the `work-orders2` row.
|
|
1256
|
+
- `POST /` reads the destination from the body.
|
|
1257
|
+
|
|
1258
|
+
Two readers were needed, not one, because the tables are not the same shape: v1
|
|
1259
|
+
`work-orders` rows keep the site at `metadata.site`, `work-orders2` rows keep it
|
|
1260
|
+
at the top level. Both are new raw readers on the repository —
|
|
1261
|
+
`getRawWorkOrderById` and `getRawWorkOrder2ById`. The existing `getWorkOrderById`
|
|
1262
|
+
is CACHED and shaped for the screen, so a reach check built on it would have
|
|
1263
|
+
decided who may touch a job from a cache entry.
|
|
1264
|
+
|
|
1265
|
+
Two further defects fixed in the same pass:
|
|
1266
|
+
|
|
1267
|
+
1. **Identity from a cookie the caller writes.** `POST /` parsed `createdBy` out
|
|
1268
|
+
of the raw `Cookie` header and never compared it to the session, so a job
|
|
1269
|
+
could be raised in somebody else's name. It comes from `callerId(req)` now.
|
|
1270
|
+
That also closes a latent crash: `cookies?.["user"].toString()`
|
|
1271
|
+
short-circuits only on `cookies` being nullish, so any request carrying
|
|
1272
|
+
cookies but no `user` cookie threw a TypeError.
|
|
1273
|
+
2. **A body that names the site twice, with unequal weight.** `POST /` accepts
|
|
1274
|
+
`metadata.site` AND a top-level `site`, and `work-order.service.ts` copies the
|
|
1275
|
+
top-level one OVER `metadata.site` (`if (value.site) value.metadata.site =
|
|
1276
|
+
value.site`). A caller could therefore name their own estate in `metadata`
|
|
1277
|
+
while the row landed at somebody else's. The guard reads them in the order the
|
|
1278
|
+
service resolves them.
|
|
1279
|
+
|
|
1280
|
+
`PUT /:id` is a write that can MOVE a record — the repository `$set`s whatever
|
|
1281
|
+
the body carries, including `site` — so the DESTINATION is checked as well as the
|
|
1282
|
+
origin. Without that, a legacy row carrying no `metadata.site` could be given a
|
|
1283
|
+
top-level `site` of the caller's own and become theirs.
|
|
1284
|
+
|
|
1285
|
+
Each guard is placed AFTER the handler's own Joi validation, so a malformed id
|
|
1286
|
+
still answers 400 exactly as before. A row that does not exist has no site, and
|
|
1287
|
+
`requireSiteReach` refuses a missing site, so an id matching nothing is refused
|
|
1288
|
+
rather than falling through.
|
|
1289
|
+
|
|
1290
|
+
The rule is the existing one, `requireSiteReach` -> `entitleSite`, already used
|
|
1291
|
+
by `/api/feedbacks`, `/api/manpower-monitoring`, `/api/attendances`,
|
|
1292
|
+
`/api/remarks`, `/api/vehicles`, `/api/documents` and `/api/incident-reports`.
|
|
1293
|
+
The `customer.sites` branch is required, not incidental: the contracted provider
|
|
1294
|
+
that does this work holds no membership in the estate's own organisation.
|
|
1295
|
+
|
|
1296
|
+
`/api/work-orders2` is a different mount, lives in API-core's own controller, and
|
|
1297
|
+
is not touched here.
|
|
1298
|
+
|
|
1299
|
+
### Patch Changes
|
|
1300
|
+
|
|
1301
|
+
- 8a056c0: Scope the attendance settings routes to their estate. `GET` and `PUT /api/attendance-settings/site/:site` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could read and rewrite any estate's attendance settings — turn location capture off, move the clock-in coordinates, or widen the geofence radius so a shift can be clocked in from anywhere. The read also created a default settings row for a site that had none, so an unauthorised read wrote into another estate's data. Reach is now decided from the session, on the site named in the request, before either the read or the write happens.
|
|
1302
|
+
- 1c8c481: Scope the bulletin-board routes to the caller's own estate.
|
|
1303
|
+
|
|
1304
|
+
All five `/api/bulletin-boards` routes carried `requireAuth` and nothing more,
|
|
1305
|
+
and the controller held no authorization identifier of any kind, so any
|
|
1306
|
+
signed-in account in any organisation could publish an announcement into another
|
|
1307
|
+
client's estate — which also fans out as a notification to every recipient group
|
|
1308
|
+
at that site — read that board, and read, rewrite or delete any single bulletin
|
|
1309
|
+
reached by id alone.
|
|
1310
|
+
|
|
1311
|
+
`add` and `getAll` now authorise the site named in the request with
|
|
1312
|
+
`requireSiteReach`; `site` is optional on the add schema, so the org-wide case is
|
|
1313
|
+
asked about separately rather than an absent site leaving the write unscoped. The
|
|
1314
|
+
three by-id handlers take the verdict off the STORED row with a new module-named
|
|
1315
|
+
`requireBulletinReach`, which answers "not yours" and "does not exist"
|
|
1316
|
+
identically so bulletin ids cannot be enumerated.
|
|
1317
|
+
|
|
1318
|
+
- 4fbd0aa: Scope the bulletin video routes to their estate. `POST /api/bulletin-videos`, the list, the by-id read, the update and the delete carried `requireAuth` and nothing more, so any signed-in account on the platform could publish a video into another client's estate, list that estate's videos, or read, rewrite and delete any video by its id alone. Reach is now decided from the session — on the site named in the request for the write and the list, and on the stored row for the three by-id routes.
|
|
1319
|
+
- b6cea8b: Classify a camera by its `type` correctly, so an ANPR unit can be switched off.
|
|
1320
|
+
|
|
1321
|
+
Reported as `.github-private#35486`: a faulty ANPR camera at a client site is
|
|
1322
|
+
down in PRODUCTION, and `PATCH /api/site-cameras/id/:id` with
|
|
1323
|
+
`status: "inactive"` answers `"You do not have permission to set up cameras on
|
|
1324
|
+
this site."` The camera is broken and the person trying to switch it off is
|
|
1325
|
+
locked out.
|
|
1326
|
+
|
|
1327
|
+
The refusal is ours. `cameraManagePermissions` (`camera-view.util.ts`) picked
|
|
1328
|
+
between the two Settings panels' permission strings with
|
|
1329
|
+
`String(type) === "anpr"`, on the strength of a comment claiming `type` was
|
|
1330
|
+
"validated to `ip | anpr` by the model". It was not. `site-camera.model.ts` used
|
|
1331
|
+
`Joi.string().allow("ip", "anpr").required()`, and `.allow()` WHITELISTS
|
|
1332
|
+
additional values rather than restricting to them. Run against joi 17.13.3 the
|
|
1333
|
+
rule accepts `"ANPR"`, `"Anpr"` and `"totally-arbitrary-xyz"` exactly as readily
|
|
1334
|
+
as `"anpr"`; only `""`, `undefined`, `null` and non-strings were refused, and
|
|
1335
|
+
those by `string().required()`, not by the list. So a stored row could carry a
|
|
1336
|
+
spelling the gate could not place, an ANPR unit was classed as CCTV, and the
|
|
1337
|
+
write demanded `site-settings:manage-cctv-camera` — which a role provisioned for
|
|
1338
|
+
the ANPR panel does not hold.
|
|
1339
|
+
|
|
1340
|
+
Two changes, and the gate itself is untouched in substance:
|
|
1341
|
+
|
|
1342
|
+
- The schema is now `Joi.string().trim().lowercase().valid("ip", "anpr")`, so
|
|
1343
|
+
nothing outside the pair can be stored again. `.trim().lowercase()` runs
|
|
1344
|
+
before the check, so a caller sending `"ANPR"` is normalised rather than newly
|
|
1345
|
+
refused; every client in the estate already sends the lower-case pair.
|
|
1346
|
+
`MSiteCamera` normalises the value it writes for the same reason.
|
|
1347
|
+
- `cameraManagePermissions` compares case- and whitespace-insensitively instead
|
|
1348
|
+
of exactly, because the rows already in the collection were written under the
|
|
1349
|
+
old rule and `loadForWrite` reads them raw, so the mapper's default never
|
|
1350
|
+
reaches them. A `type` that still cannot be placed — absent, or a legacy value
|
|
1351
|
+
— now asks for EITHER permission. The two strings are two panels, not two
|
|
1352
|
+
privilege levels; neither is the safer guess, and guessing one is what locked
|
|
1353
|
+
the correct operator out.
|
|
1354
|
+
|
|
1355
|
+
A caller must still be entitled to the site AND hold a camera-management
|
|
1356
|
+
permission on the membership that granted it, so an unrelated tenant is refused
|
|
1357
|
+
exactly as before, and none of the pre-`62e4eb62` behaviour — any signed-in
|
|
1358
|
+
account rewriting or hard-deleting any organisation's cameras — returns. No
|
|
1359
|
+
caller who could write a camera before is refused now: the unplaceable case is a
|
|
1360
|
+
superset of the CCTV string it used to demand.
|
|
1361
|
+
|
|
1362
|
+
- 991e0a1: Scope the event-management routes to the caller's own estate.
|
|
1363
|
+
|
|
1364
|
+
All five `/api/event-management` routes carried `requireAuth` and nothing more,
|
|
1365
|
+
and the controller held no authorization identifier of any kind, so any
|
|
1366
|
+
signed-in account in any organisation could file an event into another client's
|
|
1367
|
+
calendar, read that calendar, and read, rewrite or delete any single event
|
|
1368
|
+
reached by id alone.
|
|
1369
|
+
|
|
1370
|
+
`add` and `getAll` now authorise the site named in the request with
|
|
1371
|
+
`requireSiteReach`; the three by-id handlers take the verdict off the STORED row
|
|
1372
|
+
with a new module-named `requireEventReach`, which answers "not yours" and "does
|
|
1373
|
+
not exist" identically so event ids cannot be enumerated.
|
|
1374
|
+
|
|
1375
|
+
- f2755a1: Scope the facility booking routes to the estate. `/api/facility-bookings` carried
|
|
1376
|
+
`requireAuth` and nothing else on all five routes: any signed-in account could book
|
|
1377
|
+
a facility at another estate, list another client's bookings, and read, rewrite or
|
|
1378
|
+
cancel any booking by id alone — including moving a booking to a different estate.
|
|
1379
|
+
Reach is now checked on the site named in the request, and on the stored row where
|
|
1380
|
+
the request names only an id.
|
|
1381
|
+
- ddac2d2: Scope the form-entry routes to the estate and to the resident who submitted them
|
|
1382
|
+
|
|
1383
|
+
`/api/form-entries` is a second controller over the same collection as
|
|
1384
|
+
`/api/online-forms`, and the rows in it are not templates only - a resident's
|
|
1385
|
+
submission lives there too. Three of its seven routes carried no session check
|
|
1386
|
+
at all, and the other four carried `requireAuth` and nothing more with no
|
|
1387
|
+
authorization identifier in the controller.
|
|
1388
|
+
|
|
1389
|
+
`GET /resident` took `org`, `site` and `userId` from the query string and
|
|
1390
|
+
matched on exactly those with no projection, so an unauthenticated caller who
|
|
1391
|
+
could name a resident's user id got that resident's whole submission history.
|
|
1392
|
+
It now answers only for the caller themselves, or for somebody who reaches the
|
|
1393
|
+
estate being asked about.
|
|
1394
|
+
|
|
1395
|
+
The reads, the update and the archive are decided by `requireFormEntryReach`,
|
|
1396
|
+
on the STORED row: the estate through `entitleSite`, or the person whose
|
|
1397
|
+
`userId` the row carries - residents hold no `members` document, so the
|
|
1398
|
+
owns-it branch is required or every resident loses their own submissions. The
|
|
1399
|
+
list closes both halves of its optional `site`. The update authorises its
|
|
1400
|
+
destination as well as its source, drops `userId` so ownership cannot be
|
|
1401
|
+
reassigned, and stops a resident approving their own application while leaving
|
|
1402
|
+
resubmission working. `POST /resident` stamps `userId` from the session instead
|
|
1403
|
+
of the body, and `add` stamps `createdBy` from the session instead of the
|
|
1404
|
+
caller's own cookie (which also removes a crash for any request sending cookies
|
|
1405
|
+
without a `user`).
|
|
1406
|
+
|
|
1407
|
+
- 584bce4: Constrain what a HID configuration write may contain, and stop storing password salts
|
|
1408
|
+
|
|
1409
|
+
`PUT /readers/:readerId/configuration` is a passthrough to the reader's
|
|
1410
|
+
`set_configuration.fcgi`, and its body schema was `Joi.object().unknown(true)`.
|
|
1411
|
+
That reaches `monitor.hostname`, `monitor.port` and `monitor.path` — the block
|
|
1412
|
+
that decides which server receives every identification at that door — so one
|
|
1413
|
+
request could redirect a reader's entire event stream. Writes are now limited to
|
|
1414
|
+
the two modules the product actually writes, `pjsip` and `identifier`, which are
|
|
1415
|
+
the only two `setReaderConfiguration` call sites in `layer-common`. Module
|
|
1416
|
+
contents stay unvalidated (the vendor owns those keys), reading configuration is
|
|
1417
|
+
unchanged, and the monitor block is still managed by the server through
|
|
1418
|
+
`setReaderMonitor` and `configureReaderIntegration`, which do not use this
|
|
1419
|
+
schema.
|
|
1420
|
+
|
|
1421
|
+
`sanitizeHidPayload` — the central redaction every `addEvent` already passes
|
|
1422
|
+
through — did not include `salt`, so a password salt was still being persisted
|
|
1423
|
+
beside the hash it belongs to. `salt`, `salt_value` and `saltvalue` are now
|
|
1424
|
+
redacted like the rest. This does not touch records already stored.
|
|
1425
|
+
|
|
1426
|
+
- 65eda12: Scope the four manpower-attendance reads to the caller's sites and the attendance permission.
|
|
1427
|
+
- a77e9fb: Scope a subscription's invoices to the subscription's owner.
|
|
1428
|
+
|
|
1429
|
+
`GET /api/invoices/subscription/:id` carried `requireAuth` and nothing more, and
|
|
1430
|
+
`invoice.controller.ts` held no authorization identifier of any kind: the
|
|
1431
|
+
subscription id came off the URL and went straight into the query. Being signed
|
|
1432
|
+
in anywhere on the platform was therefore enough to page through another
|
|
1433
|
+
organisation's billing history — invoice numbers, amounts, line-item
|
|
1434
|
+
descriptions, due dates and payment status.
|
|
1435
|
+
|
|
1436
|
+
A new module-named `requireInvoiceReach` takes the verdict off the STORED
|
|
1437
|
+
subscription, never from anything the caller supplied. A subscription is owned
|
|
1438
|
+
by an organisation OR by a single user, so both are checked: `siteReachOf`'s
|
|
1439
|
+
`canReach` answers the organisation half, and the personal half is the caller
|
|
1440
|
+
being that user. A subscription that names neither is refused. "Not yours",
|
|
1441
|
+
"does not exist" and "not a valid id" all answer 404 identically, so
|
|
1442
|
+
subscription ids cannot be enumerated.
|
|
1443
|
+
|
|
1444
|
+
Read access to invoice records only — no card handling, no payment gateway and
|
|
1445
|
+
no money movement is touched.
|
|
1446
|
+
|
|
1447
|
+
- 5235660: Scope the manpower designation routes to the estate, and stamp who filed the plan
|
|
1448
|
+
from the session. `/api/manpower-designations` carried `requireAuth` and nothing
|
|
1449
|
+
else on all three routes: any signed-in account could file a manning plan into
|
|
1450
|
+
another estate, read any estate's guard-strength plan, or rewrite one by id alone.
|
|
1451
|
+
`createdBy` and `createdByName` were taken from the request body, so the plan could
|
|
1452
|
+
be attributed to anybody. Reach is now checked on the site named in the request and
|
|
1453
|
+
on the stored row for the update, and the two identity fields come from the session.
|
|
1454
|
+
- 8223c73: Stop the every-minute manpower-sites sync from deleting the whole catalogue when the HRM Labs lookup returns nothing.
|
|
1455
|
+
- 62f5fa4: Treat a maximum seat count of 0 as unlimited when an organisation sends an
|
|
1456
|
+
invitation. `createOrganizationInvite` read the subscription's `maxSeats` through
|
|
1457
|
+
`?? 0` and then refused on any falsy value, so an organisation configured with 0
|
|
1458
|
+
seats — which the plan dialog offers and the console labels UNLIMITED — had every
|
|
1459
|
+
invitation rejected with "No seats configured for organization." 0 now admits
|
|
1460
|
+
invitations without a cap, a real limit is still enforced at the limit, and only a
|
|
1461
|
+
genuinely absent or non-numeric value is still treated as a misconfiguration.
|
|
1462
|
+
- f4c8bdb: Scope the online form routes to the estate
|
|
1463
|
+
|
|
1464
|
+
All six `/api/online-forms` routes carried `requireAuth` and nothing more, so any
|
|
1465
|
+
signed-in account on the platform could read, rewrite, move or archive another
|
|
1466
|
+
estate's form templates. The controller now takes its verdict from the session:
|
|
1467
|
+
a new `requireOnlineFormReach` for the by-id read, update and delete, a site
|
|
1468
|
+
check on the add and on the by-site list, both tenancy filters closed on the
|
|
1469
|
+
list query, and the update's destination `site` checked as well as the source.
|
|
1470
|
+
`createdBy`/`updatedBy` are stamped from the session instead of the caller's
|
|
1471
|
+
`user` cookie, and `getOnlineFormsBySiteId` now actually applies the estate
|
|
1472
|
+
filter it was given.
|
|
1473
|
+
|
|
1474
|
+
- 1f5cba2: Return the organisation's name only on the anonymous organisation lookup.
|
|
1475
|
+
|
|
1476
|
+
`GET /api/organizations/org/id/:id` is the unguarded twin of `/id/:id`, and it
|
|
1477
|
+
shares this controller's `getById`. The handler answered `res.json(data)` on the
|
|
1478
|
+
raw stored organisation document, which carries the client's own business
|
|
1479
|
+
`email` and `contact` number alongside `description`, `busInst`, `nature`,
|
|
1480
|
+
`defaultSite`, `terms` and `policies`. So anyone at all, with no session, who
|
|
1481
|
+
knew or guessed an organisation id got a client's business contact details back.
|
|
1482
|
+
|
|
1483
|
+
It cannot be given a guard. Its only callers anywhere in the organisation are
|
|
1484
|
+
`web-app-main`'s `sign-up-invite/onboarding-admin` and
|
|
1485
|
+
`sign-up-invite/service-provider` pages (via `layer-common`'s
|
|
1486
|
+
`useOrg.getOrgById`), reached from an invite e-mail before the account being
|
|
1487
|
+
invited exists. Both pages do the same thing with the reply:
|
|
1488
|
+
`form.organization = org?.name || ""` — they read `name` and nothing else.
|
|
1489
|
+
|
|
1490
|
+
So the anonymous path now uses a new `getNameById` on the organisation
|
|
1491
|
+
repository: `findOne` with an **allow-list** projection of `_id` and `name`. A
|
|
1492
|
+
field added to the organisation document later is excluded by default rather
|
|
1493
|
+
than leaking on the day it is added.
|
|
1494
|
+
|
|
1495
|
+
`getById` itself is untouched. About twenty callers inside this package need the
|
|
1496
|
+
whole document — `customer`, `feedback`, `member`, `person`, `robot`,
|
|
1497
|
+
`service-provider`, `service-provider-invite`, `site` and `user-v2` services all
|
|
1498
|
+
read it — so projecting that function would have broken them. The new function
|
|
1499
|
+
sits beside it, and the controller picks between the two on whether the request
|
|
1500
|
+
carries a session (`callerId(req)`), which is the same shape used for the
|
|
1501
|
+
pre-login user lookup and resident-create routes. The guarded `/id/:id` route is
|
|
1502
|
+
therefore unchanged: still the whole document, still 401 without a session.
|
|
1503
|
+
|
|
1504
|
+
- 30b0739: Scope the patrol log email route to its estate. `POST /api/patrol-logs/email` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could name another client's site and that site's patrol log ids and have the report emailed — to a list of up to 25 addresses of their own choosing, from the platform's sending address. The service's own log-belongs-to-site check is internal consistency, not reach: name the other estate's site and its own logs and the two agree. Reach is now decided from the session, on the site named in the request body, before anything is read, recorded, rendered or sent. `createdBy` — the audit row for who emailed patrol logs out of the system — is taken from the session rather than from the request body.
|
|
1505
|
+
- 4a3f19e: Scope the virtual patrol log routes to the estate, and take the author from the session
|
|
1506
|
+
|
|
1507
|
+
`/api/patrol-logs` carried `requireAuth` and nothing more on all five handlers,
|
|
1508
|
+
and `patrol-log.controller.ts` held no authorization identifier of any kind. A
|
|
1509
|
+
patrol log is the proof a round was walked, so any signed-in account on the
|
|
1510
|
+
platform could read another client's patrol history, rewrite a round's outcome,
|
|
1511
|
+
detach its incident report, move it to another estate, or destroy it. The delete
|
|
1512
|
+
is a HARD delete (`collection.deleteOne`), so there is no tombstone to notice.
|
|
1513
|
+
|
|
1514
|
+
`GET /` was worse than "unscoped": `site` was an OPTIONAL query parameter and
|
|
1515
|
+
the repository only applies it when it is a valid id, so a request that simply
|
|
1516
|
+
omitted it was answered with EVERY estate's patrol history in one page. `site`
|
|
1517
|
+
is now required, which is what both live callers already send.
|
|
1518
|
+
|
|
1519
|
+
`createdBy` was accepted from the request body and never compared to the
|
|
1520
|
+
session, so a round could be filed under another guard's name. It now comes from
|
|
1521
|
+
`callerId(req)`.
|
|
1522
|
+
|
|
1523
|
+
Adds `requirePatrolLogReach`, built on the existing `requireSiteReach` rule,
|
|
1524
|
+
plus an uncached `getRawPatrolLogById` so the check is not decided from a
|
|
1525
|
+
15-minute cache entry. `PUT /id/:id` checks BOTH ends, because
|
|
1526
|
+
`schemaUpdatePatrolLog` accepts `site` and `updateById` writes it.
|
|
1527
|
+
|
|
1528
|
+
Behaviour change: `GET /id/:id` used to answer 200 with `data: null` for an id
|
|
1529
|
+
matching nothing; it now refuses.
|
|
1530
|
+
|
|
1531
|
+
- f8e379b: Scope the virtual patrol question routes to the estate
|
|
1532
|
+
|
|
1533
|
+
`/api/patrol-questions` carried `requireAuth` and nothing more on all four
|
|
1534
|
+
handlers, and `patrol-question.controller.ts` held no authorization identifier
|
|
1535
|
+
of any kind. Any signed-in account on the platform could add a question to
|
|
1536
|
+
another client's estate, reword one, change the answers it accepts, or delete
|
|
1537
|
+
it. The reword is the quiet one: the guard's console still shows a question and
|
|
1538
|
+
still records an answer, so nothing looks wrong, but the estate is no longer
|
|
1539
|
+
checking what it believes it is checking.
|
|
1540
|
+
|
|
1541
|
+
`GET /` was worse than "unscoped": `site` was an OPTIONAL query parameter and
|
|
1542
|
+
the repository only applies it when it is a valid id, so a request that simply
|
|
1543
|
+
omitted it was answered with EVERY estate's patrol questions in one page.
|
|
1544
|
+
`site` is now required, which is what both live callers already send.
|
|
1545
|
+
|
|
1546
|
+
Adds `requirePatrolQuestionReach`, built on the existing `requireSiteReach`
|
|
1547
|
+
rule, plus an uncached `getRawPatrolQuestionById` so the check is not decided
|
|
1548
|
+
from a 15-minute cache entry.
|
|
1549
|
+
|
|
1550
|
+
- 6a726f6: Scope the virtual patrol route routes to the estate
|
|
1551
|
+
|
|
1552
|
+
`/api/patrol-routes` carried `requireAuth` and nothing more on all six
|
|
1553
|
+
handlers, and `patrol-route.controller.ts` held no authorization identifier of
|
|
1554
|
+
any kind. Any signed-in account on the platform could read another client's
|
|
1555
|
+
patrol schedule, re-time it, re-assign it, point it at different cameras, move
|
|
1556
|
+
it to another estate, or delete it outright — and a deleted round simply stops
|
|
1557
|
+
appearing in `scheduled-routes`, so nobody is told to walk it and nothing looks
|
|
1558
|
+
wrong on the console.
|
|
1559
|
+
|
|
1560
|
+
`GET /` and `GET /scheduled-routes` were worse than "unscoped": `site` was an
|
|
1561
|
+
OPTIONAL query parameter and the repository only applies it when it is a valid
|
|
1562
|
+
id, so a request that simply omitted it was answered with EVERY estate's patrol
|
|
1563
|
+
schedule in one page. `site` is now required, which is what both live callers
|
|
1564
|
+
already send.
|
|
1565
|
+
|
|
1566
|
+
Adds `requirePatrolRouteReach`, built on the existing `requireSiteReach` rule,
|
|
1567
|
+
plus an uncached `getRawPatrolRouteById` so the check is not decided from a
|
|
1568
|
+
15-minute cache entry. `PUT /id/:id` checks BOTH ends, because
|
|
1569
|
+
`schemaUpdatePatrolRoute` accepts `site` and `updateById` writes it.
|
|
1570
|
+
|
|
1571
|
+
Behaviour change: `GET /id/:id` used to answer 200 with `data: null` for an id
|
|
1572
|
+
matching nothing; it now refuses.
|
|
1573
|
+
|
|
1574
|
+
- e504691: Make the people search filters narrow each other instead of overwriting each
|
|
1575
|
+
other.
|
|
1576
|
+
|
|
1577
|
+
`person.repo.getAll` built its Mongo filter as one object literal in which four
|
|
1578
|
+
different filters each wrote a `$or` key — the date range (`dateTo`), the
|
|
1579
|
+
free-text `search`, `contact` and `plateNumber`. An object literal can only
|
|
1580
|
+
carry one `$or`, so the last one written silently replaced every earlier one.
|
|
1581
|
+
In practice:
|
|
1582
|
+
|
|
1583
|
+
- searching by name **and** contact number searched only the contact number,
|
|
1584
|
+
- searching by name **and** plate number searched only the plate number,
|
|
1585
|
+
- and a **date range was dropped entirely** the moment any of the three was
|
|
1586
|
+
used — which is the live case, because `layer-common`
|
|
1587
|
+
`composables/usePeople.ts:2-30` sends `search`, `dateFrom` and `dateTo`
|
|
1588
|
+
together from the visitor/people list.
|
|
1589
|
+
|
|
1590
|
+
No error was raised; the endpoint simply answered a narrower question than it
|
|
1591
|
+
was asked, with rows that should not have matched.
|
|
1592
|
+
|
|
1593
|
+
The four groups are now collected and combined with `$and`, so each one narrows
|
|
1594
|
+
the result. A single filter behaves exactly as before, and a request with no
|
|
1595
|
+
filters is unchanged.
|
|
1596
|
+
|
|
1597
|
+
- eec7708: Scope the service provider billing routes to the organisation
|
|
1598
|
+
|
|
1599
|
+
`/api/provider-billings` carried `requireAuth` and nothing more on all five
|
|
1600
|
+
handlers, and `service-provider-billing.controller.ts` held no authorization
|
|
1601
|
+
identifier of any kind. A service provider billing record is what a contractor
|
|
1602
|
+
invoices an organisation for, so any signed-in account on the platform could
|
|
1603
|
+
read another organisation's invoices, swap the attachments that evidence one,
|
|
1604
|
+
flip its status, add an acknowledgement that was never given, or delete it.
|
|
1605
|
+
|
|
1606
|
+
The scope here is the ORGANISATION, not the estate: every row carries `orgId`
|
|
1607
|
+
and no `site`, so the rule is `requireOrgAccess` — Seven365 staff, or a live
|
|
1608
|
+
member of that organisation — not `requireSiteReach`.
|
|
1609
|
+
|
|
1610
|
+
Adds `requireServiceProviderBillingReach`, plus an uncached
|
|
1611
|
+
`getRawServiceProviderBillingById` so the check is not decided from a 15-minute
|
|
1612
|
+
cache entry (the existing reader is cached and throws `NotFoundError`, which
|
|
1613
|
+
would also answer before the guard ran).
|
|
1614
|
+
|
|
1615
|
+
- 0c167e6: Scope the robot routes to their estate. `POST /api/robots` and `GET /api/robots/organization/:organization/site/:site` carried `requireAuth` and nothing more and the controller held no authorization identifier, so any signed-in account on the platform could page any estate's robot list — which projects `appKey` and `appToken`, the robot's credentials — and aim a robot registration at any estate. Reach is now decided from the session, on the site named in the request. A registration naming no estate is refused rather than falling through unscoped, and `createdBy` is taken from the session rather than from the request body.
|
|
1616
|
+
- 88000a3: Scope the site billing configuration routes to the estate, and take createdBy from the session
|
|
1617
|
+
- 477139d: Scope the site billing item routes to the estate, and check the unit list against the item's own site
|
|
1618
|
+
- 1f71a3e: Scope the site facility routes to the estate. `/api/site-facilities` carried
|
|
1619
|
+
`requireAuth` and nothing else on all six routes: any signed-in account could add a
|
|
1620
|
+
facility to another estate, list another client's facilities — or, by omitting the
|
|
1621
|
+
optional `site` filter, every estate's facilities at once — and read, rewrite or
|
|
1622
|
+
archive any facility by id alone, including its opening hours, booking fees, deposit
|
|
1623
|
+
and policies. Reach is now checked on the site named in the request, and on the
|
|
1624
|
+
stored row where the request names only an id; the unfiltered cross-estate list is
|
|
1625
|
+
left to platform staff.
|
|
1626
|
+
- 97a5e71: Scope the statement of account routes to the estate and the unit, and take the requester and approver from the session
|
|
1627
|
+
|
|
1628
|
+
Eight of the nine routes on `/api/site-soa` carried `requireAuth` and nothing
|
|
1629
|
+
more, and `site-soa.controller.ts` held no authorization identifier of any kind.
|
|
1630
|
+
A statement of account is one unit's ledger for a period, so any signed-in
|
|
1631
|
+
account on the platform could read another estate's statements, restate one,
|
|
1632
|
+
approve or reject it, move it to another estate, or delete it.
|
|
1633
|
+
|
|
1634
|
+
`GET /user` is named for the signed-in resident but took `site` and `unitId`
|
|
1635
|
+
from the query and identified the caller not at all, so a resident could read a
|
|
1636
|
+
neighbour's statement of account by changing one id in a URL. It now requires
|
|
1637
|
+
`unitId` and checks it against a live `people` row joining the caller to that
|
|
1638
|
+
unit, via `requireOwnUnit`.
|
|
1639
|
+
|
|
1640
|
+
`add` set `createdBy`, and `reviewSOA` set `approvedBy._id`, by parsing the raw
|
|
1641
|
+
`user` cookie off the request, so a statement could be requested under another
|
|
1642
|
+
resident's name and approved under another person's. Both now come from
|
|
1643
|
+
`callerId(req)`.
|
|
1644
|
+
|
|
1645
|
+
Adds `requireStatementReach` plus an uncached `getRawStatementOfAccountById`.
|
|
1646
|
+
`PUT /id/:id` checks both ends, because `schemaUpdateStatementOfAccount` accepts
|
|
1647
|
+
`site` and `updateById` writes it.
|
|
1648
|
+
|
|
1649
|
+
`GET /generate-pdf` is deliberately unchanged: it is the platform's PDF service,
|
|
1650
|
+
called from every web app by `@7365admin1/layer-common`'s `usePDFDownload`, and
|
|
1651
|
+
it is authorized by forwarding the caller's own session to the headless browser.
|
|
1652
|
+
|
|
1653
|
+
- dd01b3e: Scope the unit billing routes to the estate, and the resident routes to the caller's own unit
|
|
1654
|
+
|
|
1655
|
+
`/api/site-unit-billing` carried `requireAuth` and nothing more on all seven
|
|
1656
|
+
handlers, and `site-unit-billing.controller.ts` held no authorization identifier
|
|
1657
|
+
of any kind. Two separate problems followed.
|
|
1658
|
+
|
|
1659
|
+
On the estate side, any signed-in account could add a bill to any estate, read
|
|
1660
|
+
another estate's whole billing run, restate what a resident owes, move a bill to
|
|
1661
|
+
another estate, or delete it. Those five routes now use `requireSiteReach`, plus
|
|
1662
|
+
a new `requireUnitBillingReach` on the stored row and an uncached
|
|
1663
|
+
`getRawUnitBillingById`. `PUT /id/:id` checks both ends, because
|
|
1664
|
+
`schemaUpdateSiteUnitBilling` accepts `site` and `updateById` writes it.
|
|
1665
|
+
|
|
1666
|
+
On the resident side, `GET /user` and `GET /user/unsettled` are named for the
|
|
1667
|
+
signed-in resident but took `site` and `unitId` from the QUERY and identified the
|
|
1668
|
+
caller not at all, so they were never "my bills" — they were any unit's bills at
|
|
1669
|
+
any estate. A resident passing a neighbour's unit id got the neighbour's
|
|
1670
|
+
maintenance bills, and omitting `unitId` dropped that filter too and returned the
|
|
1671
|
+
whole estate's. Both routes now require `unitId` and check it against a live
|
|
1672
|
+
`people` row joining the caller to that unit at that site, via a new
|
|
1673
|
+
`requireOwnUnit`. Seven365 platform staff are exempt.
|
|
1674
|
+
|
|
1675
|
+
- c1d9d07: Let Seven365 staff switch a client's faulty camera off, and record it
|
|
1676
|
+
|
|
1677
|
+
`PATCH /api/site-cameras/id/:id` now falls back to a Seven365 staff check when
|
|
1678
|
+
the ordinary site rule refuses. The fallback writes `status` only and is
|
|
1679
|
+
recorded in the console audit trail. Reads, `add` and `deleteById` are
|
|
1680
|
+
unchanged, so staff have gained no access to any organisation's camera list.
|
|
1681
|
+
|
|
1682
|
+
- 7ec7f3a: Check the vehicle-approval permission under the resource name the catalogues
|
|
1683
|
+
actually grant.
|
|
1684
|
+
|
|
1685
|
+
When a security agency adds a vehicle, the vehicle is parked at `PENDING` unless
|
|
1686
|
+
the caller may approve one. `vehicle.service.ts:125` asked for
|
|
1687
|
+
`vehicleManagement:approve-vehicle`, but every catalogue that can grant a
|
|
1688
|
+
permission spells that resource `vehicle-mgmt` — `web-app-security`
|
|
1689
|
+
`composables/useSecurityPermission.ts:87,111`, `web-app-org`
|
|
1690
|
+
`composables/usePropertyPermission.ts:312`, `ma-mobile-app`
|
|
1691
|
+
`src/features/permissions/permission.catalog.ts:7`, `isecure365-mobile-app`
|
|
1692
|
+
`src/features/permissions/permission.catalog.ts:32`. Only the prefix differed,
|
|
1693
|
+
so the string checked here was one no role could hold and the bypass could never
|
|
1694
|
+
be reached by granting the permission — the agency's vehicles stayed pending
|
|
1695
|
+
however the role was set up. `vehicleManagement:` appears nowhere else in any
|
|
1696
|
+
repository in the organisation.
|
|
1697
|
+
|
|
1698
|
+
The `*` wildcard branch is untouched, so a role holding all permissions behaves
|
|
1699
|
+
exactly as before; this only makes the specific grant work as intended.
|
|
1700
|
+
|
|
1701
|
+
- 772cd89: Scope the visitor guest routes to the estate
|
|
1702
|
+
|
|
1703
|
+
`/api/visitor-guests` carried `requireAuth` and nothing more on all four
|
|
1704
|
+
routes, so any signed-in account could add, read, edit or delete another
|
|
1705
|
+
estate's expected-visitor list. `site` was optional on the list query and the
|
|
1706
|
+
repository only applies it when valid, so omitting it returned every estate's
|
|
1707
|
+
visitor records at once. All four handlers now ask the existing site-reach
|
|
1708
|
+
rule, and `site` is required on the list query.
|
|
1709
|
+
|
|
3
1710
|
## 3.48.0
|
|
4
1711
|
|
|
5
1712
|
### Minor Changes
|