@7365admin1/core 3.32.2-staging.110 → 3.32.2-staging.111
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/hid-card-pin-authorization.md +35 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +150 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +244 -91
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/hid-authz.test.mjs +380 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
HID Amico: authorise the card, PIN, discovery and visitor-QR endpoints
|
|
6
|
+
|
|
7
|
+
Ten HID Amico handlers required a session and nothing else. The reader id in
|
|
8
|
+
the URL was the only thing selecting the device, and `GET /readers` returns
|
|
9
|
+
every reader in the estate when no `site` is supplied, so any signed-in account
|
|
10
|
+
could act on any reader at any site of any organisation — assign or delete a
|
|
11
|
+
physical access card, set or clear a keypad PIN, put a reader into card
|
|
12
|
+
enrolment, list a user's stored card values, or revoke a visitor's QR pass.
|
|
13
|
+
|
|
14
|
+
Each of the ten now authorises before it does anything:
|
|
15
|
+
|
|
16
|
+
- `listUserCards`, `assignUserCard`, `enrollUserCard`, `cancelUserCardEnrollment`,
|
|
17
|
+
`deleteUserCard` and `revokeVisitorQr` require `site-settings:manage-entry-pass`
|
|
18
|
+
at the site of the **stored** reader.
|
|
19
|
+
- `getUserPinStatus`, `setUserPin` and `deleteUserPin` allow either that same
|
|
20
|
+
administrator, or the person whose own device user it is — entitlement to the
|
|
21
|
+
reader's site plus an ownership check on `hidUserId`, so the resident and MA
|
|
22
|
+
mobile apps' "Access PIN" screen keeps working while a neighbour's PIN cannot
|
|
23
|
+
be rewritten.
|
|
24
|
+
- `discoverReader` now takes the `site` the reader is being added to and
|
|
25
|
+
authorises it, and the address it may be pointed at is constrained: an
|
|
26
|
+
`http(s)` origin only, no credentials, no path, and never the API host itself
|
|
27
|
+
or the instance-metadata range. `HID_DISCOVERY_ALLOWED_HOSTS` optionally pins
|
|
28
|
+
it to named hosts; unset, nothing changes operationally.
|
|
29
|
+
|
|
30
|
+
A reader that is not yours answers exactly like a reader that does not exist, so
|
|
31
|
+
reader ids cannot be enumerated by watching the difference.
|
|
32
|
+
|
|
33
|
+
**Consumer change required:** `POST /access-management/hid/readers/discover` now
|
|
34
|
+
requires `site` in the body. `HidReaderForm.vue` in `@7365admin1/layer-common` is
|
|
35
|
+
the only caller and must be updated to send it.
|
package/dist/index.d.ts
CHANGED
|
@@ -3630,6 +3630,13 @@ declare function useCameraViewService(): {
|
|
|
3630
3630
|
* different, narrower right and passes its own list.
|
|
3631
3631
|
*/
|
|
3632
3632
|
permissions?: Array<string>;
|
|
3633
|
+
/**
|
|
3634
|
+
* What to say when the permission is missing. The rule this function
|
|
3635
|
+
* applies — membership of the site, then the granting role's permissions —
|
|
3636
|
+
* is not camera-specific, and the HID module now asks the same question of
|
|
3637
|
+
* the same data; only the sentence a refused caller reads differs.
|
|
3638
|
+
*/
|
|
3639
|
+
message?: string;
|
|
3633
3640
|
}) => Promise<{
|
|
3634
3641
|
site: mongodb.WithId<bson.Document> | null;
|
|
3635
3642
|
siteObjectId: ObjectId;
|
|
@@ -9554,6 +9561,13 @@ declare const schemaHidAmicoLogQuery: Joi.ObjectSchema<any>;
|
|
|
9554
9561
|
declare const schemaHidAmicoIdentityQuery: Joi.ObjectSchema<any>;
|
|
9555
9562
|
declare const schemaHidAmicoSync: Joi.ObjectSchema<any>;
|
|
9556
9563
|
declare const schemaHidAmicoVisitorQr: Joi.ObjectSchema<any>;
|
|
9564
|
+
/**
|
|
9565
|
+
* Discovery is the only HID call that names no reader, so before this there was
|
|
9566
|
+
* nothing on the request to authorise against. `site` is the site the reader is
|
|
9567
|
+
* being added to — the same field `schemaHidAmicoReader` already requires when
|
|
9568
|
+
* the reader is actually created — and it is what the caller's entitlement is
|
|
9569
|
+
* checked against.
|
|
9570
|
+
*/
|
|
9557
9571
|
declare const schemaDiscoverHidAmicoReader: Joi.ObjectSchema<any>;
|
|
9558
9572
|
declare const schemaHidAmicoVisitorImageParams: Joi.ObjectSchema<any>;
|
|
9559
9573
|
declare const schemaHidAmicoUserPinParams: Joi.ObjectSchema<any>;
|
|
@@ -9818,6 +9832,15 @@ type HidObjectMutation = {
|
|
|
9818
9832
|
where?: UnknownRecord;
|
|
9819
9833
|
};
|
|
9820
9834
|
declare function useHidAmicoService(): {
|
|
9835
|
+
authorizeReader: (readerId: string, userId: string, options?: {
|
|
9836
|
+
includePassword?: boolean;
|
|
9837
|
+
}) => Promise<THidAmicoReader>;
|
|
9838
|
+
authorizeReaderUser: (readerId: string, hidUserIdValue: string | number, userId: string) => Promise<THidAmicoReader>;
|
|
9839
|
+
authorizeSiteForCaller: (siteId: string, userId: string) => Promise<{
|
|
9840
|
+
site: mongodb.WithId<bson.Document> | null;
|
|
9841
|
+
siteObjectId: bson.ObjectId;
|
|
9842
|
+
db: mongodb.Db;
|
|
9843
|
+
}>;
|
|
9821
9844
|
listReaders: (value: {
|
|
9822
9845
|
site?: string;
|
|
9823
9846
|
page?: number;
|
package/dist/index.js
CHANGED
|
@@ -37328,7 +37328,7 @@ function useCameraViewService() {
|
|
|
37328
37328
|
const required = params.permissions ?? CAMERA_VIEW_PERMISSIONS;
|
|
37329
37329
|
if (!hasAnyPermission(permissions, required)) {
|
|
37330
37330
|
throw new import_node_server_utils107.UnauthorizedError(
|
|
37331
|
-
required === CAMERA_VIEW_PERMISSIONS ? "You do not have permission to view these cameras." : "You do not have permission to set up cameras on this site."
|
|
37331
|
+
params.message ?? (required === CAMERA_VIEW_PERMISSIONS ? "You do not have permission to view these cameras." : "You do not have permission to set up cameras on this site.")
|
|
37332
37332
|
);
|
|
37333
37333
|
}
|
|
37334
37334
|
return { site, siteObjectId, db };
|
|
@@ -41655,6 +41655,7 @@ var schemaHidAmicoVisitorQr = import_joi63.default.object({
|
|
|
41655
41655
|
qrFormat: import_joi63.default.string().valid("0", "1", "2").optional()
|
|
41656
41656
|
});
|
|
41657
41657
|
var schemaDiscoverHidAmicoReader = import_joi63.default.object({
|
|
41658
|
+
site: objectIdSchema2.required(),
|
|
41658
41659
|
baseUrl: import_joi63.default.string().uri({ scheme: ["http", "https"] }).required(),
|
|
41659
41660
|
username: import_joi63.default.string().trim().required(),
|
|
41660
41661
|
password: import_joi63.default.string().required()
|
|
@@ -42546,6 +42547,82 @@ function useHidAmicoRepo() {
|
|
|
42546
42547
|
};
|
|
42547
42548
|
}
|
|
42548
42549
|
|
|
42550
|
+
// src/utils/hid-amico-authz.util.ts
|
|
42551
|
+
var HID_MANAGE_PERMISSIONS = ["site-settings:manage-entry-pass"];
|
|
42552
|
+
var HID_DISCOVERY_ALLOWED_SCHEMES = ["http:", "https:"];
|
|
42553
|
+
function isBlockedDiscoveryHost(hostname) {
|
|
42554
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
42555
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
42556
|
+
return true;
|
|
42557
|
+
if (host.includes(":")) {
|
|
42558
|
+
if (host === "::" || host === "::1")
|
|
42559
|
+
return true;
|
|
42560
|
+
if (/^fe[89ab]/.test(host) || /^fe[c-f]/.test(host))
|
|
42561
|
+
return true;
|
|
42562
|
+
const mapped = host.match(/^::ffff:([0-9a-f:.]+)$/);
|
|
42563
|
+
if (mapped) {
|
|
42564
|
+
const value = mapped[1];
|
|
42565
|
+
if (value.includes("."))
|
|
42566
|
+
return isBlockedDiscoveryHost(value);
|
|
42567
|
+
const [high, low] = value.split(":");
|
|
42568
|
+
const octets2 = [
|
|
42569
|
+
parseInt(high.padStart(4, "0").slice(0, 2), 16),
|
|
42570
|
+
parseInt(high.padStart(4, "0").slice(2), 16),
|
|
42571
|
+
parseInt((low ?? "0").padStart(4, "0").slice(0, 2), 16),
|
|
42572
|
+
parseInt((low ?? "0").padStart(4, "0").slice(2), 16)
|
|
42573
|
+
];
|
|
42574
|
+
if (octets2.every((octet) => Number.isInteger(octet))) {
|
|
42575
|
+
return isBlockedDiscoveryHost(octets2.join("."));
|
|
42576
|
+
}
|
|
42577
|
+
}
|
|
42578
|
+
return false;
|
|
42579
|
+
}
|
|
42580
|
+
const octets = host.split(".");
|
|
42581
|
+
if (octets.length !== 4 || !octets.every((octet) => /^\d{1,3}$/.test(octet))) {
|
|
42582
|
+
return false;
|
|
42583
|
+
}
|
|
42584
|
+
const [first, second] = octets.map(Number);
|
|
42585
|
+
if (first === 0)
|
|
42586
|
+
return true;
|
|
42587
|
+
if (first === 127)
|
|
42588
|
+
return true;
|
|
42589
|
+
if (first === 169 && second === 254)
|
|
42590
|
+
return true;
|
|
42591
|
+
if (first >= 224)
|
|
42592
|
+
return true;
|
|
42593
|
+
return false;
|
|
42594
|
+
}
|
|
42595
|
+
function discoveryAllowedHosts(raw = process.env.HID_DISCOVERY_ALLOWED_HOSTS) {
|
|
42596
|
+
return String(raw || "").split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
42597
|
+
}
|
|
42598
|
+
function discoveryUrlRefusalReason(baseUrl, allowedHosts = discoveryAllowedHosts()) {
|
|
42599
|
+
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
|
|
42600
|
+
return "A reader address is required.";
|
|
42601
|
+
}
|
|
42602
|
+
let url;
|
|
42603
|
+
try {
|
|
42604
|
+
url = new URL(baseUrl.trim());
|
|
42605
|
+
} catch {
|
|
42606
|
+
return "The reader address is not a valid URL.";
|
|
42607
|
+
}
|
|
42608
|
+
if (!HID_DISCOVERY_ALLOWED_SCHEMES.includes(url.protocol)) {
|
|
42609
|
+
return "The reader address must be an http or https URL.";
|
|
42610
|
+
}
|
|
42611
|
+
if (url.username || url.password) {
|
|
42612
|
+
return "The reader address must not carry credentials.";
|
|
42613
|
+
}
|
|
42614
|
+
if (url.pathname && url.pathname !== "/" || url.search || url.hash) {
|
|
42615
|
+
return "The reader address must be a host and optional port only.";
|
|
42616
|
+
}
|
|
42617
|
+
if (isBlockedDiscoveryHost(url.hostname)) {
|
|
42618
|
+
return "The reader address is not a permitted reader location.";
|
|
42619
|
+
}
|
|
42620
|
+
if (allowedHosts.length && !allowedHosts.includes(url.hostname.toLowerCase())) {
|
|
42621
|
+
return "The reader address is not a permitted reader location.";
|
|
42622
|
+
}
|
|
42623
|
+
return null;
|
|
42624
|
+
}
|
|
42625
|
+
|
|
42549
42626
|
// src/utils/hid-card.util.ts
|
|
42550
42627
|
var HID_CARD_VALUE_FACTOR = 2 ** 32;
|
|
42551
42628
|
function requireSafeNonNegativeInteger(value, label) {
|
|
@@ -42590,6 +42667,7 @@ function resolveHidPhysicalCardValue(input) {
|
|
|
42590
42667
|
// src/services/hid-amico.service.ts
|
|
42591
42668
|
var PASSWORD_PREFIX = "v1";
|
|
42592
42669
|
var remoteCardEnrollmentReaders = /* @__PURE__ */ new Set();
|
|
42670
|
+
var HID_READER_NOT_FOUND = "HID Amico reader not found.";
|
|
42593
42671
|
var PJSIP_CONFIGURATION_KEYS = [
|
|
42594
42672
|
"enabled",
|
|
42595
42673
|
"server_ip",
|
|
@@ -43635,6 +43713,58 @@ function useHidAmicoService() {
|
|
|
43635
43713
|
const visitorRepo = useVisitorTransactionRepo();
|
|
43636
43714
|
const memberRepo = useMemberRepo();
|
|
43637
43715
|
const userRepo = useUserRepo();
|
|
43716
|
+
async function authorizeSiteForCaller(siteId, userId) {
|
|
43717
|
+
return useCameraViewService().authorizeSite({
|
|
43718
|
+
siteId,
|
|
43719
|
+
userId,
|
|
43720
|
+
permissions: HID_MANAGE_PERMISSIONS,
|
|
43721
|
+
message: "You do not have permission to manage HID readers on this site."
|
|
43722
|
+
});
|
|
43723
|
+
}
|
|
43724
|
+
async function authorizeReader(readerId, userId, options = {}) {
|
|
43725
|
+
let reader;
|
|
43726
|
+
try {
|
|
43727
|
+
reader = await repo.getById(readerId, options);
|
|
43728
|
+
} catch {
|
|
43729
|
+
throw new import_node_server_utils126.NotFoundError(HID_READER_NOT_FOUND);
|
|
43730
|
+
}
|
|
43731
|
+
try {
|
|
43732
|
+
await authorizeSiteForCaller(String(reader.site || ""), userId);
|
|
43733
|
+
} catch (error) {
|
|
43734
|
+
if (error instanceof import_node_server_utils126.UnauthorizedError)
|
|
43735
|
+
throw error;
|
|
43736
|
+
throw new import_node_server_utils126.NotFoundError(HID_READER_NOT_FOUND);
|
|
43737
|
+
}
|
|
43738
|
+
return reader;
|
|
43739
|
+
}
|
|
43740
|
+
async function entitleReaderForSelf(readerId, userId) {
|
|
43741
|
+
let reader;
|
|
43742
|
+
try {
|
|
43743
|
+
reader = await repo.getById(readerId);
|
|
43744
|
+
} catch {
|
|
43745
|
+
throw new import_node_server_utils126.NotFoundError(HID_READER_NOT_FOUND);
|
|
43746
|
+
}
|
|
43747
|
+
try {
|
|
43748
|
+
await useCameraViewService().entitleSite({
|
|
43749
|
+
siteId: String(reader.site || ""),
|
|
43750
|
+
userId
|
|
43751
|
+
});
|
|
43752
|
+
} catch {
|
|
43753
|
+
throw new import_node_server_utils126.NotFoundError(HID_READER_NOT_FOUND);
|
|
43754
|
+
}
|
|
43755
|
+
return reader;
|
|
43756
|
+
}
|
|
43757
|
+
async function callersOwnHidUserId(readerId, userId) {
|
|
43758
|
+
const identity = await repo.findProfileIdentity(readerId, userId);
|
|
43759
|
+
return identity?.hidUserId ? Number(identity.hidUserId) : createStableHidId(`profile:${readerId}:${userId}`);
|
|
43760
|
+
}
|
|
43761
|
+
async function authorizeReaderUser(readerId, hidUserIdValue, userId) {
|
|
43762
|
+
const reader = await entitleReaderForSelf(readerId, userId);
|
|
43763
|
+
const own = await callersOwnHidUserId(readerId, userId);
|
|
43764
|
+
if (Number(hidUserIdValue) === own)
|
|
43765
|
+
return reader;
|
|
43766
|
+
return authorizeReader(readerId, userId);
|
|
43767
|
+
}
|
|
43638
43768
|
async function getActiveReader(id) {
|
|
43639
43769
|
const reader = await repo.getById(id, { includePassword: true });
|
|
43640
43770
|
if (reader.enabled === false || reader.status === "inactive" || reader.status === "deleted") {
|
|
@@ -43769,6 +43899,9 @@ function useHidAmicoService() {
|
|
|
43769
43899
|
};
|
|
43770
43900
|
}
|
|
43771
43901
|
async function discoverReader(value) {
|
|
43902
|
+
const refusal = discoveryUrlRefusalReason(value.baseUrl);
|
|
43903
|
+
if (refusal)
|
|
43904
|
+
throw new import_node_server_utils126.BadRequestError(refusal);
|
|
43772
43905
|
const client = new HidAmicoClient({
|
|
43773
43906
|
...value,
|
|
43774
43907
|
baseUrl: value.baseUrl.replace(/\/+$/, "")
|
|
@@ -45323,6 +45456,9 @@ function useHidAmicoService() {
|
|
|
45323
45456
|
return sipAccountResponse(account);
|
|
45324
45457
|
}
|
|
45325
45458
|
return {
|
|
45459
|
+
authorizeReader,
|
|
45460
|
+
authorizeReaderUser,
|
|
45461
|
+
authorizeSiteForCaller,
|
|
45326
45462
|
listReaders,
|
|
45327
45463
|
discoverReader,
|
|
45328
45464
|
createReader,
|
|
@@ -81257,6 +81393,9 @@ function getAuthenticatedUserId(req) {
|
|
|
81257
81393
|
}
|
|
81258
81394
|
function useHidAmicoController() {
|
|
81259
81395
|
const service = useHidAmicoService();
|
|
81396
|
+
const authorizeReader = (req, readerId) => service.authorizeReader(readerId, getAuthenticatedUserId(req));
|
|
81397
|
+
const authorizeReaderUser = (req, readerId, hidUserId) => service.authorizeReaderUser(readerId, hidUserId, getAuthenticatedUserId(req));
|
|
81398
|
+
const authorizeSite = (req, siteId) => service.authorizeSiteForCaller(siteId, getAuthenticatedUserId(req));
|
|
81260
81399
|
async function listReaders(req, res, next) {
|
|
81261
81400
|
const { error, value } = schemaHidAmicoReaderListQuery.validate(req.query);
|
|
81262
81401
|
if (error) {
|
|
@@ -81575,6 +81714,7 @@ function useHidAmicoController() {
|
|
|
81575
81714
|
return;
|
|
81576
81715
|
}
|
|
81577
81716
|
try {
|
|
81717
|
+
await authorizeSite(req, value.site);
|
|
81578
81718
|
res.json({ data: await service.discoverReader(value) });
|
|
81579
81719
|
} catch (error2) {
|
|
81580
81720
|
next(error2);
|
|
@@ -81587,6 +81727,7 @@ function useHidAmicoController() {
|
|
|
81587
81727
|
return;
|
|
81588
81728
|
}
|
|
81589
81729
|
try {
|
|
81730
|
+
await authorizeReaderUser(req, value.readerId, value.hidUserId);
|
|
81590
81731
|
res.json({ data: await service.getUserPinStatus(value.readerId, value.hidUserId) });
|
|
81591
81732
|
} catch (error2) {
|
|
81592
81733
|
next(error2);
|
|
@@ -81599,6 +81740,7 @@ function useHidAmicoController() {
|
|
|
81599
81740
|
return;
|
|
81600
81741
|
}
|
|
81601
81742
|
try {
|
|
81743
|
+
await authorizeReader(req, value.readerId);
|
|
81602
81744
|
res.json({ data: await service.listUserCards(value.readerId, value.hidUserId) });
|
|
81603
81745
|
} catch (requestError) {
|
|
81604
81746
|
next(requestError);
|
|
@@ -81612,6 +81754,7 @@ function useHidAmicoController() {
|
|
|
81612
81754
|
return;
|
|
81613
81755
|
}
|
|
81614
81756
|
try {
|
|
81757
|
+
await authorizeReader(req, params.value.readerId);
|
|
81615
81758
|
res.status(201).json({
|
|
81616
81759
|
data: await service.assignUserCard(
|
|
81617
81760
|
params.value.readerId,
|
|
@@ -81631,6 +81774,7 @@ function useHidAmicoController() {
|
|
|
81631
81774
|
return;
|
|
81632
81775
|
}
|
|
81633
81776
|
try {
|
|
81777
|
+
await authorizeReader(req, params.value.readerId);
|
|
81634
81778
|
res.status(201).json({
|
|
81635
81779
|
data: await service.enrollUserCard(
|
|
81636
81780
|
params.value.readerId,
|
|
@@ -81650,6 +81794,7 @@ function useHidAmicoController() {
|
|
|
81650
81794
|
return;
|
|
81651
81795
|
}
|
|
81652
81796
|
try {
|
|
81797
|
+
await authorizeReader(req, value.readerId);
|
|
81653
81798
|
res.json({ data: await service.cancelUserCardEnrollment(value.readerId) });
|
|
81654
81799
|
} catch (requestError) {
|
|
81655
81800
|
next(requestError);
|
|
@@ -81662,6 +81807,7 @@ function useHidAmicoController() {
|
|
|
81662
81807
|
return;
|
|
81663
81808
|
}
|
|
81664
81809
|
try {
|
|
81810
|
+
await authorizeReader(req, value.readerId);
|
|
81665
81811
|
res.json({
|
|
81666
81812
|
data: await service.deleteUserCard(
|
|
81667
81813
|
value.readerId,
|
|
@@ -81681,6 +81827,7 @@ function useHidAmicoController() {
|
|
|
81681
81827
|
return;
|
|
81682
81828
|
}
|
|
81683
81829
|
try {
|
|
81830
|
+
await authorizeReaderUser(req, params.value.readerId, params.value.hidUserId);
|
|
81684
81831
|
res.json({
|
|
81685
81832
|
data: await service.setUserPin(
|
|
81686
81833
|
params.value.readerId,
|
|
@@ -81699,6 +81846,7 @@ function useHidAmicoController() {
|
|
|
81699
81846
|
return;
|
|
81700
81847
|
}
|
|
81701
81848
|
try {
|
|
81849
|
+
await authorizeReaderUser(req, value.readerId, value.hidUserId);
|
|
81702
81850
|
res.json({ data: await service.deleteUserPin(value.readerId, value.hidUserId) });
|
|
81703
81851
|
} catch (error2) {
|
|
81704
81852
|
next(error2);
|
|
@@ -81711,6 +81859,7 @@ function useHidAmicoController() {
|
|
|
81711
81859
|
return;
|
|
81712
81860
|
}
|
|
81713
81861
|
try {
|
|
81862
|
+
await authorizeReader(req, value.readerId);
|
|
81714
81863
|
res.json({
|
|
81715
81864
|
data: await service.revokeVisitorQr(value.readerId, value.visitorId)
|
|
81716
81865
|
});
|