@7365admin1/core 3.48.0 → 3.48.1-staging.149
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/.changeset/access-management-site-scope.md +59 -0
- package/.changeset/customer-site-org-scope.md +48 -0
- package/.changeset/estate-structure-write-scope.md +51 -0
- package/.changeset/member-read-and-status-scope.md +40 -0
- package/.changeset/member-type-platform-staff-gate.md +71 -0
- package/.changeset/organization-read-scope.md +44 -0
- package/.changeset/people-read-scope.md +40 -0
- package/.changeset/person-lookup-scope.md +44 -0
- package/.changeset/person-search-filter-collision.md +26 -0
- package/.changeset/person-write-scope.md +38 -0
- package/.changeset/role-scope-and-admin-type.md +58 -0
- package/.changeset/site-org-scope.md +46 -0
- package/.changeset/vehicle-approve-permission-prefix.md +22 -0
- package/.changeset/verification-status-transition.md +35 -0
- package/dist/index.d.ts +103 -39
- package/dist/index.js +8569 -8064
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6064 -5546
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/e2e/access-management-scope.e2e.test.mjs +363 -0
- package/test/e2e/customer-site-scope.e2e.test.mjs +337 -0
- package/test/e2e/document-scope.e2e.test.mjs +299 -0
- package/test/e2e/estate-structure-scope.e2e.test.mjs +346 -0
- package/test/e2e/harness.mjs +335 -2
- package/test/e2e/member-cross-tenant.e2e.test.mjs +256 -0
- package/test/e2e/organization-read-scope.e2e.test.mjs +225 -0
- package/test/e2e/people-scope.e2e.test.mjs +230 -0
- package/test/e2e/people-search-filters.e2e.test.mjs +215 -0
- package/test/e2e/person-lookup-scope.e2e.test.mjs +265 -0
- package/test/e2e/person-write-scope.e2e.test.mjs +336 -0
- package/test/e2e/privilege-escalation.e2e.test.mjs +393 -0
- package/test/e2e/role-cross-tenant.e2e.test.mjs +305 -0
- package/test/e2e/site-scope.e2e.test.mjs +321 -0
- package/test/e2e/vehicle-scope.e2e.test.mjs +331 -0
- package/test/e2e/verification-status.e2e.test.mjs +159 -0
- package/test/member-scope.test.mjs +112 -0
- package/test/member-staff-gate.test.mjs +148 -0
- package/test/member-type-allowlist.test.mjs +99 -0
- package/test/people-scope.test.mjs +90 -0
- package/test/role-scope.test.mjs +175 -0
- 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.
|