@7365admin1/core 3.48.0 → 3.48.1-staging.148

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.
Files changed (42) hide show
  1. package/.changeset/access-management-site-scope.md +59 -0
  2. package/.changeset/customer-site-org-scope.md +48 -0
  3. package/.changeset/estate-structure-write-scope.md +51 -0
  4. package/.changeset/member-read-and-status-scope.md +40 -0
  5. package/.changeset/member-type-platform-staff-gate.md +71 -0
  6. package/.changeset/organization-read-scope.md +44 -0
  7. package/.changeset/people-read-scope.md +40 -0
  8. package/.changeset/person-lookup-scope.md +44 -0
  9. package/.changeset/person-search-filter-collision.md +26 -0
  10. package/.changeset/person-write-scope.md +38 -0
  11. package/.changeset/role-scope-and-admin-type.md +58 -0
  12. package/.changeset/site-org-scope.md +46 -0
  13. package/.changeset/vehicle-approve-permission-prefix.md +22 -0
  14. package/.changeset/verification-status-transition.md +35 -0
  15. package/dist/index.d.ts +96 -39
  16. package/dist/index.js +8532 -8076
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +6028 -5559
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +2 -2
  21. package/test/e2e/access-management-scope.e2e.test.mjs +363 -0
  22. package/test/e2e/customer-site-scope.e2e.test.mjs +337 -0
  23. package/test/e2e/document-scope.e2e.test.mjs +299 -0
  24. package/test/e2e/estate-structure-scope.e2e.test.mjs +346 -0
  25. package/test/e2e/harness.mjs +335 -2
  26. package/test/e2e/member-cross-tenant.e2e.test.mjs +256 -0
  27. package/test/e2e/organization-read-scope.e2e.test.mjs +225 -0
  28. package/test/e2e/people-scope.e2e.test.mjs +230 -0
  29. package/test/e2e/people-search-filters.e2e.test.mjs +215 -0
  30. package/test/e2e/person-lookup-scope.e2e.test.mjs +265 -0
  31. package/test/e2e/person-write-scope.e2e.test.mjs +336 -0
  32. package/test/e2e/privilege-escalation.e2e.test.mjs +393 -0
  33. package/test/e2e/role-cross-tenant.e2e.test.mjs +305 -0
  34. package/test/e2e/site-scope.e2e.test.mjs +321 -0
  35. package/test/e2e/vehicle-scope.e2e.test.mjs +331 -0
  36. package/test/e2e/verification-status.e2e.test.mjs +159 -0
  37. package/test/member-scope.test.mjs +112 -0
  38. package/test/member-staff-gate.test.mjs +148 -0
  39. package/test/member-type-allowlist.test.mjs +99 -0
  40. package/test/people-scope.test.mjs +90 -0
  41. package/test/role-scope.test.mjs +175 -0
  42. package/test/vehicle-approve-permission.test.mjs +47 -0
@@ -0,0 +1,59 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the access-management routes to a site the caller can reach.
6
+
7
+ Every route on `/api/access-management` carried `requireAuth` and nothing more.
8
+ `requireAuth` proves a session exists; it makes no organisation or site
9
+ decision. So any signed-in account on the platform — a resident of a different
10
+ estate, a cleaner, anybody with a login — could read another client's
11
+ access-card inventory, its card holders, the residents behind a unit and the
12
+ block/level/unit structure, and could ISSUE a physical NFC card into that
13
+ estate, generate visitor QR passes for it and stamp QR tags onto its cards.
14
+ These are the credentials that open that estate's doors and lifts.
15
+
16
+ Twenty-eight handlers now decide from the site the request already names,
17
+ before any work is done:
18
+
19
+ - from the body — `addPhysicalCard`, `addNonPhysicalCard`, `acknowlegdeCard`,
20
+ `replaceCard`, `assignAccessCardToUnit`, `vmsgenerateQrCodes`,
21
+ `addVisitorAccessCard`;
22
+ - from the query — `allAccessCardsCounts`, `availableAccessCards`,
23
+ `userTypeAccessCards`, `assignedAccessCards`, `assignedAccessCardsByUnit`,
24
+ `accessandLiftCards`, `doorAndLiftDropdown`, `visitorAccessCards`,
25
+ `bulkPhysicalAccessCard`, `getCardDetails`, `allQrTag`,
26
+ `availableCardContractors`, `getBlockLevelAndUnitList`, `getResidents`,
27
+ `userAccessCards`, `uploadTemplate`, `removeTemplate`, `qrCodeList`;
28
+ - from the URL — `getCardReplacement`, `getAccessManagementSettings`, `addQrTag`.
29
+
30
+ The rule is the existing one, `requireSiteReach` -> `entitleSite`: a membership
31
+ pinned to the site, an org-wide membership at the site's owning organisation, or
32
+ an ACTIVE `customer.sites` engagement. The engagement branch is required, not
33
+ incidental — access cards are administered from the security and
34
+ property-management apps, whose users hold no membership in the estate's own
35
+ organisation.
36
+
37
+ The guard writes its own response instead of throwing. Every handler in this
38
+ controller wraps its body in `try { ... } catch { res.status(400|500) }` rather
39
+ than calling `next(error)`, so a thrown `UnauthorizedError`/`NotFoundError`
40
+ would never reach the error handler and would have surfaced as a 400 with the
41
+ guard's message inside it. It is placed AFTER each handler's Joi validation, so
42
+ a malformed or missing site still answers 400 exactly as before, and only a
43
+ well-formed site the caller cannot reach is refused. That refusal is 404 — what
44
+ a site that does not exist already answers — so site ids cannot be enumerated by
45
+ watching the difference.
46
+
47
+ Six routes on the mount are deliberately NOT changed, because they carry no
48
+ site at all and need per-record resolution:
49
+
50
+ - `POST /sign-qr` and `PATCH /visitor-checkout` — both are called by shipped
51
+ mobile clients that cannot update in lockstep
52
+ (`resident-mobile-app src/actions/entrypass.ts:66`, `isecure365-mobile-app
53
+ src/features/visitors/entry-pass.service.ts:175` and `visitor.service.ts:194`).
54
+ - `PATCH /nfc-status`, `PATCH /delete-card`, `POST /card-replacement`,
55
+ `POST /assign-user` — each identifies a card by id and would need the card's
56
+ own site loaded first.
57
+ - `GET /door-access-levels`, `GET /lift-access-levels`, `GET /access-groups` and
58
+ `POST /settings` take an `acm_url` — an access-control device URL — so they
59
+ are a separate question from site reach and were not touched here.
@@ -0,0 +1,48 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope `/api/customer-sites` to the organisations on the record.
6
+
7
+ Every route on this module except the pre-login invite acceptance carried
8
+ `requireAuth` and nothing more, and took the organisation straight out of the
9
+ query string or the body (`customer-site.controller.ts`). Two things followed
10
+ from that, and both are closed here.
11
+
12
+ `POST /api/customer-sites` is not merely an engagement record: it CREATES A SITE
13
+ inside whatever `siteOrg` the body names, and writes that site onto the
14
+ organisation as its default when it has none
15
+ (`customer-site.service.ts:77-111`). Any signed-in account on the platform — a
16
+ resident, a cleaner, a guard — could therefore plant a site inside any client.
17
+
18
+ A `customer.sites` row is also the cross-organisation access grant itself: the
19
+ "engaged agency" branch of `resolveSiteAccess` reads it to decide who may reach
20
+ a site's cameras, HID readers and residents
21
+ (`camera-view.service.ts:325-341`). Being able to write, edit or delete those
22
+ rows by id was being able to grant yourself reach into another client's estate,
23
+ or revoke a contractor's.
24
+
25
+ The caller is now resolved from the session and the decision made before
26
+ anything is read or written:
27
+
28
+ - `POST /` requires `requireOrgAccess` on the body's `org`, and on `siteOrg`
29
+ too when it names a different organisation — because that is where the new
30
+ site lands. The Add / Edit Site form sends the same id for both
31
+ (`web-app-org components/SiteForm.vue:397-402`).
32
+ - `GET /` requires `requireOrgAccess` on the `org` filter the caller sends.
33
+ - `GET /:id`, `PUT /:id` and `DELETE /:id` load the row first and decide from
34
+ the row's own `org` and `siteOrg` — Seven365 staff, or a live member of
35
+ EITHER side of the engagement. Either, not one: the estate's manager reaches
36
+ the row through `siteOrg` and the contracted agency reaches the same row
37
+ through `org`, so scoping to one side would break the other's screen.
38
+ - `GET /service-provider/:id` takes a SITE id, and uses `entitleSite` — the
39
+ same site-reach rule the camera, HID and people modules already use, so an
40
+ agency contracted to a site still lists who else works there.
41
+
42
+ `POST /invite/:id` is unchanged and still reachable with no session: an invited
43
+ organisation accepts before it has an account (`web-app-main pages/sign-in.vue:182`).
44
+
45
+ No new authorization mechanism is introduced. `requireOrgAccess` and
46
+ `entitleSite` are the helpers the member, role, people, camera and HID paths
47
+ already use, and the "either side" test is `resolveInviteActor` asked about two
48
+ organisations in one round trip rather than two.
@@ -0,0 +1,51 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the estate-structure writes to the site they act on.
6
+
7
+ `POST/PATCH/PUT` on `/api/buildings`, `/api/building-levels` and
8
+ `/api/building-units` all carried `requireAuth` and nothing more. The site came
9
+ from the request body on the creates and the record id came from the URL on the
10
+ edits and deletes, and neither was checked against the caller. So any signed-in
11
+ account on the platform could add a block to another client's estate, rename its
12
+ levels in bulk, or delete its units — the records residents, visitor passes,
13
+ billing and access rights are all hung off.
14
+
15
+ Ten routes now decide from the site:
16
+
17
+ - `POST /api/buildings/`, `POST /api/building-levels/`,
18
+ `POST /api/building-units/` — from the site named in the body, which is where
19
+ the new record lands.
20
+ - `PATCH /api/buildings/id/:id`, `PUT /api/buildings/:id`,
21
+ `PUT /api/building-levels/id/:id`, `PUT /api/building-levels/:id`,
22
+ `PATCH /api/building-units/id/:id`, `PUT /api/building-units/:id` — the stored
23
+ record is loaded first and its own site decides. The id in the URL selects the
24
+ record; it never authorises it.
25
+ - `PATCH /api/building-levels/batch` — EVERY id in the batch is checked, not
26
+ just the first, and only the distinct sites are looked up so renaming twelve
27
+ levels of one block stays one check.
28
+
29
+ The rule is `entitleSite`, the site-reach rule the camera, HID and people
30
+ modules already use: a membership pinned to the site, an org-wide membership at
31
+ the site's owning organisation, or an ACTIVE `customer.sites` engagement. The
32
+ engagement branch is required, not incidental — the estate structure is
33
+ maintained from the property-management and service-provider apps, whose users
34
+ may hold no membership in the estate's own organisation.
35
+
36
+ The shared four lines live in a new `src/utils/site-reach.util.ts` so the same
37
+ check is not written out in three controllers. It introduces no second
38
+ mechanism: it resolves the caller with `resolveInviteActor` and defers to
39
+ `entitleSite`, exactly as `person.controller.ts` does.
40
+
41
+ A site the caller cannot reach answers as a site that does not exist, so site
42
+ ids cannot be enumerated by watching the difference. A record with no site at
43
+ all is refused rather than waved through.
44
+
45
+ The READS on these three routers are deliberately unchanged. Six of them share
46
+ a controller function with a pre-login signup alias
47
+ (`/buildings/list/site/resident/:siteId`, `/building-levels/list/site/resident/:siteId`,
48
+ `/building-units/resident/site/:site/block/:block/level/:level` and the three
49
+ `resident` variants), which the resident mobile app calls before an account
50
+ exists. Guarding the handler would break registration on a shipped client, so
51
+ those need the alias split first.
@@ -0,0 +1,40 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the member list and the member suspend/reactivate write to their own organisation.
6
+
7
+ `GET /api/members` took its `org` and `siteId` straight out of the query string,
8
+ and `PUT /api/members/:id/:status` took the member id straight out of the URL.
9
+ Both carried `requireAuth` and nothing more. So any signed-in account on the
10
+ platform could list another client's staff, and suspend or reactivate any member
11
+ of any client.
12
+
13
+ `GET /api/members` also had a hole in its own scope requirement. Line 170 refused
14
+ a query that named neither an organisation nor a user — **unless**
15
+ `type === "admin"`, which then listed Seven365's own staff to anybody signed in.
16
+
17
+ Both now decide from the session:
18
+
19
+ - `type=admin` requires Seven365 staff. That query belongs to the staff console
20
+ (`web-app-org pages/super-admin`), so it is gated rather than removed.
21
+ - a caller-supplied `org` is checked with `requireOrgAccess` before it is used —
22
+ Seven365 staff, or a live member of that organisation.
23
+ - `user` on its own stays open for the caller's OWN id, because "which
24
+ organisations am I a member of" is every service app's landing page
25
+ (`web-app-security pages/index.vue:118` and its siblings). Asking it about
26
+ somebody else now requires staff.
27
+ - `PUT /api/members/:id/:status` loads the stored membership and authorises on
28
+ the organisation recorded on it — never one from the request. A membership with
29
+ no organisation is a platform staff row, and only Seven365 staff may touch it;
30
+ a missing org is refused, not waved through.
31
+
32
+ Service providers are not special-cased and do not need to be: a provider holds a
33
+ membership in the organisation whose site it works on — that is how
34
+ `GET /api/members/user/:id/app/:type?org=` finds its own row at sign-in — so the
35
+ guard app's member list (`isecure365-mobile-app
36
+ src/features/patrol/use-patrol.ts:180`, which sends `org: site.org` with a
37
+ `siteId`) passes on the ordinary rule.
38
+
39
+ Nothing changes for a permitted caller: same response shape, same fields, same
40
+ status.
@@ -0,0 +1,71 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Only Seven365 staff can create a Seven365 staff membership.
6
+
7
+ `members.type` decides who is platform staff. `isSuperAdmin` answers true for a
8
+ live `members` row of type `"admin"` whose role is also of type `"admin"`, and
9
+ that field was validated as `Joi.string().required()` — a free-form string with
10
+ no allow-list. Every request path that writes a membership takes the value
11
+ straight from the caller:
12
+
13
+ - `POST /api/members/direct` writes the body's `app` verbatim as `type`
14
+ (`member.service.ts:137`);
15
+ - `POST /api/auth/invite` stores the body's `app` on the invitation, and
16
+ accepting it writes that value as `type` (`member.service.ts:73`);
17
+ - `PUT /api/members/id/:id/role/:role/type/:type/org/:org` re-pointed a
18
+ membership at any role, including the seeded platform one, checking only that
19
+ an owner role existed and that the target was not the last owner — never who
20
+ was asking.
21
+
22
+ All three are `requireAuth`-only. So any signed-in account — a resident, a
23
+ cleaner, a guard — could post `{ app: "admin" }`, or invite itself as `admin`
24
+ and accept its own invitation, and be Seven365 platform staff on the next
25
+ request. That defeats every gate built on `isSuperAdmin`: the staff console,
26
+ service-provider approvals, the notification broadcast and client suspension.
27
+ Executed end to end against a throwaway in-memory database, the chain succeeded
28
+ at every step.
29
+
30
+ Three changes, all fail-closed:
31
+
32
+ - `members.type` now validates against `MEMBER_TYPES`, enumerated from the
33
+ values this package actually writes — `admin` (`user.service.ts`
34
+ `createDefaultUser` and the console's own invitations), `organization` (the
35
+ default in `member.service.ts`, `user.service.ts`, `user-v2.service.ts` and
36
+ `subscription.service.ts`), `resident` (`person.service.ts`), and the eight
37
+ `AppServiceType` service memberships, the same list `organization.model.ts`
38
+ already accepts as an organisation's `nature`.
39
+ - `createMemberDirect` refuses `app: "admin"`, and refuses a role of type
40
+ `"admin"`, unless the caller is already platform staff. The caller is resolved
41
+ from the session by the controller and passed in; it is never read from a body
42
+ or a URL.
43
+ - `createUserInvite` refuses `app: "admin"` unless the caller is already
44
+ platform staff, so the invitation road to the same place is shut too.
45
+
46
+ `updateRoleById` refuses to move a membership onto a role of type `"admin"` for
47
+ the same reason — that was step 6 of the chain, the one that turned
48
+ `isPlatformOwner` true by pointing a membership at the seeded owner role.
49
+
50
+ The seeded role marker `default: true` was re-checked rather than assumed and is
51
+ still not forgeable: Joi rejects unknown keys on all three role schemas.
52
+
53
+ Every legitimate caller across the sibling repos was checked and still works.
54
+ `web-app-main pages/sign-in.vue` and `pages/verify/email/index.vue`, and
55
+ `web-app-org pages/onboarding/getting-started/index.vue`, all pass
56
+ `app: "organization"` or a service type — none reaches a gate. The one caller
57
+ that does send `"admin"` is the staff console itself
58
+ (`web-app-org pages/super-admin/invitations/index.vue:403`), operated by an
59
+ account that already is staff.
60
+
61
+ One transitional case is kept deliberately. `web-app-org
62
+ pages/org/[organization]/invitations/invite.vue:122` and `web-app-hygiene
63
+ pages/[org]/[site]/invitations/invite.vue:135` send the ORGANISATION ID as the
64
+ invitation's `app`, so accepting one of those invitations writes an org id into
65
+ `members.type`. That is a defect in those two screens, but it is live: the
66
+ validator therefore also accepts a 24-character hex string, with the two file
67
+ references named at the line, to be removed once both screens send a real
68
+ membership type. It cannot be used to escalate — `"admin"` is not 24 hex
69
+ characters, so the allow-list still decides that.
70
+
71
+ No response shape, status code or field changed for a permitted caller.
@@ -0,0 +1,44 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the organisation lookups so they stop mapping one client to another.
6
+
7
+ Four reads on `/api/organizations` carried `requireAuth` and nothing more, and
8
+ every one took its key straight from the URL:
9
+
10
+ - `GET /api/organizations/user/:user` — which organisations ANY person belongs to
11
+ - `GET /api/organizations/orgs/:email` — the same, keyed on ANY email address
12
+ - `GET /api/organizations/name/:name` — any organisation, by name
13
+ - `GET /api/organizations/email/:email` — any organisation, by email address
14
+
15
+ Together those are a cross-client directory and an account-enumeration oracle: a
16
+ resident of one estate could map which clients an address or a user id belongs
17
+ to across the whole platform.
18
+
19
+ Each is now decided from the session:
20
+
21
+ - `user/:user` — the caller's own id, or Seven365 staff. Every live caller asks
22
+ about itself: the seven service-provider app layouts, the org app, and the
23
+ resident mobile app all pass the signed-in user's own id.
24
+ - `orgs/:email` — the caller's own address, compared case-insensitively after
25
+ being resolved from the session, or Seven365 staff. All three live callers
26
+ pass the signed-in user's own address.
27
+ - `name/:name` — Seven365 staff. Its only caller is the staff console's client
28
+ list.
29
+ - `email/:email` — Seven365 staff. No caller exists in any repository in the
30
+ organisation.
31
+
32
+ A key that does not exist is refused exactly as one that does, so the refusal
33
+ itself is not an oracle.
34
+
35
+ `GET /api/organizations/id/:id` is deliberately NOT guarded here: the same
36
+ controller function is mounted a second time, unauthenticated, at
37
+ `/api/organizations/org/id/:id`, which the sign-up-invite screens call before an
38
+ account exists. Guarding the handler would guard both mounts and break
39
+ registration. That one needs the alias split first.
40
+
41
+ `GET /api/organizations/` (the full client list) is also unchanged and is
42
+ recorded as a decision for the leads: it is reached from a live signup path that
43
+ must move onto the organisation `POST /api/organizations/onboarding` already
44
+ returns before the list can be staff-gated.
@@ -0,0 +1,40 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope `GET /api/people` to a site the caller can actually reach.
6
+
7
+ The endpoint returns resident and visitor records — name, NRIC, contact number,
8
+ unit, plate numbers — and filters on NRIC, contact and plate number. It carried
9
+ `requireAuth` and nothing more, and took its `org` and `site` from the query
10
+ string (`person.controller.ts:79-80, 93`). So any signed-in account on the
11
+ platform could page through another estate's residents, or look a person up by
12
+ NRIC across every client at once.
13
+
14
+ The caller is now resolved from the session and the scope decided before
15
+ anything is read:
16
+
17
+ - a named `site` is checked with `entitleSite` — the same site-reach rule the
18
+ camera and HID modules already use: a membership at the site, an org-wide
19
+ membership at the site's owning organisation, or an ACTIVE `customer.sites`
20
+ engagement. Reusing it matters rather than being tidy: an agency contracted to
21
+ an estate holds no membership in that estate's organisation, so an
22
+ org-membership check would have locked every guard out of the site they work
23
+ at. A site the caller cannot reach answers exactly as a site that does not
24
+ exist, so site ids cannot be enumerated.
25
+ - a named `org` is checked against the caller's own memberships.
26
+ - naming neither — the NRIC, contact and plate lookups — is narrowed to the
27
+ caller's own organisations instead of searching the whole platform.
28
+ - Seven365 staff keep cross-tenant reach, unchanged.
29
+
30
+ Every live caller passes a site: `layer-common` `VisitorForm.vue`,
31
+ `MemberInformation.vue` and `VehicleForm.vue` all pass the site the screen is
32
+ open on, and the People list passes org and site from its route. The two
33
+ composable functions that pass neither — `usePeople().findPersonByContact` and
34
+ `findUsersByPlateNumber` — have no caller in any repository.
35
+
36
+ `person.repo.getAll` gains an optional `orgs` filter for the last case. It
37
+ applies only when no explicit `org` was given, so it cannot fight one, and its
38
+ absence means "no restriction", which is what staff get.
39
+
40
+ No response shape, status code or field changed for a permitted caller.
@@ -0,0 +1,44 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the single-value people lookups to a site the caller can reach.
6
+
7
+ Seven endpoints on `/api/people` carried `requireAuth` and nothing else and were
8
+ not scoped at all:
9
+
10
+ | Route | What it answered |
11
+ |---|---|
12
+ | `GET /nric/:nric` | a `findOne` on NRIC across the **whole platform** |
13
+ | `GET /contact/:contact` | the same, on contact number |
14
+ | `GET /plateNumber/:plateNumber` | every person with that plate, anywhere |
15
+ | `GET /unit/:unit` | every person at that unit id |
16
+ | `GET /user/:userId` | the person record behind any user id |
17
+ | `GET /company` | company names aggregated across every client |
18
+ | `GET /all-nric` | a paged NRIC list at a caller-named site |
19
+
20
+ So any signed-in account could type an NRIC or a car plate and read the matching
21
+ resident of any estate — name, unit, contact number, plates. #1907 scoped the
22
+ paged list (`GET /api/people`); these are the lookups beside it.
23
+
24
+ None of them takes a site — `layer-common usePeople.findPersonByNRIC`,
25
+ `searchCompanyList` and `getPeopleByUnit` pass only the value being looked up —
26
+ so the scope is decided **after** the read, on the record, with the same
27
+ site-reach rule the camera, HID and people-list already use. A record the caller
28
+ cannot reach is left out, which for these endpoints is exactly what "no such
29
+ person" already looked like: `null`, or an empty list. **No client sees a new
30
+ response shape or a new status code.** `entitleSite` is asked once per distinct
31
+ site, not once per record.
32
+
33
+ `/all-nric` requires a site in the query, so that one is checked before the read
34
+ and answers `404` for a site the caller cannot reach — the same answer a site
35
+ that does not exist gives, so ids cannot be probed.
36
+
37
+ `/company` has no site or unit to scope on, so it is narrowed to the caller's
38
+ own organisations — the fallback `getAll` already uses. `person.repo.getCompany`
39
+ gains an optional `orgs` filter; absent means no restriction, which is what
40
+ Seven365 staff get.
41
+
42
+ `GET /user/:userId` also always answers about **your own** record, whatever your
43
+ memberships say: the resident app reads it during sign-up and when resubmitting
44
+ a rejected registration.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Make the people search filters narrow each other instead of overwriting each
6
+ other.
7
+
8
+ `person.repo.getAll` built its Mongo filter as one object literal in which four
9
+ different filters each wrote a `$or` key — the date range (`dateTo`), the
10
+ free-text `search`, `contact` and `plateNumber`. An object literal can only
11
+ carry one `$or`, so the last one written silently replaced every earlier one.
12
+ In practice:
13
+
14
+ - searching by name **and** contact number searched only the contact number,
15
+ - searching by name **and** plate number searched only the plate number,
16
+ - and a **date range was dropped entirely** the moment any of the three was
17
+ used — which is the live case, because `layer-common`
18
+ `composables/usePeople.ts:2-30` sends `search`, `dateFrom` and `dateTo`
19
+ together from the visitor/people list.
20
+
21
+ No error was raised; the endpoint simply answered a narrower question than it
22
+ was asked, with rows that should not have matched.
23
+
24
+ The four groups are now collected and combined with `$and`, so each one narrows
25
+ the result. A single filter behaves exactly as before, and a request with no
26
+ filters is unchanged.
@@ -0,0 +1,38 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope the resident and visitor WRITES to a site the caller can actually reach.
6
+
7
+ `PUT /api/people/id/:id` (edit), `PUT /api/people/:id` (delete),
8
+ `PATCH /api/people/:id` (approve / reject / ask to resubmit) and the exported
9
+ `suspendResidentById` identify the person by an id in the URL and carried
10
+ `requireAuth` and nothing more. Any signed-in account on the platform — a
11
+ resident, a cleaner, a guard at a different estate — could rename, delete,
12
+ approve or reject any other client's resident. #1907 closed the read
13
+ (`GET /api/people`); these are the writes behind it.
14
+
15
+ The scope is resolved from the **stored record**, never from the request, and
16
+ that is not a stylistic choice: **not one live caller sends a site.**
17
+ `layer-common usePeople.deleteById` sends no body at all,
18
+ `reviewResidentPerson` sends only `{ status, remarks }`, and both
19
+ `web-app-property-management pages/[org]/[site]/people-mgmt/index.vue:915-917`
20
+ and `components/PeopleFormMgmt.vue:618-620` explicitly `delete payload.site`
21
+ before calling `updateById`. A guard that read the site out of the body would
22
+ have refused every real caller and protected nobody.
23
+
24
+ The rule is the site-reach rule the camera, HID and people-list already use —
25
+ membership of the site, an org-wide membership at the site's owner, or an active
26
+ `customer.sites` engagement — so an agency contracted to an estate keeps
27
+ working. A record carrying an organisation but no site falls back to that
28
+ organisation. Seven365 staff are unchanged.
29
+
30
+ A resident resubmitting their own registration from the mobile app still works:
31
+ `person.service.ts:155-166` writes a `members` row pinned to the site at
32
+ sign-up, before any approval, so a rejected resident still reaches their own
33
+ site. There is an end-to-end case for exactly that.
34
+
35
+ Also: **who approved a resident now comes from the session.** It was read out of
36
+ the `user` cookie the browser sends, falling back to whatever `approvedBy` the
37
+ request body claimed, so an approval could be attributed to anybody. No caller
38
+ sends `approvedBy`, so nothing is lost.
@@ -0,0 +1,58 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope role writes to the role's own organisation, and reserve platform roles for Seven365 staff.
6
+
7
+ Every write on `role.controller.ts` carried `requireAuth` and nothing more, and
8
+ the role id comes from the URL. So any signed-in account on the platform — a
9
+ resident, a cleaner, a guard — could reach any organisation's roles:
10
+
11
+ - `PATCH /api/roles/permissions/id/:id` rewrote another client's role, including
12
+ its owner role. Executed against a throwaway database during the audit, an
13
+ outsider rewrote a second organisation's owner role and got a 200.
14
+ - `PATCH /api/roles/id/:id` renamed it and replaced its permissions.
15
+ - `PUT /api/roles/deleted/role` and `PUT /api/roles/:id/delete-with-reassignments`
16
+ deleted it, the second one moving its members onto other roles on the way.
17
+ - `GET /api/roles/:id/deletion-preview` listed the members holding it — another
18
+ client's staff list by another name.
19
+ - `POST /api/roles` and `POST /api/roles/v2` minted a role of `type: "admin"` —
20
+ the half of `isSuperAdmin` that lives in the roles collection.
21
+
22
+ Each of the five writes now loads the role first and authorises on the
23
+ organisation stored ON THAT ROLE, never on one the caller supplied:
24
+
25
+ - a role carrying an `org` is a tenant's, and goes through `requireOrgAccess` —
26
+ Seven365 staff, or a live member of that organisation. That is the rule the
27
+ invitation and subscription paths already use; no new mechanism is introduced.
28
+ - a role with NO `org` is a platform role — the staff console's own, and the one
29
+ `createDefaultUser` seeds — and only Seven365 staff may touch it. A missing org
30
+ is refused, not waved through.
31
+
32
+ `createRole` on both `/api/roles` and `/api/roles/v2` requires the caller to be
33
+ platform staff before it will accept `type: "admin"`. `/api/roles/v2` is a
34
+ separate mount onto a separate controller, so gating one and not the other would
35
+ have left the hole reachable by adding `/v2` to the URL.
36
+
37
+ A role the caller may not reach is reported the same as a role that does not
38
+ exist, so role ids cannot be enumerated by watching the difference.
39
+
40
+ Two things are deliberately NOT changed, and are asserted in the tests so they
41
+ read as decisions rather than oversights. `createRole` is not scoped to the
42
+ caller's own organisation, and neither are the role reads: `web-app-main
43
+ pages/sign-in.vue:311,439` and `pages/verify/email/index.vue:474` look up and
44
+ create the "Admin Service Provider" role in the INVITING organisation, which the
45
+ caller has not joined yet — that is what accepting a service-provider invitation
46
+ is, and an org-membership check there would break sign-in for every invited
47
+ provider. Scoping those needs the invitation to be consulted as well as the
48
+ membership, and is a separate change.
49
+
50
+ Every legitimate caller was checked and still works: the tenant screens
51
+ (`web-app-org pages/org/[organization]/roles-permissions.vue`, and the
52
+ `[org]/[site]/roles-permissions` screens in hygiene, security and
53
+ property-management, all through `layer-common RolePermissionMain.vue` and
54
+ `RolePermissionFormPreviewUpdate.vue`) edit roles in an organisation the operator
55
+ is a member of; the staff console (`web-app-org pages/super-admin/*` and the
56
+ retired admin app) is operated by staff and keeps cross-tenant reach.
57
+
58
+ No response shape or field changed for a permitted caller.
@@ -0,0 +1,46 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Scope `/api/sites` to the organisation that owns the site.
6
+
7
+ Every route on this router except the pre-login resident picker carried
8
+ `requireAuth` and nothing more (`site.controller.ts`), and took the
9
+ organisation from the query string or the site id from the URL. `requireAuth`
10
+ proves a session exists and makes no organisation or site decision, so any
11
+ signed-in account on the platform — a resident, a cleaner, an agency guard —
12
+ could `DELETE /api/sites/:id` any site belonging to any client, rename it,
13
+ re-block it, move its guard posts, rewrite its information page, create a site
14
+ inside any organisation, and page through any client's estate list.
15
+
16
+ The caller is now resolved from the session and the decision made before
17
+ anything is read or written:
18
+
19
+ - `POST /` uses `requireOrgAccess` on the body's `orgId` — that is where the new
20
+ site lands.
21
+ - `GET /` uses `requireOrgAccess` on the `org` filter the caller sends.
22
+ - `PUT /:id/block`, `PATCH /id/:id`, `PATCH /guard-post/id/:id`,
23
+ `PATCH /information/id/:id` and `DELETE /:id` use `entitleSite` — a
24
+ membership pinned to the site, an org-wide membership at the site's owning
25
+ organisation, or an ACTIVE `customer.sites` engagement. The engagement branch
26
+ is required, not incidental: Site Settings lives in the SERVICE-PROVIDER apps
27
+ (`web-app-security` and the five module apps, each
28
+ `pages/[org]/[site]/settings.vue` via `layer-common useSiteSettings.ts`), and
29
+ those users hold no membership in the estate's organisation at all.
30
+ - `GET /:id` is deliberately one notch wider than the writes: it also passes
31
+ for any live membership in the site's OWNING organisation. Three shipped
32
+ mobile clients read this route, and the resident app reads
33
+ `org.defaultSite` — which in a multi-site organisation need not be the
34
+ resident's own site, and a resident's membership carries a `siteId`. The
35
+ wider read closes the cross-CLIENT read, which is the defect, and leaves
36
+ site-to-site partitioning inside one client where it is today.
37
+
38
+ A site the caller cannot reach answers exactly as a site that does not exist,
39
+ so site ids cannot be enumerated by watching the difference.
40
+
41
+ `GET /site/resident` is unchanged and still pre-login: the resident mobile app
42
+ needs a site picker before an account exists.
43
+
44
+ No new authorization mechanism is introduced — `requireOrgAccess` and
45
+ `entitleSite` are the helpers the member, role, people, camera and HID paths
46
+ already use.
@@ -0,0 +1,22 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Check the vehicle-approval permission under the resource name the catalogues
6
+ actually grant.
7
+
8
+ When a security agency adds a vehicle, the vehicle is parked at `PENDING` unless
9
+ the caller may approve one. `vehicle.service.ts:125` asked for
10
+ `vehicleManagement:approve-vehicle`, but every catalogue that can grant a
11
+ permission spells that resource `vehicle-mgmt` — `web-app-security`
12
+ `composables/useSecurityPermission.ts:87,111`, `web-app-org`
13
+ `composables/usePropertyPermission.ts:312`, `ma-mobile-app`
14
+ `src/features/permissions/permission.catalog.ts:7`, `isecure365-mobile-app`
15
+ `src/features/permissions/permission.catalog.ts:32`. Only the prefix differed,
16
+ so the string checked here was one no role could hold and the bypass could never
17
+ be reached by granting the permission — the agency's vehicles stayed pending
18
+ however the role was set up. `vehicleManagement:` appears nowhere else in any
19
+ repository in the organisation.
20
+
21
+ The `*` wildcard branch is untouched, so a role holding all permissions behaves
22
+ exactly as before; this only makes the specific grant work as intended.
@@ -0,0 +1,35 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Restrict the unauthenticated invitation-status write to the one transition its
6
+ callers actually perform.
7
+
8
+ `PATCH /api/auth/verification/status` (`API-core auth.route.ts:36`) carries no
9
+ middleware, and it cannot be given any: every caller is an invitation-landing
10
+ page opened from an emailed link **before an account exists** —
11
+ `web-app-main` `pages/verify/invitation/[id].vue`,
12
+ `pages/verify/service-provider/[id].vue`,
13
+ `pages/verify/service-provider-invite/[id].vue` and
14
+ `pages/sign-up-invite/service-provider/index.vue`, all through
15
+ `layer-common composables/useLocalAuth.ts:130`. Adding `requireAuth` would break
16
+ invitation acceptance for everybody, including the shipped clients.
17
+
18
+ What is restricted instead is what an anonymous caller may *do*. Each of those
19
+ pages calls `GET /auth/verify/:id` first, and `verify`
20
+ (`verification.service.ts:661-690`) already refuses an invitation that is
21
+ expired, cancelled or complete — so the only transition a real caller ever
22
+ performs is `pending -> complete`. Accepting the other three states let anyone
23
+ who learned an invitation id:
24
+
25
+ - set a used invitation back to `pending` and replay it,
26
+ - revive a `cancelled` or `expired` invitation, or
27
+ - burn somebody else's pending invitation by marking it `complete` before they
28
+ opened the email.
29
+
30
+ The handler now accepts only `complete`, loads the invitation and requires it to
31
+ be `pending`. Completing an already-complete invitation still answers 200 so a
32
+ page that calls this twice is not broken. Cancelling an invitation on purpose
33
+ keeps its own session-protected endpoint,
34
+ `PUT /api/verifications/:id/cancel` (`verification.route.ts:13`), so no
35
+ capability is removed.