@coffeerunhobby/mcp-ha-connect 1.3.0 → 1.3.1
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/dist/permissions/index.js +20 -5
- package/dist/permissions/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +8 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/permissions/index.ts +22 -5
- package/src/version.ts +1 -1
- package/tests/permissions/index.test.ts +21 -0
|
@@ -19,24 +19,39 @@ export const Role = {
|
|
|
19
19
|
ADMIN: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY | Permission.CONFIGURE | Permission.AI | Permission.ADMIN,
|
|
20
20
|
SUPERUSER: 0xFF, // All permissions
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a role name to its permission mask, case-insensitively.
|
|
24
|
+
*
|
|
25
|
+
* Config (and JSON) commonly use lowercase role names ("operator") while the
|
|
26
|
+
* `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
|
|
27
|
+
* role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
|
|
28
|
+
* name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
|
|
29
|
+
* a zero mask anyway but is easy to mishandle).
|
|
30
|
+
*/
|
|
31
|
+
function resolveRoleName(name) {
|
|
32
|
+
if (!name) {
|
|
33
|
+
return Role.NONE;
|
|
34
|
+
}
|
|
35
|
+
const key = name.toUpperCase();
|
|
36
|
+
return Role[key] ?? Role.NONE;
|
|
37
|
+
}
|
|
22
38
|
/**
|
|
23
39
|
* Get permission mask for a user by their JWT sub claim
|
|
24
40
|
*/
|
|
25
41
|
export function getUserPermissions(sub, config) {
|
|
42
|
+
const fallback = resolveRoleName(config.defaultRole);
|
|
26
43
|
if (!sub) {
|
|
27
|
-
return
|
|
44
|
+
return fallback;
|
|
28
45
|
}
|
|
29
46
|
const user = config.users.find((u) => u.sub === sub);
|
|
30
47
|
if (!user) {
|
|
31
|
-
return
|
|
48
|
+
return fallback;
|
|
32
49
|
}
|
|
33
50
|
// Direct mask takes precedence over role
|
|
34
51
|
if (user.mask !== undefined) {
|
|
35
52
|
return user.mask;
|
|
36
53
|
}
|
|
37
|
-
|
|
38
|
-
const roleKey = user.role?.toUpperCase();
|
|
39
|
-
return roleKey && Role[roleKey] !== undefined ? Role[roleKey] : Role.NONE;
|
|
54
|
+
return resolveRoleName(user.role);
|
|
40
55
|
}
|
|
41
56
|
/**
|
|
42
57
|
* Check if a permission mask includes required permission
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,mFAAmF;AACnF,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,KAAK,EAAE,CAAC,EAAO,6DAA6D;IAC5E,SAAS,EAAE,CAAC,EAAG,qCAAqC;IACpD,OAAO,EAAE,CAAC,EAAK,0CAA0C;IACzD,KAAK,EAAE,CAAC,EAAO,qCAAqC;IACpD,MAAM,EAAE,EAAE,EAAK,qBAAqB;IACpC,EAAE,EAAE,EAAE,EAAS,2BAA2B;CAClC,CAAC;AAIX,iDAAiD;AACjD,MAAM,CAAC,MAAM,IAAI,GAAG;IAClB,IAAI,EAAE,CAAC;IACP,QAAQ,EAAE,UAAU,CAAC,KAAK;IAC1B,QAAQ,EAAE,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,OAAO,GAAG,UAAU,CAAC,MAAM;IACnE,WAAW,EAAE,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,OAAO,GAAG,UAAU,CAAC,MAAM,GAAG,UAAU,CAAC,SAAS;IAC7F,KAAK,EAAE,UAAU,CAAC,KAAK,GAAG,UAAU,CAAC,OAAO,GAAG,UAAU,CAAC,MAAM,GAAG,UAAU,CAAC,SAAS,GAAG,UAAU,CAAC,EAAE,GAAG,UAAU,CAAC,KAAK;IAC1H,SAAS,EAAE,IAAI,EAAE,kBAAkB;CAC3B,CAAC;AAgBX
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/permissions/index.ts"],"names":[],"mappings":"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"}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
### 1.3.1
|
|
2
|
+
**Patch — RBAC role-name case-insensitivity.** Follow-up to the v1.3.0 fail-closed
|
|
3
|
+
permission change. `getUserPermissions` now resolves both the per-user `role` and the
|
|
4
|
+
`defaultRole` fall-back case-insensitively: a lowercase `"operator"` / `"admin"` in
|
|
5
|
+
`MCP_PERMISSIONS_CONFIG` (a common real-world spelling) maps to the correct mask instead
|
|
6
|
+
of silently collapsing to an empty (no-permission) mask. Unknown role names still fail
|
|
7
|
+
closed to `NONE`. Regression tests added under `tests/permissions/`.
|
|
8
|
+
|
|
1
9
|
### 1.3.0
|
|
2
10
|
**Security hardening release.** A full OWASP-aligned audit (API Top 10 2023 + Top 10 2021)
|
|
3
11
|
drove fixes across access control, authentication, injection, transport, configuration,
|
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Home Assistant MCP Wrapper",
|
|
5
|
-
"version": "1.3.
|
|
5
|
+
"version": "1.3.1",
|
|
6
6
|
"description": "REST endpoints that wrap MCP tools for Home Assistant integration. Raw JSON-RPC passthrough available at POST / for n8n compatibility."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
package/src/permissions/index.ts
CHANGED
|
@@ -38,17 +38,36 @@ export interface PermissionsConfig {
|
|
|
38
38
|
defaultRole?: RoleName;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a role name to its permission mask, case-insensitively.
|
|
43
|
+
*
|
|
44
|
+
* Config (and JSON) commonly use lowercase role names ("operator") while the
|
|
45
|
+
* `Role` map is keyed uppercase ("OPERATOR"). Normalize here so BOTH the per-user
|
|
46
|
+
* role and the `defaultRole` fall-back resolve consistently. An unknown/undefined
|
|
47
|
+
* name fails closed to `Role.NONE` (never `undefined`, which downstream coerces to
|
|
48
|
+
* a zero mask anyway but is easy to mishandle).
|
|
49
|
+
*/
|
|
50
|
+
function resolveRoleName(name: string | undefined): number {
|
|
51
|
+
if (!name) {
|
|
52
|
+
return Role.NONE;
|
|
53
|
+
}
|
|
54
|
+
const key = name.toUpperCase() as RoleName;
|
|
55
|
+
return Role[key] ?? Role.NONE;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/**
|
|
42
59
|
* Get permission mask for a user by their JWT sub claim
|
|
43
60
|
*/
|
|
44
61
|
export function getUserPermissions(sub: string | undefined, config: PermissionsConfig): number {
|
|
62
|
+
const fallback = resolveRoleName(config.defaultRole);
|
|
63
|
+
|
|
45
64
|
if (!sub) {
|
|
46
|
-
return
|
|
65
|
+
return fallback;
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
const user = config.users.find((u) => u.sub === sub);
|
|
50
69
|
if (!user) {
|
|
51
|
-
return
|
|
70
|
+
return fallback;
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
// Direct mask takes precedence over role
|
|
@@ -56,9 +75,7 @@ export function getUserPermissions(sub: string | undefined, config: PermissionsC
|
|
|
56
75
|
return user.mask;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
const roleKey = user.role?.toUpperCase() as RoleName | undefined;
|
|
61
|
-
return roleKey && Role[roleKey] !== undefined ? Role[roleKey] : Role.NONE;
|
|
78
|
+
return resolveRoleName(user.role);
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
/**
|
package/src/version.ts
CHANGED
|
@@ -143,6 +143,27 @@ describe('getUserPermissions', () => {
|
|
|
143
143
|
const configWithDefault = { ...config, defaultRole: 'READONLY' as const };
|
|
144
144
|
expect(getUserPermissions('unknown', configWithDefault)).toBe(Role.READONLY);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it('should resolve a lowercase defaultRole (case-insensitive)', () => {
|
|
148
|
+
// Real-world config commonly uses lowercase ("operator"); it must resolve to
|
|
149
|
+
// the OPERATOR mask, not silently fall through to an empty/undefined mask.
|
|
150
|
+
const cfg = { users: [], defaultRole: 'operator' as unknown as 'OPERATOR' };
|
|
151
|
+
expect(getUserPermissions('unlisted-sub', cfg)).toBe(Role.OPERATOR);
|
|
152
|
+
expect(getUserPermissions(undefined, cfg)).toBe(Role.OPERATOR);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should resolve a lowercase per-user role (case-insensitive)', () => {
|
|
156
|
+
const cfg = {
|
|
157
|
+
users: [{ sub: 'claude', role: 'admin' as unknown as 'ADMIN' }],
|
|
158
|
+
defaultRole: 'NONE' as const,
|
|
159
|
+
};
|
|
160
|
+
expect(getUserPermissions('claude', cfg)).toBe(Role.ADMIN);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should fail closed to NONE for an unrecognized role name', () => {
|
|
164
|
+
const cfg = { users: [], defaultRole: 'wizard' as unknown as 'ADMIN' };
|
|
165
|
+
expect(getUserPermissions('anyone', cfg)).toBe(Role.NONE);
|
|
166
|
+
});
|
|
146
167
|
});
|
|
147
168
|
|
|
148
169
|
describe('parsePermissionsConfig', () => {
|