@7365admin1/layer-common 4.2.12 → 4.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.2.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 1d22a74: Gate the six HID access-mgmt screens on the access-card-mgmt permissions the API already enforces
8
+
9
+ The access-logs, administrator, hid-cards, hid-readers, hid-users and intercom
10
+ pages carried no permission check, so any member of any organisation could type
11
+ the URL and reach door access, biometric enrolment, physical cards and the
12
+ access log. HidEnabledGate now also requires the caller to hold one of the nine
13
+ access-card-mgmt strings, site-settings:manage-entry-pass, or the wildcard.
14
+
15
+ Those are the same strings /api/access-management/hid already enforces, so the
16
+ set now refused is a strict subset of the set the API refuses today: nobody with
17
+ a working screen loses it.
18
+
19
+ ## 4.2.13
20
+
21
+ ### Patch Changes
22
+
23
+ - 396bae2: Accept every shipped spelling of a permission when deciding a gate.
24
+
25
+ `hasPermission` tested one exact string, so a role holding a minority spelling
26
+ (`work-orders:see-all-work-orders`, `feedbacks:see-all-feedbacks`,
27
+ `visitor-mgmt:see-all-visitor`) was refused while the role editor showed the
28
+ grant. camelCase stays canonical and the role editor is unchanged; nothing
29
+ stored is rewritten. Widening only.
30
+
31
+ The role editor's ticks and its read-only preview are alias-aware too, so a
32
+ grant stored under an alias is shown and can actually be cleared.
33
+
3
34
  ## 4.2.12
4
35
 
5
36
  ### Patch Changes
@@ -580,11 +611,11 @@
580
611
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
581
612
  own rule from two endpoints the console already calls, unprojected:
582
613
 
583
- GET /api/members/user/:user/app/admin the Seven365 staff membership
584
- GET /api/roles/id/:role that membership's role document
614
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
615
+ GET /api/roles/id/:role that membership's role document
585
616
 
586
- owner = member.type === "admin" && role.type === "admin" && role.default === true
587
- staff = member.type === "admin" && role.type === "admin"
617
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
618
+ staff = member.type === "admin" && role.type === "admin"
588
619
 
589
620
  `role.default` is the marker because it is the only property of a platform
590
621
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -17,7 +17,22 @@
17
17
  a coloured slab. Icons are translated centrally — no hand-mapping here.
18
18
  -->
19
19
  <template>
20
- <slot v-if="hidEnabled" />
20
+ <!-- Nothing at all while the role is still resolving. `userAppRole` is null
21
+ on the first tick for EVERYONE including the `*` owner, so drawing the
22
+ denial here would flash "no permission" at the owner on every load. -->
23
+ <template v-if="!isAppRoleResolved" />
24
+
25
+ <slot v-else-if="hidEnabled && canAccessHidScreens" />
26
+
27
+ <div v-else-if="hidEnabled" class="screen-empty hid-gate">
28
+ <span class="hid-gate__icon tone-info">
29
+ <v-icon icon="mdi-lock-outline" size="24" />
30
+ </span>
31
+ <h2 class="hid-gate__title">You do not have permission for HID access management</h2>
32
+ <p class="hid-gate__body">
33
+ Ask an administrator to grant your role access card management for this site.
34
+ </p>
35
+ </div>
21
36
 
22
37
  <div v-else class="screen-empty hid-gate">
23
38
  <span class="hid-gate__icon tone-info">
@@ -45,6 +60,14 @@ const props = withDefaults(
45
60
  const { getSiteById } = useSiteSettings();
46
61
  const { getHidServiceEnabled } = useOptionalServices();
47
62
 
63
+ // The permission half of the same gate. This component is the only thing all
64
+ // six `access-mgmt` pages have in common, so putting the check here governs all
65
+ // six -- and all 66 inherited routes across the eleven apps -- without a page
66
+ // guard per screen. See `composables/useHidAccessPermission.ts` for why the
67
+ // gate cannot remove access that currently works.
68
+ const { canAccessHidScreens } = useHidAccessPermission();
69
+ const { isAppRoleResolved } = useLocalSetup();
70
+
48
71
  // Do not await this request during route setup. Every HID page uses this gate;
49
72
  // awaiting it makes Nuxt Suspense keep rendering the previous page until the
50
73
  // site request finishes, so the URL changes while the visible module appears
@@ -66,8 +66,9 @@
66
66
  <template #prepend>
67
67
  <v-checkbox-btn
68
68
  :model-value="
69
- definedModel.includes(
70
- `${String(resourceKey)}:${String(actionKey)}`
69
+ isActionSelected(
70
+ String(resourceKey),
71
+ String(actionKey)
71
72
  )
72
73
  "
73
74
  :disabled="!edit"
@@ -130,6 +131,10 @@
130
131
  <script setup lang="ts">
131
132
  import useRole from "../composables/useRole";
132
133
  import useUtils from "../composables/useUtils";
134
+ import {
135
+ holdsPermission,
136
+ withoutPermission,
137
+ } from "../utils/permission-spellings";
133
138
 
134
139
  const definedModel = defineModel<Array<string>>({
135
140
  default: () => [],
@@ -185,7 +190,26 @@ function isWebOnly(resource: string) {
185
190
  return props.webOnlyResources.includes(resource);
186
191
  }
187
192
 
193
+ /**
194
+ * Is this box ticked?
195
+ *
196
+ * The same grant is stored under more than one spelling. An exact `includes()`
197
+ * showed a role holding `work-orders:see-all-work-orders` as NOT having
198
+ * `workOrder:see-all-work-orders`, so the dialog reported a grant the role
199
+ * really holds as absent -- and unticking a box then cleared nothing. Ticks are
200
+ * alias-aware, and so is the untick below.
201
+ */
202
+ function isActionSelected(resource: string, action: string) {
203
+ return holdsPermission(definedModel.value, `${resource}:${action}`);
204
+ }
205
+
188
206
  function selectedActionCount(resource: string) {
207
+ const actions = safePermissions.value[resource];
208
+ if (actions) {
209
+ return Object.keys(actions).filter((action) =>
210
+ isActionSelected(resource, action)
211
+ ).length;
212
+ }
189
213
  return definedModel.value.filter((permission) =>
190
214
  permission.startsWith(`${resource}:`)
191
215
  ).length;
@@ -208,20 +232,22 @@ function toggleCategory(resource: string, value: boolean | null) {
208
232
  expandedResources.value = expandedResources.value.filter(
209
233
  (item) => item !== resource
210
234
  );
211
- definedModel.value = definedModel.value.filter(
212
- (permission) => !permission.startsWith(`${resource}:`)
235
+ const actions = Object.keys(safePermissions.value[resource] ?? {});
236
+ definedModel.value = actions.reduce(
237
+ (kept, action) => withoutPermission(kept, `${resource}:${action}`),
238
+ definedModel.value.filter(
239
+ (permission) => !permission.startsWith(`${resource}:`)
240
+ )
213
241
  );
214
242
  }
215
243
 
216
244
  function toggleAction(resource: string, action: string, value: boolean | null) {
217
245
  if (!edit.value || value === null) return;
218
246
  const permission = `${resource}:${action}`;
219
- if (value && !definedModel.value.includes(permission)) {
247
+ if (value && !isActionSelected(resource, action)) {
220
248
  definedModel.value = [...definedModel.value, permission];
221
249
  } else if (!value) {
222
- definedModel.value = definedModel.value.filter(
223
- (item) => item !== permission
224
- );
250
+ definedModel.value = withoutPermission(definedModel.value, permission);
225
251
  }
226
252
  }
227
253
 
@@ -233,7 +259,8 @@ const isAllSelected = computed(() => {
233
259
  Object.keys(actions || {}).map((action) => `${resource}:${action}`)
234
260
  );
235
261
  return (
236
- keys.length > 0 && keys.every((key) => definedModel.value.includes(key))
262
+ keys.length > 0 &&
263
+ keys.every((key) => holdsPermission(definedModel.value, key))
237
264
  );
238
265
  });
239
266
 
@@ -189,6 +189,7 @@
189
189
  <script setup lang="ts">
190
190
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
191
191
  import { showSiteField } from "../utils/role";
192
+ import { holdsPermission } from "../utils/permission-spellings";
192
193
 
193
194
  const props = defineProps({
194
195
  orgId: {
@@ -414,7 +415,12 @@ function filterPermissions(
414
415
  Object.entries(allPermissions).forEach(([resource, actions]) => {
415
416
  if (!actions || typeof actions !== "object") return;
416
417
  const filteredActions = Object.entries(actions)
417
- .filter(([action]) => storedPermissions.includes(`${resource}:${action}`))
418
+ // Alias-aware: the read-only preview must list a grant the role holds
419
+ // under any shipped spelling, or the same string is invisible here and
420
+ // ticked in the editor.
421
+ .filter(([action]) =>
422
+ holdsPermission(storedPermissions, `${resource}:${action}`)
423
+ )
418
424
  .reduce((acc: Record<string, any>, [action, data]) => {
419
425
  acc[action] = data;
420
426
  return acc;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * WHO MAY REACH THE SIX HID `access-mgmt` SCREENS.
3
+ *
4
+ * `pages/[org]/[site]/access-mgmt/{access-logs, administrator, hid-cards,
5
+ * hid-readers, hid-users, intercom}/index.vue` shipped with
6
+ * `middleware: ["01-auth","02-org"], memberOnly: true` and no permission check
7
+ * at all. Every one of the eleven Nuxt apps inherits all six, so that was 66
8
+ * routes on which any member of any organisation could type the URL and reach
9
+ * door access, biometric enrolment, physical cards and the access log. The menu
10
+ * that links five of them (`useHidNavigation`) is gated on
11
+ * `getHidServiceEnabled(site)` -- a SITE SERVICE FLAG, not a permission -- so
12
+ * the menu was never a boundary either.
13
+ *
14
+ * ## Why adding this gate cannot lock anybody out
15
+ *
16
+ * Every data call these screens make goes through `useHidAmico` to
17
+ * `/api/access-management/hid`, and every route there carries `requireAuth`
18
+ * plus `authorizeView` or `authorizeManage`. Those resolve through
19
+ * `authorizeHidAccess` (`core/services/hid-amico.service.ts`) to the lists in
20
+ * `core/utils/hid-access.util.ts` -- the nine `access-card-mgmt` strings below.
21
+ *
22
+ * WARNING: there are TWO different `HID_MANAGE_PERMISSIONS` in `core` and they
23
+ * are NOT the same list. `hid-amico-authz.util.ts` exports
24
+ * `["site-settings:manage-entry-pass"]`; `hid-access.util.ts` exports the
25
+ * `access-card-mgmt` family. `hid-amico.service.ts` imports the one from
26
+ * `hid-access.util.ts` -- and `authorizeSiteForCaller`'s own doc comment is
27
+ * stale and still claims the other. Follow the import, not the name.
28
+ *
29
+ * So a member holding none of these strings already gets a 401 on every list
30
+ * call on all six screens: the screens are non-functional for them today, and
31
+ * they see chrome plus an error toast. The set this gate refuses is a strict
32
+ * SUBSET of the set the server already refuses in production, which is a
33
+ * stronger guarantee than the usual widen-never-swap argument because it is
34
+ * anchored on live server behaviour rather than on a role census.
35
+ *
36
+ * `site-settings:manage-entry-pass` is unioned in -- it is what
37
+ * `hid-amico-authz.util.ts` enforces on the entry-pass paths and the string the
38
+ * HID menu has always been associated with -- and `*` keeps its short-circuit.
39
+ *
40
+ * ## Intercom is gated with the other five
41
+ *
42
+ * `intercom` was the one screen that might have lived entirely on
43
+ * `authorizeMember` endpoints (`/intercom/status`, `/intercom/call`,
44
+ * `/intercom/hangup`). It does not. `HidIntercomManagement.reload()` opens with
45
+ * `getReaders()` (`GET /readers`, `authorizeView`) and filters its rows out of
46
+ * that response; the contacts tab, the dial-code dialog, the make-call dialog
47
+ * and the web phone are all reached from a row, and `loadSipAccount()` is only
48
+ * ever called from one of those dialogs. Without `authorizeView` the screen is
49
+ * an empty table and an error, so gating it removes nothing that works.
50
+ *
51
+ * ## Drawing, not securing
52
+ *
53
+ * The server re-decides every HID call on its own. This hides screens that do
54
+ * not work; it does not add protection the API does not already have.
55
+ */
56
+
57
+ /** The nine strings `core/utils/hid-access.util.ts` enforces, view + manage. */
58
+ const HID_ACTIONS = [
59
+ "see-all-card",
60
+ "see-all-qr-tagging",
61
+ "view",
62
+ "add-access-card",
63
+ "update-access-card",
64
+ "replace-access-card",
65
+ "delete-access-card",
66
+ "assign-access-card",
67
+ "manage-hid",
68
+ ] as const;
69
+
70
+ export function useHidAccessPermission() {
71
+ const { hasPermission } = usePermission();
72
+ const { siteSettingsPermissions } = useCommonPermissions();
73
+ const { userAppRole } = useLocalSetup();
74
+
75
+ // `hasPermission` needs BOTH the role's string AND a catalogue entry for it:
76
+ // a resource missing from the catalogue is a dead gate however the role is
77
+ // granted. `access-card-mgmt` exists in no shared catalogue composable, so it
78
+ // is built here, exactly as `useServiceProviderPermission` builds its own.
79
+ const permissions: TPermissions = {
80
+ "access-card-mgmt": Object.fromEntries(
81
+ HID_ACTIONS.map((action) => [
82
+ action,
83
+ {
84
+ check: true,
85
+ description: `Allows the user to reach the HID access management screens (${action}).`,
86
+ },
87
+ ]),
88
+ ),
89
+ "site-settings": siteSettingsPermissions,
90
+ };
91
+
92
+ /**
93
+ * Can this member open a HID `access-mgmt` screen at all? Any single string
94
+ * the server accepts is enough -- somebody granted only `delete-access-card`
95
+ * still has to open the screen to use it.
96
+ */
97
+ const canAccessHidScreens = computed(() => {
98
+ const role = userAppRole.value;
99
+ if (!role) return false;
100
+ if (role.permissions.includes("*")) return true;
101
+ if (hasPermission(role, permissions, "site-settings", "manage-entry-pass")) return true;
102
+ return HID_ACTIONS.some((action) =>
103
+ hasPermission(role, permissions, "access-card-mgmt", action),
104
+ );
105
+ });
106
+
107
+ // Only the gate is returned. `isAppRoleResolved` is deliberately NOT
108
+ // re-exported: `gate-defaults.test.mjs` sweeps every ref a permission
109
+ // composable returns and requires it to read false for an unresolved role,
110
+ // which a "has the lookup finished" flag is not. Callers that need it take it
111
+ // from `useLocalSetup()`, which is where it lives.
112
+ return { canAccessHidScreens };
113
+ }
114
+
115
+ export default useHidAccessPermission;
@@ -1,3 +1,5 @@
1
+ import { holdsPermission } from "../utils/permission-spellings";
2
+
1
3
  export default function usePermission() {
2
4
  // Permission-Based Access Control with Dynamic Role Creation
3
5
 
@@ -25,8 +27,16 @@ export default function usePermission() {
25
27
  ): boolean {
26
28
  const permissionKey = `${resource}:${action}`;
27
29
 
28
- // Check if the permission exists in the user's permission array
29
- if (!user.permissions?.includes(permissionKey)) {
30
+ // Check if the permission exists in the user's permission array.
31
+ //
32
+ // The same grant is stored under more than one spelling -- the role editor
33
+ // writes `workOrder:see-all-work-orders` (81 roles) but a minority of roles
34
+ // hold `work-orders:see-all-work-orders` (6). An exact test answered "no"
35
+ // for those roles while the console showed the grant. `holdsPermission`
36
+ // accepts any shipped spelling of the same grant; camelCase stays canonical
37
+ // and nothing stored is rewritten. Widening only -- no role that passed
38
+ // before can fail now.
39
+ if (!holdsPermission(user.permissions, permissionKey)) {
30
40
  return false;
31
41
  }
32
42
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.2.12",
5
+ "version": "4.2.14",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,87 @@
1
+ import {
2
+ LEGACY_PERMISSION_ALIASES,
3
+ PERMISSION_STRINGS,
4
+ } from "../constants/permissions.ts";
5
+
6
+ /**
7
+ * The same grant is stored under more than one spelling.
8
+ *
9
+ * `constants/permissions.ts` already records every shipped spelling as
10
+ * `legacy -> canonical`. That map was written for the drift test; this file is
11
+ * the ENFORCEMENT side of it. It turns the one-way map into equivalence
12
+ * classes, so a check for one spelling is answered by any spelling of the same
13
+ * grant.
14
+ *
15
+ * Which spelling is CANONICAL is settled and unchanged by this file: the role
16
+ * editor keeps writing what it has always written (`workOrder:…`,
17
+ * `feedbacks:see-all-feedback`, `visitorManagement:…`), which is the majority
18
+ * spelling in live role data by an order of magnitude (81 roles vs 6, 95 vs 8).
19
+ * Nothing stored is rewritten. This is a WIDENING only: a role that passed a
20
+ * gate before still passes it, and a role holding a minority spelling stops
21
+ * being silently unenforced.
22
+ */
23
+ /**
24
+ * Spellings that are recorded in the alias map but must NOT be accepted.
25
+ *
26
+ * `service-provider:*` (no `-mgmt`) is the 4.0.3 regression: 0 of 428 roles
27
+ * hold it, and `test/service-provider-gate.test.mjs` pins it shut so nobody
28
+ * reintroduces a gate that reads a string which can never arrive. Accepting it
29
+ * would grant nobody anything and would quietly remove that tripwire. The map
30
+ * keeps the entry because the drift test needs to know the string exists.
31
+ */
32
+ const NOT_ACCEPTED: ReadonlySet<string> = new Set([
33
+ "service-provider:see-all-service-providers",
34
+ "service-provider:add-service-provider",
35
+ "service-provider:invite-service-provider",
36
+ ]);
37
+
38
+ const SPELLINGS: ReadonlyMap<string, readonly string[]> = (() => {
39
+ const classes = new Map<string, Set<string>>();
40
+ const join = (canonical: string, spelling: string) => {
41
+ const members = classes.get(canonical) ?? new Set<string>([canonical]);
42
+ members.add(spelling);
43
+ classes.set(canonical, members);
44
+ };
45
+
46
+ for (const canonical of PERMISSION_STRINGS) join(canonical, canonical);
47
+ for (const [legacy, canonical] of Object.entries(LEGACY_PERMISSION_ALIASES)) {
48
+ if (NOT_ACCEPTED.has(legacy)) continue;
49
+ join(canonical, legacy);
50
+ }
51
+
52
+ const bySpelling = new Map<string, readonly string[]>();
53
+ for (const members of classes.values()) {
54
+ const all = Object.freeze([...members]);
55
+ for (const spelling of all) bySpelling.set(spelling, all);
56
+ }
57
+ return bySpelling;
58
+ })();
59
+
60
+ /** Every shipped spelling of the same grant, `value` included. */
61
+ export function permissionSpellings(value: string): readonly string[] {
62
+ return SPELLINGS.get(value) ?? [value];
63
+ }
64
+
65
+ /** Does this stored permission list carry `value` under ANY shipped spelling? */
66
+ export function holdsPermission(
67
+ held: readonly string[] | undefined | null,
68
+ value: string,
69
+ ): boolean {
70
+ if (!held?.length) return false;
71
+ return permissionSpellings(value).some((spelling) => held.includes(spelling));
72
+ }
73
+
74
+ /**
75
+ * Drop `value` under EVERY spelling.
76
+ *
77
+ * The counterpart to `holdsPermission`. A tick that is alias-aware must have an
78
+ * untick that is too, or clearing the box leaves the grant in place and the
79
+ * screen lies in the other direction.
80
+ */
81
+ export function withoutPermission(
82
+ held: readonly string[],
83
+ value: string,
84
+ ): string[] {
85
+ const spellings = permissionSpellings(value);
86
+ return held.filter((item) => !spellings.includes(item));
87
+ }