@7365admin1/core 3.32.2-staging.67 → 3.32.2-staging.69
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/camera-engagement-site-scope.md +15 -0
- package/.changeset/camera-list-site-scoping.md +42 -0
- package/dist/index.d.ts +35 -6
- package/dist/index.js +30 -17
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +30 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/camera-entitlement-wiring.test.mjs +46 -0
- package/test/camera-view.util.test.mjs +106 -6
- package/test/camera-write-gate.test.mjs +25 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Camera entitlement: an engagement grants a site only to an org-level membership, and permissions resolve against the granting role
|
|
6
|
+
|
|
7
|
+
Two follow-ups to the camera entitlement change:
|
|
8
|
+
|
|
9
|
+
- the engagement branch of `cameraGrant` ignored `members.siteId`, so a member
|
|
10
|
+
pinned to one site could reach every site their organisation is engaged at.
|
|
11
|
+
It now requires an org-level membership, matching the owning-org branch above
|
|
12
|
+
it: a membership pinned to a site never grants a different site.
|
|
13
|
+
- `getUserPermissions` accepts the granting membership's `role`, so a caller
|
|
14
|
+
holding several memberships in one organisation is no longer resolved by
|
|
15
|
+
whichever member document Mongo returns first.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scope `GET /site-cameras` to the caller's own site.
|
|
6
|
+
|
|
7
|
+
The site was an OPTIONAL query parameter and nothing on the handler looked at
|
|
8
|
+
the session beyond `requireAuth`. So any signed-in account, in any organisation,
|
|
9
|
+
could page the whole estate's cameras - on staging that is all 15 active IP
|
|
10
|
+
cameras across 3 owner organisations, each record carrying its `host` and its
|
|
11
|
+
`username`. It is the plainest cross-tenant read left in the camera module.
|
|
12
|
+
|
|
13
|
+
`site` is now required, and the caller is authorised against it with
|
|
14
|
+
`entitleSite`: membership of the site, of its owning organisation, or an ACTIVE
|
|
15
|
+
`customer.sites` engagement with it. "Not yours" and "does not exist" answer
|
|
16
|
+
identically, so site ids cannot be enumerated from the difference.
|
|
17
|
+
|
|
18
|
+
**Every caller in the estate already sends `site`** - checked across every
|
|
19
|
+
repository, not assumed:
|
|
20
|
+
|
|
21
|
+
| caller | sends |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `iservice365-mobile-app-security` `pages/cctv/index.vue` (live) | `site`, `type: ip`, `page` |
|
|
24
|
+
| `iservice365-mobile-app-security` `useAnprEnabled` (live) | `site`, `type: anpr` |
|
|
25
|
+
| `iservice365-layer-common` `CameraMain.vue` (Site Settings) | `site`, `type`, `page` |
|
|
26
|
+
| `iservice365-layer-common` `CameraWall.vue` | `site`, `type: ip`, `page` |
|
|
27
|
+
| `iservice365-web-app-security` `VirtualPatrolRoutesForm.vue` | `site`, `type: ip`, `page: 1` |
|
|
28
|
+
| `isecure365-mobile-app` `site.service.ts` `getAnprCameras` | `site`, `type: anpr` |
|
|
29
|
+
|
|
30
|
+
So no running client breaks.
|
|
31
|
+
|
|
32
|
+
Deliberately NOT gated on a camera permission. This is the same list the site's
|
|
33
|
+
own Settings panel and the guard's app have always read; on staging only 25 of
|
|
34
|
+
the 118 people entitled to a site with a camera hold a camera-view permission,
|
|
35
|
+
so a permission gate here would strand people who are legitimately at the site.
|
|
36
|
+
Restricting the read to the caller's own sites is the fix; who among them may
|
|
37
|
+
look is a separate decision that needs its own measurement.
|
|
38
|
+
|
|
39
|
+
The repository's 15-minute cache key already carries the site, so a cached page
|
|
40
|
+
can only be served to callers authorised for that same site. No caller id was
|
|
41
|
+
added to the key - it would make the cache per-person for an answer that is
|
|
42
|
+
identical per site.
|
package/dist/index.d.ts
CHANGED
|
@@ -2658,6 +2658,12 @@ declare function hasAnyPermission(permissions: unknown, allowed: Array<string>):
|
|
|
2658
2658
|
type CameraMembership = {
|
|
2659
2659
|
siteId?: unknown;
|
|
2660
2660
|
org?: unknown;
|
|
2661
|
+
/**
|
|
2662
|
+
* The role this membership carries. Returned with the grant so permissions
|
|
2663
|
+
* are read off the role that actually granted access — see
|
|
2664
|
+
* `permissionsForGrant` in the service.
|
|
2665
|
+
*/
|
|
2666
|
+
role?: unknown;
|
|
2661
2667
|
};
|
|
2662
2668
|
/**
|
|
2663
2669
|
* WHICH membership lets this caller reach this site, or `null` for none.
|
|
@@ -2669,12 +2675,35 @@ type CameraMembership = {
|
|
|
2669
2675
|
* 2. **A membership at the site's OWNING organisation, with no site on it** —
|
|
2670
2676
|
* an org-wide role. 90 rows, 56 people, are recorded this way.
|
|
2671
2677
|
* 3. **An engagement** — an ACTIVE `customer.sites` row saying one of the
|
|
2672
|
-
* caller's organisations is contracted to serve this site
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
* (`useCustomerSite().getAll()` in the
|
|
2676
|
-
* camera path read it before, so a
|
|
2677
|
-
* was refused a camera the switcher
|
|
2678
|
+
* caller's organisations is contracted to serve this site, AND a membership
|
|
2679
|
+
* that is not pinned to some other site. This is how the product routes a
|
|
2680
|
+
* security agency to a property manager's site, and it is the list the web
|
|
2681
|
+
* apps' own site switcher is built from (`useCustomerSite().getAll()` in the
|
|
2682
|
+
* Security app's layout). Nothing in the camera path read it before, so a
|
|
2683
|
+
* guard whose agency is engaged at a site was refused a camera the switcher
|
|
2684
|
+
* had just offered them.
|
|
2685
|
+
*
|
|
2686
|
+
* ## Why the engagement branch requires an ORG-LEVEL membership
|
|
2687
|
+
*
|
|
2688
|
+
* **Product decision, owner, 2026-08-10, settled:** an agency engaged at several
|
|
2689
|
+
* sites gives its staff access to **the site each person is assigned to, and no
|
|
2690
|
+
* other**. It matches how the rest of the product is described — modules are set
|
|
2691
|
+
* up per site.
|
|
2692
|
+
*
|
|
2693
|
+
* So `!idOf(membership.siteId)` is required here exactly as branch 2 requires
|
|
2694
|
+
* it. Before, this branch matched on organisation alone, which made a person
|
|
2695
|
+
* pinned to site X reach every site their agency serves — while somebody pinned
|
|
2696
|
+
* to site X of the site's OWNING organisation could not reach its other sites.
|
|
2697
|
+
* The two branches now say the same thing: **a membership pinned to a site never
|
|
2698
|
+
* grants a different site**, and a membership with no site is an org-wide role
|
|
2699
|
+
* within whichever organisation holds it.
|
|
2700
|
+
*
|
|
2701
|
+
* Measured on staging before the change: this removes 68 (user, camera-site)
|
|
2702
|
+
* grants, all of them cross-organisation engagements held by a membership pinned
|
|
2703
|
+
* elsewhere; every one of those 68 belongs to somebody whose organisation is
|
|
2704
|
+
* engaged at the site they ARE pinned to, so nobody is left without their own
|
|
2705
|
+
* site. 0 grants through branch 2 are affected — **estate-side org-level access
|
|
2706
|
+
* is a different path and is untouched.**
|
|
2678
2707
|
*
|
|
2679
2708
|
* The membership is RETURNED, not just a yes/no, because the caller's
|
|
2680
2709
|
* permissions must then be resolved against the organisation that actually
|
package/dist/index.js
CHANGED
|
@@ -27401,6 +27401,7 @@ var import_mongodb49 = require("mongodb");
|
|
|
27401
27401
|
async function getUserPermissions(params) {
|
|
27402
27402
|
const user = params.user?.toString();
|
|
27403
27403
|
const org = params.org?.toString();
|
|
27404
|
+
const grantingRole = params.role?.toString();
|
|
27404
27405
|
if (!user || !import_mongodb49.ObjectId.isValid(user)) {
|
|
27405
27406
|
return [];
|
|
27406
27407
|
}
|
|
@@ -27408,19 +27409,23 @@ async function getUserPermissions(params) {
|
|
|
27408
27409
|
if (!db) {
|
|
27409
27410
|
return [];
|
|
27410
27411
|
}
|
|
27411
|
-
|
|
27412
|
-
|
|
27413
|
-
|
|
27414
|
-
|
|
27415
|
-
|
|
27416
|
-
|
|
27417
|
-
|
|
27418
|
-
|
|
27419
|
-
|
|
27420
|
-
|
|
27412
|
+
let roleId = grantingRole;
|
|
27413
|
+
if (!roleId || !import_mongodb49.ObjectId.isValid(roleId)) {
|
|
27414
|
+
const memberQuery = {
|
|
27415
|
+
user: new import_mongodb49.ObjectId(user),
|
|
27416
|
+
status: { $ne: "deleted" }
|
|
27417
|
+
};
|
|
27418
|
+
if (org && import_mongodb49.ObjectId.isValid(org)) {
|
|
27419
|
+
memberQuery.org = new import_mongodb49.ObjectId(org);
|
|
27420
|
+
}
|
|
27421
|
+
const member = await db.collection("members").findOne(memberQuery);
|
|
27422
|
+
if (!member?.role || !import_mongodb49.ObjectId.isValid(member.role)) {
|
|
27423
|
+
return [];
|
|
27424
|
+
}
|
|
27425
|
+
roleId = member.role.toString();
|
|
27421
27426
|
}
|
|
27422
27427
|
const role = await db.collection("roles").findOne({
|
|
27423
|
-
_id: new import_mongodb49.ObjectId(
|
|
27428
|
+
_id: new import_mongodb49.ObjectId(roleId),
|
|
27424
27429
|
status: "active"
|
|
27425
27430
|
});
|
|
27426
27431
|
if (!role || !Array.isArray(role.permissions)) {
|
|
@@ -32349,7 +32354,7 @@ function cameraGrant(params) {
|
|
|
32349
32354
|
if (atOrg)
|
|
32350
32355
|
return atOrg;
|
|
32351
32356
|
const engaged = params.engagedOrgs?.size ? params.memberships.find(
|
|
32352
|
-
(membership) => params.engagedOrgs.has(idOf(membership.org))
|
|
32357
|
+
(membership) => !idOf(membership.siteId) && params.engagedOrgs.has(idOf(membership.org))
|
|
32353
32358
|
) : void 0;
|
|
32354
32359
|
return engaged ?? null;
|
|
32355
32360
|
}
|
|
@@ -33691,7 +33696,8 @@ function useCameraViewService() {
|
|
|
33691
33696
|
async function permissionsForGrant(userId, grant, memberships) {
|
|
33692
33697
|
return getUserPermissions({
|
|
33693
33698
|
user: userId,
|
|
33694
|
-
org: grant?.org ?? memberships[0]?.org
|
|
33699
|
+
org: grant?.org ?? memberships[0]?.org,
|
|
33700
|
+
role: grant?.role
|
|
33695
33701
|
});
|
|
33696
33702
|
}
|
|
33697
33703
|
async function authorizeCamera(params) {
|
|
@@ -34228,7 +34234,7 @@ function useSiteCameraController() {
|
|
|
34228
34234
|
deleteById: _deleteById,
|
|
34229
34235
|
findOne: _findOne
|
|
34230
34236
|
} = useSiteCameraRepo();
|
|
34231
|
-
const { authorizeSite } = useCameraViewService();
|
|
34237
|
+
const { authorizeSite, entitleSite } = useCameraViewService();
|
|
34232
34238
|
function callerId(req) {
|
|
34233
34239
|
const user = req.user;
|
|
34234
34240
|
return user?.user?.toString?.() || user?._id?.toString?.();
|
|
@@ -34300,10 +34306,9 @@ function useSiteCameraController() {
|
|
|
34300
34306
|
}
|
|
34301
34307
|
async function getAll(req, res, next) {
|
|
34302
34308
|
const query2 = req.query;
|
|
34303
|
-
const allowedTypes = ["ip", "exit", "entry", "both"];
|
|
34304
34309
|
const validation = import_joi49.default.object({
|
|
34305
34310
|
type: import_joi49.default.string().optional(),
|
|
34306
|
-
site: import_joi49.default.string().
|
|
34311
|
+
site: import_joi49.default.string().hex().length(24).required(),
|
|
34307
34312
|
page: import_joi49.default.number().min(1).optional(),
|
|
34308
34313
|
limit: import_joi49.default.number().min(10).optional()
|
|
34309
34314
|
});
|
|
@@ -34313,10 +34318,18 @@ function useSiteCameraController() {
|
|
|
34313
34318
|
return;
|
|
34314
34319
|
}
|
|
34315
34320
|
const type = value.type ?? null;
|
|
34316
|
-
const site = value.site
|
|
34321
|
+
const site = value.site;
|
|
34317
34322
|
const page = value.page ?? 1;
|
|
34318
34323
|
const limit = value.limit ? parseInt(value.limit) : 10;
|
|
34319
34324
|
try {
|
|
34325
|
+
try {
|
|
34326
|
+
await entitleSite({ siteId: site, userId: callerId(req) });
|
|
34327
|
+
} catch (error2) {
|
|
34328
|
+
if (error2 instanceof import_node_server_utils95.NotFoundError) {
|
|
34329
|
+
throw new import_node_server_utils95.NotFoundError("Site not found.");
|
|
34330
|
+
}
|
|
34331
|
+
throw error2;
|
|
34332
|
+
}
|
|
34320
34333
|
const siteCameras = await _getAll({ type, site, page, limit });
|
|
34321
34334
|
res.json(siteCameras);
|
|
34322
34335
|
return;
|