@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.
@@ -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. This is how the
2673
- * product routes a security agency to a property manager's site, and it is
2674
- * the list the web apps' own site switcher is built from
2675
- * (`useCustomerSite().getAll()` in the Security app's layout). Nothing in the
2676
- * camera path read it before, so a guard whose agency is engaged at a site
2677
- * was refused a camera the switcher had just offered them.
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
- const memberQuery = {
27412
- user: new import_mongodb49.ObjectId(user),
27413
- status: { $ne: "deleted" }
27414
- };
27415
- if (org && import_mongodb49.ObjectId.isValid(org)) {
27416
- memberQuery.org = new import_mongodb49.ObjectId(org);
27417
- }
27418
- const member = await db.collection("members").findOne(memberQuery);
27419
- if (!member?.role || !import_mongodb49.ObjectId.isValid(member.role)) {
27420
- return [];
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(member.role),
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().optional(),
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;