scimgateway 6.2.3 → 6.2.4
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 +3 -0
- package/README.md +3 -3
- package/config/plugin-entra-id.json +7 -1
- package/lib/helper-rest.ts +2 -2
- package/lib/plugin-entra-id.ts +68 -2
- package/lib/scimgateway.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
+
### v6.2.4
|
|
4
|
+
- **[Improved]** `plugin-entra-id` now support reset users MFA capabilites and user will be forced to re-register MFA.
|
|
5
|
+
|
|
3
6
|
### v6.2.3
|
|
4
7
|
- **[Improved]** `plugin-entra-id` now includes information on whether a user has registered for MFA (has an MFA-capable method registered).
|
|
5
8
|
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ SCIM Gateway is a user provisioning bridge built with [Bun](https://bun.sh/) and
|
|
|
81
81
|
|
|
82
82
|
## What's New
|
|
83
83
|
|
|
84
|
-
- **`plugin-entra-id`** now supports Entra ID roles
|
|
84
|
+
- **`plugin-entra-id`** now supports Entra ID roles, access packages and MFA, in addition to reading licenses.
|
|
85
85
|
- **`plugin-generic`** replaces `plugin-scim` — a flexible template using `endpointMapper` with the new `valueMap` option for allowlisting and name mapping e.g., groups
|
|
86
86
|
- **`GET /Roles` and `GET /Entitlements`** endpoint support, with user management via SCIM `roles` and `entitlements` attributes; `plugin-entra-id` uses `entitlements` for Entra ID licenses (read-only) and `roles` for Permanent and Eligible PIM roles (full management)
|
|
87
87
|
- **AI Agent ready** — `x-agent-schema` configuration in `endpointMapper` enables custom schema generation with MCP tool instructions for autonomous provisioning agents
|
|
@@ -1054,7 +1054,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
|
|
|
1054
1054
|
- `Organization.ReadWrite.All`
|
|
1055
1055
|
- Additional for signInActivity, MFA, roles, and access packages:
|
|
1056
1056
|
- `AuditLog.Read.All` *(sign-in activity; only if using `map.user.signInActivity`; requires Entra ID Premium)*
|
|
1057
|
-
- `UserAuthenticationMethod.
|
|
1057
|
+
- `UserAuthenticationMethod.ReadWrite.All` *(MFA information and reset; only if using `map.user.mfa`)*
|
|
1058
1058
|
- `RoleEligibilitySchedule.ReadWrite.Directory` *(PIM Eligible roles; only if using `map.user.roles`)*
|
|
1059
1059
|
- `RoleManagement.ReadWrite.Directory` *(PIM Permanent roles; only if using `map.user.roles`)*
|
|
1060
1060
|
- `EntitlementManagement.ReadWrite.All` *(IGA Access Packages; only if using `map.user.entitlements`)*
|
|
@@ -1063,7 +1063,7 @@ The `baseEntity` parameter enables multi-tenant setups — create multiple endpo
|
|
|
1063
1063
|
|
|
1064
1064
|
> For full access to admin users, assign the `Global Administrator` role. The `User Administrator` role has limitations on users with admin roles.
|
|
1065
1065
|
|
|
1066
|
-
> Note, if PIM and Access Package `management` is not required, `ReadWrite` can be replaced with `Read`. **Remove any mapping configuration whose conditions are not met** — The minimum `Read` permissions are validated at startup.
|
|
1066
|
+
> Note, if MFA, PIM, and Access Package `management` is not required, `ReadWrite` can be replaced with `Read`. **Remove any mapping configuration whose conditions are not met** — The minimum `Read` permissions are validated at startup.
|
|
1067
1067
|
|
|
1068
1068
|
### Plugin Configuration
|
|
1069
1069
|
|
|
@@ -210,10 +210,16 @@
|
|
|
210
210
|
"mutability": "readOnly",
|
|
211
211
|
"description": "true/false - legacy per-user MFA is enabled ('perUserMfaState' is 'enabled' or 'enforced'); such users bypass/aren´t fully controlled by standard Conditional Access only design and therefore require special attention.",
|
|
212
212
|
"type": "boolean"
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "reset",
|
|
216
|
+
"mutability": "writeOnly",
|
|
217
|
+
"description": "Set to 'true' will reset users MFA capabilites and user will be forced to re-register MFA.",
|
|
218
|
+
"type": "boolean"
|
|
213
219
|
}
|
|
214
220
|
],
|
|
215
221
|
"x-agent-schema": {
|
|
216
|
-
"description": "Read-only attribute object
|
|
222
|
+
"description": "Read-only attribute object (except for 'mfa.reset') presenting the user's MFA capabilities. Note: 'mfa.isMfaCapable' does not mean MFA is enabled for the user. Conditional Access policies or using Security Defaults must be verified separately.",
|
|
217
223
|
"readOnly": true
|
|
218
224
|
}
|
|
219
225
|
},
|
package/lib/helper-rest.ts
CHANGED
|
@@ -794,13 +794,13 @@ export class HelperRest {
|
|
|
794
794
|
const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
|
|
795
795
|
return ret // problem fixed
|
|
796
796
|
} else {
|
|
797
|
-
if (statusCode === 404 || ctx
|
|
797
|
+
if (statusCode === 404 || ctx?.skipLogAsError) {
|
|
798
798
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
799
799
|
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
800
800
|
throw err
|
|
801
801
|
}
|
|
802
802
|
} else {
|
|
803
|
-
if (statusCode === 404 || ctx
|
|
803
|
+
if (statusCode === 404 || ctx?.skipLogAsError) { // 404 not logged as error e.g. getUser-manager
|
|
804
804
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
805
805
|
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${options.url} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
806
806
|
if (statusCode === 401) delete this._serviceClient[baseEntity]
|
package/lib/plugin-entra-id.ts
CHANGED
|
@@ -637,6 +637,18 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
637
637
|
return undefined
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
+
// MFA Reset
|
|
641
|
+
if (Object.hasOwn(parsedAttrObj, 'mfa')) {
|
|
642
|
+
if (parsedAttrObj.mfa.reset === true) {
|
|
643
|
+
if (!permission[baseEntity]?.mfa || !permission[baseEntity]?.eligible) throw new Error(`${action} error: MFA reset is not supported by the endpoint - missing permissions.`)
|
|
644
|
+
const currentRoles = await getUserRoles(baseEntity, id, [], true, ctx)
|
|
645
|
+
const isPrivileged = currentRoles.some(r => isPrivilegedRole(r.value))
|
|
646
|
+
if (isPrivileged) throw new Error(`${action} error: MFA reset is not allowed for users with high-privilege roles for security reasons.`)
|
|
647
|
+
await resetUserMfa(baseEntity, id, ctx)
|
|
648
|
+
}
|
|
649
|
+
delete parsedAttrObj.mfa
|
|
650
|
+
}
|
|
651
|
+
|
|
640
652
|
// Roles
|
|
641
653
|
if (Object.hasOwn(parsedAttrObj, 'roles') && Array.isArray(parsedAttrObj.roles)) {
|
|
642
654
|
const r: Record<string, any>[] = []
|
|
@@ -655,7 +667,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
655
667
|
const res: Record<string, any> = { value: el.value, type: el.type }
|
|
656
668
|
if (el.display) res.display = el.display
|
|
657
669
|
if (el.operation === 'delete') {
|
|
658
|
-
if (el.value
|
|
670
|
+
if (isPrivilegedRole(el.value)) throw new Error(`${action} error: Removal of high-privilege roles is not allowed for security reasons.`)
|
|
659
671
|
res.operation = el.operation
|
|
660
672
|
}
|
|
661
673
|
r.push(res)
|
|
@@ -1719,6 +1731,15 @@ const getUserAccessPackages = async (baseEntity: string, userId: string, include
|
|
|
1719
1731
|
return result
|
|
1720
1732
|
}
|
|
1721
1733
|
|
|
1734
|
+
const isPrivilegedRole = (roleId: string): boolean => {
|
|
1735
|
+
return [
|
|
1736
|
+
'62e90394-69f5-4237-9190-012177145e10', // Global Administrator
|
|
1737
|
+
'e8611ab8-c189-46e8-94e1-60213ab1f814', // Privileged Role Administrator
|
|
1738
|
+
'7be44c8a-adaf-4e2a-84d6-ab2649e08a13', // Privileged Authentication Administrator
|
|
1739
|
+
'c4e39bd9-1100-46d3-8c65-fb160da0071f', // Authentication Administrator
|
|
1740
|
+
].includes(roleId)
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1722
1743
|
const isMethodMfaCapable = (odataType: string): boolean => {
|
|
1723
1744
|
return [
|
|
1724
1745
|
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
|
|
@@ -1729,8 +1750,24 @@ const isMethodMfaCapable = (odataType: string): boolean => {
|
|
|
1729
1750
|
].includes(odataType)
|
|
1730
1751
|
}
|
|
1731
1752
|
|
|
1753
|
+
const getAuthMethodCollectionFromODataType = (odataType: string): string => {
|
|
1754
|
+
const map: Record<string, string> = {
|
|
1755
|
+
'#microsoft.graph.microsoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
|
|
1756
|
+
'#microsoft.graph.passwordlessMicrosoftAuthenticatorAuthenticationMethod': 'microsoftAuthenticatorMethods',
|
|
1757
|
+
'#microsoft.graph.phoneAuthenticationMethod': 'phoneMethods',
|
|
1758
|
+
'#microsoft.graph.softwareOathAuthenticationMethod': 'softwareOathMethods',
|
|
1759
|
+
'#microsoft.graph.fido2AuthenticationMethod': 'fido2Methods',
|
|
1760
|
+
'#microsoft.graph.emailAuthenticationMethod': 'emailMethods',
|
|
1761
|
+
'#microsoft.graph.temporaryAccessPassAuthenticationMethod': 'temporaryAccessPassMethods',
|
|
1762
|
+
'#microsoft.graph.windowsHelloForBusinessAuthenticationMethod': 'windowsHelloForBusinessMethods',
|
|
1763
|
+
'#microsoft.graph.passwordAuthenticationMethod': 'passwordMethods',
|
|
1764
|
+
}
|
|
1765
|
+
const collection = map[odataType]
|
|
1766
|
+
return collection
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1732
1769
|
const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
|
|
1733
|
-
const action = '
|
|
1770
|
+
const action = 'getUserisMfaCapable'
|
|
1734
1771
|
const method = 'GET'
|
|
1735
1772
|
const body = null
|
|
1736
1773
|
const path = `/users/${userId}/authentication/methods`
|
|
@@ -1744,6 +1781,35 @@ const getUserisMfaCapable = async (baseEntity: string, userId: string, ctx?: und
|
|
|
1744
1781
|
return methodTypes.some(isMethodMfaCapable)
|
|
1745
1782
|
}
|
|
1746
1783
|
|
|
1784
|
+
const resetUserMfa = async (baseEntity: string, userId: string, ctx?: undefined | Record<string, any>): Promise<boolean> => {
|
|
1785
|
+
const action = 'resetUserMfa'
|
|
1786
|
+
const method = 'GET'
|
|
1787
|
+
const body = null
|
|
1788
|
+
const path = `/users/${userId}/authentication/methods`
|
|
1789
|
+
const r = await helper.doRequest(baseEntity, method, path, body, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1790
|
+
if (!r.body?.value) {
|
|
1791
|
+
if (r.body?.id) r.body.value = [r.body]
|
|
1792
|
+
else throw new Error(`${action} error: invalid response: ${JSON.stringify(r)}`)
|
|
1793
|
+
}
|
|
1794
|
+
const methods = r.body.value.map((m: any) => { return { 'id': m.id, '@odata.type': m['@odata.type'] } })
|
|
1795
|
+
if (methods.length < 1) return false
|
|
1796
|
+
const fnArr: { fn: () => Promise<any>, index?: number, objArr?: any, key?: string }[] = []
|
|
1797
|
+
for (const m of methods) {
|
|
1798
|
+
if (!isMethodMfaCapable(m['@odata.type'])) continue
|
|
1799
|
+
const methodName = getAuthMethodCollectionFromODataType(m['@odata.type'])
|
|
1800
|
+
if (!methodName) continue
|
|
1801
|
+
const path = `/users/${userId}/authentication/${methodName}/${m.id}`
|
|
1802
|
+
const fn = () => helper.doRequest(baseEntity, 'DELETE', path, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1803
|
+
fnArr.push({ fn })
|
|
1804
|
+
}
|
|
1805
|
+
if (fnArr.length === 0) return false
|
|
1806
|
+
// include revoke sessions
|
|
1807
|
+
const fn = () => helper.doRequest(baseEntity, 'POST', `/users/${userId}/invalidateAllRefreshTokens`, null, ctx?.headers ? { headers: ctx?.headers } : undefined)
|
|
1808
|
+
fnArr.push({ fn })
|
|
1809
|
+
await fnCunckExecute(fnArr)
|
|
1810
|
+
return true
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1747
1813
|
/**
|
|
1748
1814
|
* getUsersByRole returns an array of user IDs having a specific role assigned
|
|
1749
1815
|
* @param baseEntity
|
package/lib/scimgateway.ts
CHANGED
|
@@ -2261,6 +2261,7 @@ export class ScimGateway {
|
|
|
2261
2261
|
if (res?.Resources && Array.isArray(res.Resources) && res.Resources.length === 1) {
|
|
2262
2262
|
// we might have endpoint like Entra ID that hasn’t caught up yet due to internal sync
|
|
2263
2263
|
// ensure returned object reflects changes by doing a merge with patch payload (convertedScim)
|
|
2264
|
+
if (finalScimdata?.mfa) delete finalScimdata.mfa // writeOnly mfa.reset
|
|
2264
2265
|
res.Resources[0] = this.patchObj(res.Resources[0], finalScimdata) // merge
|
|
2265
2266
|
if (res.Resources[0].password) delete res.Resources[0].password
|
|
2266
2267
|
}
|
package/package.json
CHANGED