ldap-rest 1.2.0 → 1.3.0
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/bin/sync-james.mjs +13 -2
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/abstract/plugin.js +1 -1
- package/dist/abstract/twakePlugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/config/options.js +1 -0
- package/dist/config/schema.js +1 -1
- package/dist/lib/auth/base.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/entitySchema/validator.js +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/health.js +1 -1
- package/dist/lib/hookBus.js +1 -0
- package/dist/lib/instanceProviders.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapSchema.js +1 -1
- package/dist/lib/parseConfig.js +1 -1
- package/dist/lib/route.js +1 -0
- package/dist/lib/schemaFile.js +1 -1
- package/dist/lib/storage/store.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/lib/version.js +1 -0
- package/dist/logger/winston.js +1 -1
- package/dist/plugins/auth/auth.options.js +1 -0
- package/dist/plugins/auth/authzDynamic.js +1 -1
- package/dist/plugins/auth/authzLinid1.js +1 -1
- package/dist/plugins/auth/authzPerBranch.js +1 -1
- package/dist/plugins/auth/authzPerRoute.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/auth/crowdsec.js +1 -1
- package/dist/plugins/auth/fake.js +1 -1
- package/dist/plugins/auth/hmac.js +1 -1
- package/dist/plugins/auth/llng.js +1 -1
- package/dist/plugins/auth/openidconnect.js +1 -1
- package/dist/plugins/auth/rateLimit.js +1 -1
- package/dist/plugins/auth/token.js +1 -1
- package/dist/plugins/auth/totp.js +1 -1
- package/dist/plugins/auth/trustedProxy.js +1 -1
- package/dist/plugins/bcl.js +1 -1
- package/dist/plugins/bcl.options.js +1 -0
- package/dist/plugins/configApi.js +1 -1
- package/dist/plugins/demo/helloworld.js +1 -1
- package/dist/plugins/ldap/accountLifecycle.js +1 -1
- package/dist/plugins/ldap/bulkImport.js +1 -1
- package/dist/plugins/ldap/departmentSync.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
- package/dist/plugins/ldap/flatGeneric.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/ldap.options.js +1 -0
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/passwordPolicy.js +1 -1
- package/dist/plugins/ldap/raw.js +1 -1
- package/dist/plugins/ldap/trash.js +1 -1
- package/dist/plugins/metrics.js +1 -1
- package/dist/plugins/metrics.options.js +1 -0
- package/dist/plugins/rabbitmq.js +1 -1
- package/dist/plugins/rabbitmq.options.js +1 -0
- package/dist/plugins/scim/baseResolver.js +1 -1
- package/dist/plugins/scim/bulk.js +1 -1
- package/dist/plugins/scim/discovery.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/scim/filter.js +1 -1
- package/dist/plugins/scim/groups.js +1 -1
- package/dist/plugins/scim/mapping.js +1 -1
- package/dist/plugins/scim/patch.js +1 -1
- package/dist/plugins/scim/projection.js +1 -1
- package/dist/plugins/scim/scim.js +1 -1
- package/dist/plugins/scim/scim.options.js +1 -0
- package/dist/plugins/scim/users.js +1 -1
- package/dist/plugins/static.js +1 -1
- package/dist/plugins/static.options.js +1 -0
- package/dist/plugins/storage/file.js +1 -1
- package/dist/plugins/storage/ldap.js +1 -1
- package/dist/plugins/storage/postgres.js +1 -1
- package/dist/plugins/storage/valkey.js +1 -1
- package/dist/plugins/storage.js +1 -1
- package/dist/plugins/storage.options.js +1 -0
- package/dist/plugins/twake/appAccounts.options.js +1 -0
- package/dist/plugins/twake/appAccountsApi.js +1 -1
- package/dist/plugins/twake/appAccountsConsistency.js +1 -1
- package/dist/plugins/twake/calendar.js +1 -1
- package/dist/plugins/twake/calendar.options.js +1 -0
- package/dist/plugins/twake/calendarResources.js +1 -1
- package/dist/plugins/twake/cloudery.options.js +1 -0
- package/dist/plugins/twake/clouderyProvision.js +1 -1
- package/dist/plugins/twake/cozy.options.js +1 -0
- package/dist/plugins/twake/cozyProvision.js +1 -1
- package/dist/plugins/twake/drive.js +1 -1
- package/dist/plugins/twake/drive.options.js +1 -0
- package/dist/plugins/twake/groups.js +1 -1
- package/dist/plugins/twake/instances.js +1 -1
- package/dist/plugins/twake/james.js +1 -1
- package/dist/plugins/twake/james.options.js +1 -0
- package/dist/plugins/twake/lifecycleAttributes.js +1 -1
- package/dist/plugins/twake/lifecycleEvents.js +1 -1
- package/dist/plugins/twake/migrationProxy.js +1 -1
- package/dist/plugins/twake/migrationProxy.options.js +1 -0
- package/dist/plugins/twake/spaces.js +1 -1
- package/dist/plugins/twake/tombstone.js +1 -1
- package/dist/plugins/twake/twakeGroups.options.js +1 -0
- package/dist/plugins/twake/twakeInstances.options.js +1 -0
- package/dist/plugins/twake/twakeLifecycle.options.js +1 -0
- package/dist/plugins/twake/twakeSpaces.options.js +1 -0
- package/dist/plugins/twake/twakeTombstone.options.js +1 -0
- package/dist/plugins/weblogs.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +28 -10
- package/dist/src/abstract/plugin.d.ts +10 -5
- package/dist/src/abstract/twakePlugin.d.ts +14 -4
- package/dist/src/bin/index.d.ts +17 -4
- package/dist/src/config/args.d.ts +2 -4
- package/dist/src/config/options.d.ts +89 -0
- package/dist/src/config/schema.d.ts +50 -1
- package/dist/src/hooks.d.ts +60 -22
- package/dist/src/lib/authz/base.d.ts +4 -51
- package/dist/src/lib/authz/composition.d.ts +2 -2
- package/dist/src/lib/entitySchema/validator.d.ts +63 -6
- package/dist/src/lib/errors.d.ts +56 -1
- package/dist/src/lib/expressFormatedResponses.d.ts +49 -7
- package/dist/src/lib/hookBus.d.ts +226 -0
- package/dist/src/lib/instanceProviders.d.ts +0 -8
- package/dist/src/lib/ldapActions.d.ts +11 -2
- package/dist/src/lib/parseConfig.d.ts +5 -0
- package/dist/src/lib/route.d.ts +99 -0
- package/dist/src/lib/utils.d.ts +37 -36
- package/dist/src/lib/version.d.ts +12 -0
- package/dist/src/plugins/auth/auth.options.d.ts +88 -0
- package/dist/src/plugins/auth/authzDynamic.d.ts +10 -1
- package/dist/src/plugins/auth/authzLinid1.d.ts +6 -0
- package/dist/src/plugins/auth/authzPerBranch.d.ts +9 -0
- package/dist/src/plugins/auth/authzPerRoute.d.ts +3 -0
- package/dist/src/plugins/auth/authzScope.d.ts +23 -14
- package/dist/src/plugins/auth/crowdsec.d.ts +5 -0
- package/dist/src/plugins/auth/fake.d.ts +3 -0
- package/dist/src/plugins/auth/hmac.d.ts +4 -0
- package/dist/src/plugins/auth/llng.d.ts +4 -0
- package/dist/src/plugins/auth/openidconnect.d.ts +7 -0
- package/dist/src/plugins/auth/rateLimit.d.ts +4 -0
- package/dist/src/plugins/auth/token.d.ts +3 -0
- package/dist/src/plugins/auth/totp.d.ts +5 -0
- package/dist/src/plugins/auth/trustedProxy.d.ts +4 -0
- package/dist/src/plugins/bcl.d.ts +3 -0
- package/dist/src/plugins/bcl.options.d.ts +8 -0
- package/dist/src/plugins/configApi.d.ts +24 -5
- package/dist/src/plugins/demo/helloworld.d.ts +8 -19
- package/dist/src/plugins/ldap/accountLifecycle.d.ts +17 -0
- package/dist/src/plugins/ldap/bulkImport.d.ts +6 -76
- package/dist/src/plugins/ldap/departmentSync.d.ts +1 -0
- package/dist/src/plugins/ldap/enterpriseRules.d.ts +8 -1
- package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +4 -0
- package/dist/src/plugins/ldap/flatGeneric.d.ts +4 -0
- package/dist/src/plugins/ldap/groups.d.ts +27 -11
- package/dist/src/plugins/ldap/ldap.options.d.ts +75 -0
- package/dist/src/plugins/ldap/onChange.d.ts +10 -2
- package/dist/src/plugins/ldap/organizations.d.ts +16 -0
- package/dist/src/plugins/ldap/passwordPolicy.d.ts +32 -1
- package/dist/src/plugins/ldap/raw.d.ts +50 -19
- package/dist/src/plugins/ldap/trash.d.ts +6 -0
- package/dist/src/plugins/metrics.d.ts +3 -0
- package/dist/src/plugins/metrics.options.d.ts +8 -0
- package/dist/src/plugins/rabbitmq.d.ts +3 -0
- package/dist/src/plugins/rabbitmq.options.d.ts +8 -0
- package/dist/src/plugins/scim/baseResolver.d.ts +2 -1
- package/dist/src/plugins/scim/bulk.d.ts +6 -7
- package/dist/src/plugins/scim/discovery.d.ts +3 -0
- package/dist/src/plugins/scim/errors.d.ts +4 -1
- package/dist/src/plugins/scim/groups.d.ts +6 -4
- package/dist/src/plugins/scim/scim.d.ts +37 -3
- package/dist/src/plugins/scim/scim.options.d.ts +35 -0
- package/dist/src/plugins/scim/types.d.ts +2 -1
- package/dist/src/plugins/scim/users.d.ts +6 -7
- package/dist/src/plugins/static.d.ts +9 -0
- package/dist/src/plugins/static.options.d.ts +13 -0
- package/dist/src/plugins/storage.d.ts +12 -1
- package/dist/src/plugins/storage.options.d.ts +16 -0
- package/dist/src/plugins/twake/appAccounts.options.d.ts +13 -0
- package/dist/src/plugins/twake/appAccountsApi.d.ts +18 -0
- package/dist/src/plugins/twake/appAccountsConsistency.d.ts +8 -0
- package/dist/src/plugins/twake/calendar.d.ts +25 -2
- package/dist/src/plugins/twake/calendar.options.d.ts +19 -0
- package/dist/src/plugins/twake/cloudery.options.d.ts +26 -0
- package/dist/src/plugins/twake/clouderyProvision.d.ts +18 -0
- package/dist/src/plugins/twake/cozy.options.d.ts +23 -0
- package/dist/src/plugins/twake/cozyProvision.d.ts +14 -0
- package/dist/src/plugins/twake/drive.d.ts +7 -0
- package/dist/src/plugins/twake/drive.options.d.ts +15 -0
- package/dist/src/plugins/twake/groups.d.ts +30 -3
- package/dist/src/plugins/twake/instances.d.ts +43 -10
- package/dist/src/plugins/twake/james.d.ts +17 -0
- package/dist/src/plugins/twake/james.options.d.ts +21 -0
- package/dist/src/plugins/twake/lifecycleEvents.d.ts +12 -0
- package/dist/src/plugins/twake/migrationProxy.d.ts +11 -11
- package/dist/src/plugins/twake/migrationProxy.options.d.ts +16 -0
- package/dist/src/plugins/twake/spaces.d.ts +30 -0
- package/dist/src/plugins/twake/tombstone.d.ts +16 -14
- package/dist/src/plugins/twake/twakeGroups.options.d.ts +20 -0
- package/dist/src/plugins/twake/twakeInstances.options.d.ts +43 -0
- package/dist/src/plugins/twake/twakeLifecycle.options.d.ts +21 -0
- package/dist/src/plugins/twake/twakeSpaces.options.d.ts +19 -0
- package/dist/src/plugins/twake/twakeTombstone.options.d.ts +17 -0
- package/docs/README.md +1 -0
- package/docs/client-development/api/rest-api.md +9 -3
- package/docs/client-development/schemas/README.md +96 -32
- package/docs/plugin-development/README.md +45 -94
- package/docs/plugin-development/dependencies.md +11 -6
- package/docs/plugin-development/hooks.md +142 -23
- package/docs/plugin-development/openapi.md +14 -1
- package/docs/plugin-development/options.md +109 -0
- package/docs/plugin-development/routes.md +152 -0
- package/docs/plugin-development/testing.md +3 -3
- package/docs/usage/configuration.md +31 -24
- package/docs/usage/operations.md +19 -4
- package/docs/usage/plugins/integrations/groups.md +2 -2
- package/docs/usage/plugins/integrations/scim.md +12 -1
- package/docs/usage/plugins/integrations/spaces.md +1 -1
- package/docs/usage/plugins/ldap/enterprise-rules.md +10 -1
- package/docs/usage/plugins/ldap/flat-generic.md +4 -0
- package/docs/usage/plugins/ldap/raw.md +17 -1
- package/docs/usage/upgrading.md +207 -0
- package/package.json +358 -277
- package/static/browser/ldap-browser/LdapBrowser.js +8 -6
- package/static/browser/ldap-browser/LdapBrowser.js.map +1 -1
- package/static/browser/ldap-browser/api/RawApiClient.js +2 -0
- package/static/browser/ldap-browser/api/RawApiClient.js.map +1 -1
- package/static/browser/ldap-browser/components/EntryTree.js +9 -5
- package/static/browser/ldap-browser/components/EntryTree.js.map +1 -1
- package/static/browser/ldap-browser/components/EntryView.js +2 -0
- package/static/browser/ldap-browser/components/EntryView.js.map +1 -1
- package/static/browser/ldap-browser/schema.js +3 -2
- package/static/browser/ldap-browser/schema.js.map +1 -1
- package/static/browser/ldap-browser.esm.js +27 -18
- package/static/browser/ldap-browser.esm.js.map +1 -1
- package/static/browser/ldap-browser.js +27 -18
- package/static/browser/ldap-browser.js.map +1 -1
- package/static/browser/ldap-browser.min.js +1 -1
- package/static/browser/ldap-browser.min.js.map +1 -1
- package/static/browser/ldap-group-editor/LdapGroupEditor.js +8 -6
- package/static/browser/ldap-group-editor/LdapGroupEditor.js.map +1 -1
- package/static/browser/ldap-group-editor/api/GroupApiClient.js +1 -0
- package/static/browser/ldap-group-editor/api/GroupApiClient.js.map +1 -1
- package/static/browser/ldap-group-editor/components/CreateGroupModal.js +5 -1
- package/static/browser/ldap-group-editor/components/CreateGroupModal.js.map +1 -1
- package/static/browser/ldap-group-editor/components/GroupPropertyEditor.js +6 -3
- package/static/browser/ldap-group-editor/components/GroupPropertyEditor.js.map +1 -1
- package/static/browser/ldap-group-editor/components/GroupTree.js +6 -3
- package/static/browser/ldap-group-editor/components/GroupTree.js.map +1 -1
- package/static/browser/ldap-group-editor/components/MoveGroupModal.js +6 -3
- package/static/browser/ldap-group-editor/components/MoveGroupModal.js.map +1 -1
- package/static/browser/ldap-group-editor.esm.js +32 -16
- package/static/browser/ldap-group-editor.esm.js.map +1 -1
- package/static/browser/ldap-group-editor.js +32 -16
- package/static/browser/ldap-group-editor.js.map +1 -1
- package/static/browser/ldap-group-editor.min.js +1 -1
- package/static/browser/ldap-group-editor.min.js.map +1 -1
- package/static/browser/ldap-resource-editor/api/ResourceApiClient.js +3 -0
- package/static/browser/ldap-resource-editor/api/ResourceApiClient.js.map +1 -1
- package/static/browser/ldap-resource-editor/cache/CacheManager.js +3 -1
- package/static/browser/ldap-resource-editor/cache/CacheManager.js.map +1 -1
- package/static/browser/ldap-tree-viewer/LdapTreeViewer.js +6 -1
- package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
- package/static/browser/ldap-tree-viewer/api/LdapApiClient.js +2 -0
- package/static/browser/ldap-tree-viewer/api/LdapApiClient.js.map +1 -1
- package/static/browser/ldap-tree-viewer/components/TreeNode.js +4 -0
- package/static/browser/ldap-tree-viewer/components/TreeNode.js.map +1 -1
- package/static/browser/ldap-tree-viewer/components/TreeRoot.js +3 -0
- package/static/browser/ldap-tree-viewer/components/TreeRoot.js.map +1 -1
- package/static/browser/ldap-tree-viewer/store/Store.js +3 -1
- package/static/browser/ldap-tree-viewer/store/Store.js.map +1 -1
- package/static/browser/ldap-tree-viewer.esm.js +18 -2
- package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
- package/static/browser/ldap-tree-viewer.js +18 -2
- package/static/browser/ldap-tree-viewer.js.map +1 -1
- package/static/browser/ldap-tree-viewer.min.js +1 -1
- package/static/browser/ldap-tree-viewer.min.js.map +1 -1
- package/static/browser/ldap-unit-editor/LdapUnitEditor.js +7 -5
- package/static/browser/ldap-unit-editor/LdapUnitEditor.js.map +1 -1
- package/static/browser/ldap-unit-editor/api/UnitApiClient.js +1 -0
- package/static/browser/ldap-unit-editor/api/UnitApiClient.js.map +1 -1
- package/static/browser/ldap-unit-editor/components/MoveUnitModal.js +7 -4
- package/static/browser/ldap-unit-editor/components/MoveUnitModal.js.map +1 -1
- package/static/browser/ldap-unit-editor/components/UnitPropertyEditor.js +7 -4
- package/static/browser/ldap-unit-editor/components/UnitPropertyEditor.js.map +1 -1
- package/static/browser/ldap-unit-editor/components/UnitTree.js +6 -3
- package/static/browser/ldap-unit-editor/components/UnitTree.js.map +1 -1
- package/static/browser/ldap-unit-editor.esm.js +28 -16
- package/static/browser/ldap-unit-editor.esm.js.map +1 -1
- package/static/browser/ldap-unit-editor.js +28 -16
- package/static/browser/ldap-unit-editor.js.map +1 -1
- package/static/browser/ldap-unit-editor.min.js +1 -1
- package/static/browser/ldap-unit-editor.min.js.map +1 -1
- package/static/browser/ldap-user-editor/LdapUserEditor.js +9 -7
- package/static/browser/ldap-user-editor/LdapUserEditor.js.map +1 -1
- package/static/browser/ldap-user-editor/api/UserApiClient.js +2 -0
- package/static/browser/ldap-user-editor/api/UserApiClient.js.map +1 -1
- package/static/browser/ldap-user-editor/cache/CacheManager.js +3 -1
- package/static/browser/ldap-user-editor/cache/CacheManager.js.map +1 -1
- package/static/browser/ldap-user-editor/components/MoveUserModal.js +7 -4
- package/static/browser/ldap-user-editor/components/MoveUserModal.js.map +1 -1
- package/static/browser/ldap-user-editor/components/PointerField.js +6 -1
- package/static/browser/ldap-user-editor/components/PointerField.js.map +1 -1
- package/static/browser/ldap-user-editor/components/UserEditor.js +10 -5
- package/static/browser/ldap-user-editor/components/UserEditor.js.map +1 -1
- package/static/browser/ldap-user-editor/components/UserList.js +8 -4
- package/static/browser/ldap-user-editor/components/UserList.js.map +1 -1
- package/static/browser/ldap-user-editor/components/UserTree.js +7 -3
- package/static/browser/ldap-user-editor/components/UserTree.js.map +1 -1
- package/static/browser/ldap-user-editor.esm.js +55 -30
- package/static/browser/ldap-user-editor.esm.js.map +1 -1
- package/static/browser/ldap-user-editor.js +55 -30
- package/static/browser/ldap-user-editor.js.map +1 -1
- package/static/browser/ldap-user-editor.min.js +1 -1
- package/static/browser/ldap-user-editor.min.js.map +1 -1
- package/static/browser/shared/components/DisposableComponent.js +3 -5
- package/static/browser/shared/components/DisposableComponent.js.map +1 -1
- package/static/browser/shared/components/Modal.js +2 -1
- package/static/browser/shared/components/Modal.js.map +1 -1
- package/static/browser/shared/components/StatusMessage.js +3 -2
- package/static/browser/shared/components/StatusMessage.js.map +1 -1
- package/static/browser/shared/utils/form.js.map +1 -1
- package/static/browser/shared/utils/hmac.js +1 -0
- package/static/browser/shared/utils/hmac.js.map +1 -1
- package/static/browser/shared/utils/totp.js +1 -0
- package/static/browser/shared/utils/totp.js.map +1 -1
- package/static/browser/shared.esm.js +9 -8
- package/static/browser/shared.esm.js.map +1 -1
- package/static/browser/shared.js +9 -8
- package/static/browser/shared.js.map +1 -1
- package/static/browser/shared.min.js +1 -1
- package/static/browser/shared.min.js.map +1 -1
- package/static/schemas/ad/groups.json +1 -1
- package/static/schemas/ad/users.json +3 -3
- package/static/schemas/example/organizations.json +2 -2
- package/static/schemas/example/users.json +2 -2
- package/static/schemas/obm/users.json +2 -2
- package/static/schemas/standard/groups.json +1 -1
- package/static/schemas/standard/users.json +2 -2
- package/static/schemas/twake/groups.json +4 -4
- package/static/schemas/twake/teamMailboxes.json +3 -3
- package/static/schemas/twake/users.json +7 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as t}from"node:crypto";import{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{parseDn as o,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,normalizeDn as d,unescapeDnValue as h}from"../../lib/utils.js";import p from"../ldap/groups.js";import{BaseResolver as m}from"../scim/baseResolver.js";import{mergeMapping as g,loadMappingFile as l,DEFAULT_GROUP_MAPPING as f}from"../scim/mapping.js";import{outsideRequest as y}from"../../lib/requestContext.js";import{valueOf as w,lifecycleAttributes as b,first as _}from"./lifecycleAttributes.js";const read=(t,e)=>_(w(t,e)),v="{org}",O=["displayName","description","createdAt"],$=["uid","displayName","mail","jobTitle"],k={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},I={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},j="twakeIsTechnical";class RouteError extends Error{constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),N="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const R=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&R.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return d(t)}catch{return t.toLowerCase()}}function parentOf(t){return o(t).slice(1).join(",")}function rdnValue(t){const[e]=o(t),r=e.indexOf("=");return r>0?h(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=o(t).join(",").split(v).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"));return new RegExp(`^${e}(?<org>(?:\\\\.|[^,])+)${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const P=["twakegroup","twakestaticgroup"],z=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(o(e).join(",")));return r?.groups?h(r.groups.org):void 0}class TwakeGroups extends p{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const o=movedInto(this.groupPattern,t,r);if(void 0===o)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[z]:!0},t);a=e[0]?.member}catch(t){if(32!==i(t))throw t}if(void 0!==this.foreign(o,a))throw new e(`The group holds members of another organization than ${o}`);return[t,r,s]},ldaprenamedone:async([t,e])=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:i}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==w(i[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:this.orgPattern.replace(v,a(r))}})},ldapsearchfilter:async([t,e,r])=>[r?.[z]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(o(s).join(","))?{...e,id:t()}:e,r,s])},this.groupBase=this.config.twake_group_base||"",this.userBase=this.config.twake_group_user_base||"";for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(!e.includes(v))throw new Error(`${this.name}: ${t} must hold ${v}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=b(this.config),this.orgPattern=this.config.twake_group_organization_dn||"",this.displayName=this.config.twake_group_display_name_attribute||"twakeDisplayName",this.color=this.config.twake_group_color_attribute||"twakeGroupColor",this.createdAt=this.config.twake_group_created_at_attribute||"twakeCreatedAt",this.maxPage=this.config.twake_group_max_page_limit||1e3,this.memberFields={...k,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:R.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),i=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=i}return t}afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=this.config.scim_group_rdn_attribute||"cn",e=this.config.scim_group_mapping||"",r=g(f,e?l(e):void 0).entries.find(e=>e.ldap?.toLowerCase()===t.toLowerCase());r&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${r.scim} to ${t}, so SCIM groups keep it as their RDN instead of a generated cn: map ${r.scim} to another attribute with --scim-group-mapping`)}linked(t,r,s){const i=this.config.ldap_organization_link_attribute;if(!i||!this.orgPattern||!this.judgedByAttachment())return s;const o=this.orgPattern.replace(v,a(r)),n=read(s,i);if(void 0!==n){if(dnKey(n)!==dnKey(o))throw new e(`${t} lies in the branch of ${r}, and must be linked to it`);return s}const c=values(s.objectClass);return c.some(t=>P.includes(t.toLowerCase()))||c.push("twakeGroup"),{...s,objectClass:c,[i]:o}}checkLinkChange(t,r){const s=this.config.ldap_organization_link_attribute,i=this.organizationOf(t);if(void 0===i||!s||!this.orgPattern||!this.judgedByAttachment())return;const o=dnKey(this.orgPattern.replace(v,a(i))),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),n=[...named(r.add).flatMap(t=>values(r.add[t])),...named(r.replace).flatMap(t=>values(r.replace[t]))],c=Array.isArray(r.delete)?r.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(r.delete).length>0,u=named(r.replace).length>0&&0===n.length;if(c||u||n.some(t=>dnKey(t)!==o))throw new e(`${t} lies in the branch of ${i}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(v,a(t))}userBaseOf(t){return this.userBase.replace(v,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const i=this.foreign(s,r);if(void 0!==i)throw new e(`${i} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!n(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===i(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this);t.get(e,r("listing groups",(t,e,r)=>this.listRoute(t,e,r))),t.post(e,r("creating group",(t,e,r)=>this.createRoute(t,e,r))),t.get(`${e}/:groupId`,r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)})),t.get(`${e}/:groupId/members`,r("listing group members",(t,e,r)=>this.membersRoute(t,e,r))),t.patch(`${e}/:groupId`,r("updating group",(t,e,r)=>this.updateRoute(t,e,r))),t.delete(`${e}/:groupId`,r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})})),t.post(`${e}/:groupId/members`,r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r))),t.delete(`${e}/:groupId/members/:userId`,r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r)))}organizationRoute(t,e,i=this.name){return async(o,a)=>{try{const t=o.params.id;await this.checkOrganization(t,o),await e(o,a,t)}catch(e){if(e instanceof RouteError)return void a.status(e.status).json({error:e.message,code:e.code});if(u(e))return void a.status(403).json({error:r,code:"REFUSED"});if(e instanceof s&&e.statusCode<500)return void a.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:i,context:t,error:String(e)}),a.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${a(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${a(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.config.twake_group_organization_status_attribute||"twakeOrgStatus",s=this.orgPattern.replace(v,a(t));if(!await this.ldap.readableBy(s,e))throw notFound("organization");let o;try{o=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[r]},s)).searchEntries[0]}catch(t){if(32!==i(t))throw t}if(!o)throw notFound("organization");const n=this.config.twake_group_organization_deleted_value||"deleted";if(read(o,r)===n)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),i=t.query.search||void 0;if(void 0!==i&&("string"!=typeof i||i.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const o=t.query.sortBy;if(void 0!==o&&!e.includes(o))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:i,sortBy:o,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),i=values(e.member).filter(t=>!n(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?rdnValue(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:i,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:i}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return i}catch(t){if(32===i(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===i(t)||u(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${c(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===i(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let i=`(${this.cn}=*)`;if(s.search){const t=c(s.search);i=`(&${i}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const o=this.sorted((await this.groups(r,i,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:o.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:o.length,totalPages:Math.ceil(o.length/s.limit)}})}async createRoute(e,r,s){const i=bodyOf(e);if(!isDisplayName(i.name))throw invalid(N);if(void 0!==i.description&&"string"!=typeof i.description)throw invalid("description must be a string");if(void 0!==i.color&&!isColor(i.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const o=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:i.name,[this.createdAt]:(new Date).toISOString()};i.description&&(a.description=i.description),i.color&&(a[this.color]=i.color);const n=this.linked(this.groupDn(s,o),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,o),this.candidateGroup(o,[],n),e]);const c=i.name;if(await y(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,o),[],n,e);const u=await y(()=>this.readGroup(s,o));r.status(201).json(u)}async updateRoute(t,e,r){const s=bodyOf(t),i=t.params.groupId,o=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(o.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${o.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(N);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const u={};if(void 0!==a&&(u[this.displayName]=a),void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{replace:u},this.opNumber(),t]),void 0!==a){const t=await y(()=>this.named(r,a));if(t&&t!==i)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,i),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,$),i=t.params.groupId,o=await this.readGroup(r,i,t);if(!o)throw notFound("group");e.json({organizationId:r,id:i,...await this.memberPage(s,r,o.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),i){const o=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,o,i);let n=o.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,s){const{deleted:o,deletedValue:a}=this.attrs,n=o?`(!(${o}=${c(a)}))`:"",d=[];for(let o=0;o<e.length;o+=this.maxPage){const a=`(&(|${e.slice(o,o+this.maxPage).map(t=>`(${this.userAttribute}=${c(t)})`).join("")})${n})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:a,attributes:r},this.userBaseOf(t),s);d.push(...e)}catch(t){if(32!==i(t)&&!u(t))throw t}}return d}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),j])],i=new Map;for(const o of await this.users(t,e,s,r)){const t=read(o,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(o,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(I)){const s=read(o,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(o,j)?.toUpperCase(),i.set(t.toLowerCase(),e)}return i}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const i=t.params.groupId,o=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,i),{add:{member:o.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,i,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,o,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(o.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),u=o.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),d=this.groupDn(r,i);u.length&&(await this.onGroup(()=>this.ldap.modify(d,{add:{member:u}},t)),await this.dropPlaceholder(d,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(i(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=i(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{N as NAME_RULE,v as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,rdnValue,read,values};
|
|
1
|
+
import{randomUUID as t}from"node:crypto";import{Type as e}from"@sinclair/typebox";import{BadRequestError as r,isAuthzRefusal as s,AUTHZ_REFUSED as o,HttpError as i}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{defineRoute as n}from"../../lib/route.js";import{parseDn as c,isDummyMemberDn as h,escapeLdapFilter as u,escapeDnValue as d,rdnValue as p,fillPattern as m,normalizeDn as g,unescapeDnValue as l}from"../../lib/utils.js";import f from"../ldap/groups.js";import{BaseResolver as w}from"../scim/baseResolver.js";import{mergeMapping as y,loadMappingFile as b,DEFAULT_GROUP_MAPPING as _}from"../scim/mapping.js";import{scimOptionsOf as O}from"../scim/scim.options.js";import{outsideRequest as v}from"../../lib/requestContext.js";import{lifecycleAttributes as $,valueOf as j,first as I}from"./lifecycleAttributes.js";import{twakeGroupsOptions as k}from"./twakeGroups.options.js";const read=(t,e)=>I(j(t,e)),N="{org}";function withOrg(t,e){return m(t,N,d(e))}const R=["displayName","description","createdAt"],P=["uid","displayName","mail","jobTitle"],z={uid:"uid",_id:"entryUUID",cn:"cn",sn:"sn",givenName:"givenName",displayName:"displayName",mail:"mail",mobile:"mobile",jobTitle:"twakeJobTitle",company:"twakeCompany",organizationRole:"twakeOrganizationRole",organizationId:"twakeOrganizationId"},A={familyName:"sn",givenName:"givenName",additionalName:"twakeAdditionalName",namePrefix:"twakeNamePrefix"},C="twakeIsTechnical";class RouteError extends Error{status;code;constructor(t,e,r){super(e),this.status=t,this.code=r}}const notFound=t=>new RouteError(404,`${t[0].toUpperCase()}${t.slice(1)} not found`,`${t.toUpperCase()}_NOT_FOUND`),invalid=(t,e="INVALID_INPUT")=>new RouteError(400,t,e),E="name must be a non-blank string of at most 256 characters with no control characters";function isDisplayName(t){return"string"==typeof t&&t.trim().length>0&&t.length<=256&&!/[\x00-\x1f\x7f]/.test(t)}const B=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&B.test(t),text=t=>"string"==typeof t||"number"==typeof t?String(t):"";function bodyOf(t){const e=t.body;if(!e||"object"!=typeof e||Array.isArray(e))throw invalid("Request body must be a JSON object");return e}function values(t){return null==t?[]:(Array.isArray(t)?t:[t]).map(t=>t.toString())}function dnKey(t){try{return g(t)}catch{return t.toLowerCase()}}function parentOf(t){return c(t).slice(1).join(",")}function branchPattern(t){const[e,r]=c(t).join(",").split(N).map(t=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")),s=String.fromCharCode(92);return new RegExp(`^${e}(?<org>${`(?:[^,${s}${s}]|${s}${s}.)+`})${r}$`,"i")}function movedInto(t,e,r){const s=organizationIn(t,r);return void 0!==s&&dnKey(parentOf(r))!==dnKey(parentOf(e))?s:void 0}const D=["twakegroup","twakestaticgroup"],L=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(c(e).join(",")));return r?.groups?l(r.groups.org):void 0}const S=e.Object({id:e.String({minLength:1})}),x=e.Object({id:e.String({minLength:1}),groupId:e.String({minLength:1})}),G=e.Object({id:e.String({minLength:1}),groupId:e.String({minLength:1}),userId:e.String({minLength:1})});class TwakeGroups extends f{static optionDefinitions=k;name="twakeGroups";groupBase;userBase;groupPattern;attrs;orgPattern;displayName;color;createdAt;maxPage;memberFields;constructor(t){super(t);const e=this.options(k);this.groupBase=e.twake_group_base,this.userBase=e.twake_group_user_base;for(const[t,e]of[["--twake-group-base",this.groupBase],["--twake-group-user-base",this.userBase]])if(2!==e.split(N).length)throw new Error(`${this.name}: ${t} must hold ${N} once`);this.groupPattern=branchPattern(this.groupBase),this.attrs=$(this.config),this.orgPattern=e.twake_group_organization_dn,this.displayName=e.twake_group_display_name_attribute,this.color=e.twake_group_color_attribute,this.createdAt=e.twake_group_created_at_attribute,this.maxPage=e.twake_group_max_page_limit,this.memberFields={...z,...e.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:B.source},[this.createdAt]:{type:"string"}},r=this.config.ldap_organization_link_attribute;r&&this.orgPattern&&(e[r]={type:"string",generated:!0});for(const[r,s]of Object.entries(e)){const e=Object.keys(t.attributes).find(t=>t.toLowerCase()===r.toLowerCase()),o=e?t.attributes[e]:s;e&&delete t.attributes[e],t.attributes[r]=o}return t}hooks={ldapaddrequest:([t,e,r])=>{this.checkMembers(t,e.member);const s=this.organizationOf(t);return void 0!==s&&void 0!==j(e,this.displayName)&&void 0===j(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),void 0!==s&&(e=this.linked(t,s,e)),[t,e,r]},ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),this.checkLinkChange(t,e),[t,e,r,s]),ldaprenamerequest:async([t,e,s])=>{const o=movedInto(this.groupPattern,t,e);if(void 0===o)return[t,e,s];let i;try{const{searchEntries:e}=await this.ldap.system.search({paged:!1,scope:"base",attributes:["member"],[L]:!0},t);i=e[0]?.member}catch(t){if(32!==a(t))throw t}if(void 0!==this.foreign(o,i))throw new r(`The group holds members of another organization than ${o}`);return[t,e,s]},ldaprenamedone:async(t,e)=>{const r=movedInto(this.groupPattern,t,e),s=this.config.ldap_organization_link_attribute;if(void 0===r||!s||!this.orgPattern||!this.judgedByAttachment())return;const{searchEntries:o}=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e);void 0!==j(o[0]??{},s)&&await this.ldap.system.modify(e,{replace:{[s]:withOrg(this.orgPattern,r)}})},ldapsearchfilter:async([t,e,r])=>[r?.[L]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??=new w(this.config).groupBase(r),[this.groupPattern.test(c(s).join(","))?{...e,id:t()}:e,r,s])};afterLoad(){if(!this.server.loadedPlugins.scim)return;const t=O(this.config),e=t.scim_group_rdn_attribute,r=t.scim_group_mapping,s=y(_,r?b(r):void 0).entries.find(t=>t.ldap?.toLowerCase()===e.toLowerCase());s&&this.logger.warn(`${this.name}: the SCIM group mapping writes ${s.scim} to ${e}, so SCIM groups keep it as their RDN instead of a generated cn: map ${s.scim} to another attribute with --scim-group-mapping`)}linked(t,e,s){const o=this.config.ldap_organization_link_attribute;if(!o||!this.orgPattern||!this.judgedByAttachment())return s;const i=withOrg(this.orgPattern,e),a=read(s,o);if(void 0!==a){if(dnKey(a)!==dnKey(i))throw new r(`${t} lies in the branch of ${e}, and must be linked to it`);return s}const n=values(s.objectClass);return n.some(t=>D.includes(t.toLowerCase()))||n.push("twakeGroup"),{...s,objectClass:n,[o]:i}}checkLinkChange(t,e){const s=this.config.ldap_organization_link_attribute,o=this.organizationOf(t);if(void 0===o||!s||!this.orgPattern||!this.judgedByAttachment())return;const i=dnKey(withOrg(this.orgPattern,o)),named=t=>Object.keys(t??{}).filter(t=>t.toLowerCase()===s.toLowerCase()),a=[...named(e.add).flatMap(t=>values(e.add[t])),...named(e.replace).flatMap(t=>values(e.replace[t]))],n=Array.isArray(e.delete)?e.delete.some(t=>t.toLowerCase()===s.toLowerCase()):named(e.delete).length>0,c=named(e.replace).length>0&&0===a.length;if(n||c||a.some(t=>dnKey(t)!==i))throw new r(`${t} lies in the branch of ${o}, and must stay linked to it`)}judgedByAttachment(){return Object.values(this.server.loadedPlugins).some(t=>t.roles?.includes("authz")&&!0===t.judgesByAttachment?.())}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return withOrg(this.groupBase,t)}userBaseOf(t){return withOrg(this.userBase,t)}checkMembers(t,e){const s=this.organizationOf(t);if(!s)return;const o=this.foreign(s,e);if(void 0!==o)throw new r(`${o} is not a user of organization ${s}`)}foreign(t,e){const r=dnKey(this.userBaseOf(t));return values(e).find(t=>!h(t,this.config.group_dummy_user)&&dnKey(parentOf(t))!==r)}async hideTombstones(t){if(!this.attrs.deleted)return t;const e=new Set;for(const r of t.searchEntries){const t=this.organizationOf(r.dn);t&&void 0!==r.member&&e.add(t)}if(0===e.size)return t;const r=new Set;for(const t of e)for(const e of await this.tombstonesOf(t))r.add(dnKey(e));return 0===r.size?t:{...t,searchEntries:t.searchEntries.map(t=>void 0!==t.member&&this.organizationOf(t.dn)?{...t,member:values(t.member).filter(t=>!r.has(dnKey(t)))}:t)}}async tombstonesOf(t){const{deleted:e,deletedValue:r}=this.attrs;if(!e)return[];try{const{searchEntries:s}=await this.ldap.system.search({paged:!1,scope:"one",filter:`(${e}=${u(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===a(t))return[];throw t}}api(t){const e=`${this.config.api_prefix}/v1/organizations/:id/groups`,r=this.organizationRoute.bind(this),s=r("listing groups",(t,e,r)=>this.listRoute(t,e,r));n(t,{method:"get",path:e,params:S,handler:({req:t,res:e})=>s(t,e)});const o=r("creating group",(t,e,r)=>this.createRoute(t,e,r));n(t,{method:"post",path:e,params:S,handler:({req:t,res:e})=>o(t,e)});const i=r("getting group",async(t,e,r)=>{const s=await this.readGroup(r,t.params.groupId,t);if(!s)throw notFound("group");e.json(s)});n(t,{method:"get",path:`${e}/:groupId`,params:x,handler:({req:t,res:e})=>i(t,e)});const a=r("listing group members",(t,e,r)=>this.membersRoute(t,e,r));n(t,{method:"get",path:`${e}/:groupId/members`,params:x,handler:({req:t,res:e})=>a(t,e)});const c=r("updating group",(t,e,r)=>this.updateRoute(t,e,r));n(t,{method:"patch",path:`${e}/:groupId`,params:x,handler:({req:t,res:e})=>c(t,e)});const h=r("deleting group",async(t,e,r)=>{await this.onGroup(()=>this.deleteGroup(this.groupDn(r,t.params.groupId),t)),e.json({success:!0})});n(t,{method:"delete",path:`${e}/:groupId`,params:x,handler:({req:t,res:e})=>h(t,e)});const u=r("adding group members",(t,e,r)=>this.addMembersRoute(t,e,r));n(t,{method:"post",path:`${e}/:groupId/members`,params:x,handler:({req:t,res:e})=>u(t,e)});const d=r("removing group member",(t,e,r)=>this.removeMemberRoute(t,e,r));n(t,{method:"delete",path:`${e}/:groupId/members/:userId`,params:G,handler:({req:t,res:e})=>d(t,e)})}organizationRoute(t,e,r=this.name){return async(a,n)=>{try{const t=a.params.id;await this.checkOrganization(t,a),await e(a,n,t)}catch(e){if(e instanceof RouteError)return void n.status(e.status).json({error:e.message,code:e.code});if(s(e))return void n.status(403).json({error:o,code:"REFUSED"});if(e instanceof i&&e.statusCode<500)return void n.status(e.statusCode).json({error:e.message,code:400===e.statusCode?"INVALID_INPUT":"REFUSED"});this.logger.error({plugin:r,context:t,error:String(e)}),n.status(500).json({error:`Error ${t}`,code:"INTERNAL_ERROR"})}}}groupDn(t,e){return`${this.cn}=${d(e)},${this.groupBaseOf(t)}`}get userAttribute(){return this.config.ldap_user_main_attribute||"uid"}userDn(t,e){return`${this.userAttribute}=${d(e)},${this.userBaseOf(t)}`}async checkOrganization(t,e){if(!this.orgPattern)return;const r=this.options(k),s=r.twake_group_organization_status_attribute,o=withOrg(this.orgPattern,t);if(!await this.ldap.readableBy(o,e))throw notFound("organization");let i;try{i=(await this.ldap.system.search({paged:!1,scope:"base",attributes:[s]},o)).searchEntries[0]}catch(t){if(32!==a(t))throw t}if(!i)throw notFound("organization");const n=r.twake_group_organization_deleted_value;if(read(i,s)===n)throw new RouteError(410,"Organization has been deleted","ORGANIZATION_DELETED")}page(t,e){const r=Math.max(1,parseInt(t.query.page)||1),s=Math.max(1,Math.min(parseInt(t.query.limit)||20,this.maxPage)),o=t.query.search||void 0;if(void 0!==o&&("string"!=typeof o||o.length<2))throw invalid("Search query must be at least 2 characters","INVALID_SEARCH_QUERY");const i=t.query.sortBy;if(void 0!==i&&!e.includes(i))throw invalid(`sortBy must be one of: ${e.join(", ")}`,"INVALID_SORT_FIELD");return{page:r,limit:s,offset:(r-1)*s,search:o,sortBy:i,desc:"desc"===t.query.sortOrder}}sorted(t,e,r){if(!e)return t;const s=r?-1:1;return t.sort((t,r)=>text(t[e]).localeCompare(text(r[e]))*s)}groupOf(t,e){const r=read(e,this.cn)||"",s=dnKey(this.userBaseOf(t)),o=values(e.member).filter(t=>!h(t,this.config.group_dummy_user)).map(t=>dnKey(parentOf(t))===s?p(t):t);return{id:r,cn:r,displayName:read(e,this.displayName)??r,description:read(e,"description"),color:read(e,this.color),organizationId:t,baseDN:this.groupDn(t,r),members:o,createdAt:read(e,this.createdAt)}}async groups(t,e,r,s){try{const{searchEntries:o}=await this.ldap.search({paged:!1,scope:"one",filter:e,...r&&{attributes:r}},this.groupBaseOf(t),s);return o}catch(t){if(32===a(t))return[];throw t}}async readGroup(t,e,r){try{const{searchEntries:s}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e),r);return s[0]&&this.groupOf(t,s[0])}catch(t){if(32===a(t)||s(t))return;throw t}}async named(t,e){const[r]=await this.groups(t,`(${this.displayName}=${u(e)})`);return r&&read(r,this.cn)}async onGroup(t){try{return await t()}catch(t){if(32===a(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,R);let o=`(${this.cn}=*)`;if(s.search){const t=u(s.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const i=this.sorted((await this.groups(r,o,void 0,t)).map(t=>this.groupOf(r,t)),s.sortBy,s.desc);e.json({organizationId:r,groups:i.slice(s.offset,s.offset+s.limit),pagination:{page:s.page,limit:s.limit,total:i.length,totalPages:Math.ceil(i.length/s.limit)}})}async createRoute(e,r,s){const o=bodyOf(e);if(!isDisplayName(o.name))throw invalid(E);if(void 0!==o.description&&"string"!=typeof o.description)throw invalid("description must be a string");if(void 0!==o.color&&!isColor(o.color))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB)");const i=t(),a={objectClass:this.config.group_class||["top","groupOfNames"],[this.displayName]:o.name,[this.createdAt]:(new Date).toISOString()};o.description&&(a.description=o.description),o.color&&(a[this.color]=o.color);const n=this.linked(this.groupDn(s,i),s,a);await this.ldap.authorize("ldapaddrequest",[this.groupDn(s,i),this.candidateGroup(i,[],n),e]);const c=o.name;if(await v(()=>this.named(s,c)))throw new RouteError(409,"Group already exists","GROUP_EXISTS");await this.addGroup(this.groupDn(s,i),[],n,e);const h=await v(()=>this.readGroup(s,i));r.status(201).json(h)}async updateRoute(t,e,r){const s=bodyOf(t),o=t.params.groupId,i=Object.keys(s).filter(t=>!["name","description","color"].includes(t));if(i.length)throw invalid(`Only "name", "description" and "color" can be updated; unsupported field(s): ${i.join(", ")}`);const{name:a,description:n,color:c}=s;if(void 0===a&&void 0===n&&void 0===c)throw invalid('at least one of "name", "description" or "color" must be provided');if(void 0!==a&&!isDisplayName(a))throw invalid(E);if(void 0!==n&&"string"!=typeof n)throw invalid("description must be a string");if(void 0!==c&&""!==c&&!isColor(c))throw invalid("color must be a hex code (e.g. #RRGGBB or #RGB) or empty to clear it");const h={};if(void 0!==a&&(h[this.displayName]=a),void 0!==n&&(h.description=n||[]),void 0!==c&&(h[this.color]=c||[]),await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,o),{replace:h},this.opNumber(),t]),void 0!==a){const t=await v(()=>this.named(r,a));if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS")}await this.onGroup(()=>this.modifyGroup(this.groupDn(r,o),{replace:h},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,P),o=t.params.groupId,i=await this.readGroup(r,o,t);if(!i)throw notFound("group");e.json({organizationId:r,id:o,...await this.memberPage(s,r,i.members,void 0,t)})}async memberPage(t,e,r,s=()=>({}),o){const i=[...new Map(r.map(t=>[t.toLowerCase(),t])).values()],a=await this.profiles(e,i,o);let n=i.map(t=>({...a.get(t.toLowerCase())??{uid:t},...s(t)}));if(t.search){const e=t.search.toLowerCase();n=n.filter(t=>[t.uid,t.displayName||t.cn,t.mail].some(t=>text(t).toLowerCase().includes(e)))}n=this.sorted(n,t.sortBy??"uid",t.desc);const c=Math.ceil(n.length/t.limit);return{members:n.slice(t.offset,t.offset+t.limit),pagination:{page:t.page,limit:t.limit,total:n.length,totalPages:c,hasNextPage:t.page<c,hasPreviousPage:t.page>1}}}async users(t,e,r,o){const{deleted:i,deletedValue:n}=this.attrs,c=i?`(!(${i}=${u(n)}))`:"",h=[];for(let i=0;i<e.length;i+=this.maxPage){const n=`(&(|${e.slice(i,i+this.maxPage).map(t=>`(${this.userAttribute}=${u(t)})`).join("")})${c})`;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"one",filter:n,attributes:r},this.userBaseOf(t),o);h.push(...e)}catch(t){if(32!==a(t)&&!s(t))throw t}}return h}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(A),C])],o=new Map;for(const i of await this.users(t,e,s,r)){const t=read(i,this.userAttribute);if(!t)continue;const e={};for(const[t,r]of Object.entries(this.memberFields)){const s=read(i,r);void 0!==s&&(e[t]=s)}const r={};for(const[t,e]of Object.entries(A)){const s=read(i,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(i,C)?.toUpperCase(),o.set(t.toLowerCase(),e)}return o}async addMembersRoute(t,e,r){const{usernames:s}=bodyOf(t);if(!Array.isArray(s)||0===s.length)throw invalid("usernames must be a non-empty array");if(!s.every(t=>"string"==typeof t&&t.length>0))throw invalid("usernames must be an array of non-empty strings");if(s.length>this.maxPage)throw invalid(`usernames cannot exceed ${this.maxPage} entries per request`);const o=t.params.groupId,i=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()];await this.ldap.authorize("ldapmodifyrequest",[this.groupDn(r,o),{add:{member:i.map(t=>this.userDn(r,t))}},this.opNumber(),t]);const a=await this.readGroup(r,o,t);if(!a)throw notFound("group");const n=new Map((await this.users(r,i,[this.userAttribute],t)).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(i.some(t=>!n.has(t)))throw notFound("user");const c=new Set(a.members.map(t=>t.toLowerCase())),h=i.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),u=this.groupDn(r,o);h.length&&(await this.onGroup(()=>this.ldap.modify(u,{add:{member:h}},t)),await this.dropPlaceholder(u,t)),e.json({success:!0})}async dropPlaceholder(t,e){const r=this.config.group_dummy_user;if(r)try{await this.ldap.modify(t,{delete:{member:r}},e)}catch(t){if(![16,65].includes(a(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),o=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:o}},t)}catch(e){const r=a(e);if(32===r)throw notFound("group");if(16===r)throw notFound("member");if(65!==r||!this.config.group_dummy_user)throw e;await this.ldap.modify(s,{replace:{member:[this.config.group_dummy_user]}},t)}e.json({success:!0})}}export{E as NAME_RULE,N as ORG,G as OrganizationGroupMemberParams,x as OrganizationGroupParams,RouteError,S as TwakeOrganizationParams,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,read,values,withOrg};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as e}from"../../lib/errors.js";import{jsonBody as a}from"../../lib/expressFormatedResponses.js";import{CozyStackProvider as n,ClouderyProvider as s,matrixId as r,instanceSlug as o}from"../../lib/instanceProviders.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{rdnValue as h,asyncHandler as d,escapeLdapFilter as l}from"../../lib/utils.js";import{lifecycleAttributes as u,isTombstone as g}from"./lifecycleAttributes.js";const first=t=>{const i=Array.isArray(t)?t[0]:t;return(null==i?"":i.toString())||void 0},w=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/;class TwakeInstances extends t{constructor(t){super(t),this.name="twakeInstances",this.roles=["consistency","api"],this.dependencies={rabbitmq:"core/rabbitmq"},this.offers=[],this.queues=new Map,this.announcing=new Map,this.hooks={ldapaddrequest:([t,i,e])=>{if(this.patterns.some(i=>i.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const e of Object.keys(i))t.includes(e.toLowerCase())&&delete i[e]}return[t,i,e]},ldapaddafter:async([t,i])=>{const e=this.account(t,i);e&&(await this.serialized(t,async()=>{try{await this.provide(t,i,e)}catch(i){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(i)})}}),e.orgId&&await this.announcing.get(e.orgId.toLowerCase()))},ldapdeletedone:async t=>{const i=this.provider;if(i instanceof n)for(const e of Array.isArray(t)?t:[t]){const t=this.match(e),a=/^uid=/i.test(e)?h(e):"";t&&a&&await this.serialized(e,async()=>{try{await i.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:e,error:String(t)})}})}}};const i=this.config,e=(i.plugin||[]).find(t=>w.test(t));if(e)throw new Error(`${this.name}: ${e} gives accounts their instance too; load one of them`);this.patterns=(i.twake_instance_dn||[]).map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,i]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(i);for(const[,t]of(i.twake_instance_id||"{uid}").matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=u(i),this.fqdnAttribute=i.twake_instance_fqdn_attribute||"twakeWorkspaceUrl",this.sentAttribute=i.twake_instance_sent_attribute||"twakeCreatedEventAt";const r=i.twake_instance_timeout||3e4;if("cozy-stack"===i.twake_instance_provider){if(!i.twake_instance_cozy_url||!i.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(i.twake_instance_cozy_url.replace(/\/$/,""),i.twake_instance_cozy_user||"admin",i.twake_instance_cozy_passphrase||"",i.twake_instance_cozy_domain,i.twake_instance_cozy_context||"",i.twake_instance_cozy_apps||"",r),this.locale=i.twake_instance_locale||"fr"}else{if("cloudery"!==i.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!i.twake_instance_cloudery_url||!i.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=(i.twake_instance_cloudery_offer||"").split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new s(i.twake_instance_cloudery_url.replace(/\/$/,""),i.twake_instance_cloudery_token||"",i.twake_instance_cloudery_domain,t.length>1?"":t[0],r),this.locale=i.twake_instance_locale||"en"}}}async api(t){const n=this.requirePlugin("rabbitmq");if(!n||!await n.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const r=await this.server.ldap.schemaIndex(),o=[this.fqdnAttribute,this.sentAttribute,...this.config.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!r.getAttributeType(t));if(o.length)throw new Error(`${this.name}: the directory schema defines no ${o.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);t.post(`${this.config.api_prefix}/v1/twake/instances/ensure`,d(async(t,n)=>{const s=a(t,n);if(!s)return;let r=s.dn;if(void 0===r&&"string"==typeof s.mail&&s.mail){const e=await this.findByMail(s.mail,t);if(!e)throw new i(`No account for ${s.mail}`);r=e.dn}if("string"!=typeof r||!r)throw new e("Give the account as dn or mail");n.json({state:await this.ensureInstance(r,t)})})),this.provider instanceof s&&await n.subscribe(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_workplace_created_key||"workplace.created",this.config.twake_instance_queue||"workplace.created.ldap-rest",t=>this.onWorkplaceCreated(t))}async ensureInstance(t,a){let n;const s=await this.serialized(t,async()=>{const s=await this.read(t,a);if(!s)throw new i(`No entry at ${t}`);const r=this.account(t,s);if(!r)throw new e(`${t} gets no instance`);if(n=r.orgId,first(this.value(s,this.sentAttribute)))return"ready";a&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},a);const o=first(this.value(s,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(r)?{fqdn:o,ready:!0}:await this.provider.find(r,o);if(i?.ready)return await this.ready(t,s,r,i.fqdn,a),"ready"}return this.provide(t,s,r,a)});return n&&await this.announcing.get(n.toLowerCase()),s}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.system.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),r=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return r?.ready?await this.organizationReady(e,r.fqdn):await this.releaseMembers(e.dn),"ready";let o=r?.ready&&r.fqdn;return r||(this.provider instanceof s&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.config.twake_instance_cloudery_organization_offer,organization:!0}}async requestOrganization(t,i){if(!(this.provider instanceof s))return;const e=await this.findOrganization(t);if(!e||first(this.value(e,this.sentAttribute)))return;const a=first(this.value(e,this.organizationName)),n=first(this.value(e,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const r=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(r);if(o){const s=t.toLowerCase();if(!o.ready||this.announcing.has(s))return;return i&&await this.server.ldap.modify(e.dn,{},i),void(this.announcing.has(s)||this.announcing.set(s,this.ensureOrganization({id:t,name:a,domain:n}).catch(i=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(i)})}).finally(()=>this.announcing.delete(s))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(r)}catch(t){if(!await this.provider.find(r))throw t}}async onWorkplaceCreated(t){const{internalEmail:i,workplaceFqdn:e}=t;if(!i||!e)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),s=first(this.value(a,this.organizationDomain)),r=i.toLowerCase();if(s?this.organizationMail(n,s)===r:r.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,e)})}const n=await this.findByMail(i);n?await this.settle(n.dn,e):this.logger.debug(`${this.name}: no account here for ${i}`)}async settle(t,i){await this.serialized(t,async()=>{const e=await this.read(t),a=e&&this.account(t,e);e&&a&&(first(this.value(e,this.sentAttribute))||await this.ready(t,e,a,i))})}async provide(t,i,e,a){e.orgId&&await this.requestOrganization(e.orgId,a);const n=await this.provider.find(e),s=n?n.ready&&n.fqdn:await this.provider.create(e);return s?(await this.ready(t,i,e,s,a),"ready"):"pending"}async ready(t,i,e,a,n){const o={declaredClasses:this.config.user_class};if(first(this.value(i,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,o),first(this.value(i,this.sentAttribute)))return;const c=this.provider instanceof s&&e.orgId&&await this.findOrganization(e.orgId);if(c&&!first(this.value(c,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${c.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${e.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${e.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}const h=r(e.email);await this.announce(this.config.twake_instance_auth_exchange||"auth",this.config.twake_instance_user_created_key||"user.created",{twakeId:e.twakeId,internalEmail:e.email,workplaceFqdn:a,...h?{matrixId:h}:{},...e.orgId?{organizationId:e.orgId}:{},...e.orgDomain?{domain:e.orgDomain,organizationDomain:e.orgDomain}:{},...e.phone?{mobile:e.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n,o)}async organizationReady(t,i){const e=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof s&&await this.provider.linkOrganization(a,i),first(this.value(t,this.sentAttribute)))return this.releaseMembers(e);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.system.modify(e,{replace:{[this.organizationFqdn]:i}});const n=first(this.value(t,this.organizationDomain)),r=first(this.value(t,this.organizationName));await this.announce(this.config.twake_instance_b2b_exchange||"b2b",this.config.twake_instance_organization_created_key||"organization.created",{organizationId:a,workplaceFqdn:i,...r?{organization:r}:{},...n?{domain:n}:{}}),await this.server.ldap.system.modify(e,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(e)}async releaseMembers(t){const i=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let e=0;for(const t of i.searchEntries)try{await this.release(t)}catch(i){e++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(i)}`)}if(e)throw new Error(`${this.name}: ${e} member(s) of ${t} not released`)}async release(t){const i=t.dn,e=this.account(i,t),a=first(this.value(t,this.fqdnAttribute));if(!e||!a)return;const n=a.toLowerCase()===this.provider.address(e)?{fqdn:a,ready:!0}:await this.provider.find(e,a);if(n?.ready)return this.settle(i,n.fqdn);this.logger.warn(`${this.name}: ${i} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,i){const e=t.toLowerCase(),a=(this.queues.get(e)??Promise.resolve()).catch(()=>{}).then(i),n=a.catch(()=>{});return this.queues.set(e,n),n.then(()=>{this.queues.get(e)===n&&this.queues.delete(e)}),a}get organizationId(){return this.config.twake_instance_organization_id_attribute||"ou"}get organizationDomain(){return this.config.twake_instance_organization_domain_attribute||"twakeDomain"}get organizationName(){return this.config.twake_instance_organization_name_attribute||"description"}get organizationFqdn(){return this.config.twake_instance_organization_fqdn_attribute||""}organizationMail(t,i){return`${t}@${i}`.toLowerCase()}isOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===i.toLowerCase()}async findOrganization(t){const i=this.config.twake_instance_organization_base;if(!i)return;return(await this.server.ldap.system.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${l(t)})`},i)).searchEntries[0]}async read(t,i){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,i)).searchEntries[0]}catch(i){const a=c(i);if(32===a)return;if(34===a)throw new e(`Invalid DN: ${t}`);throw i}}async announce(t,i,e){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${i} not sent`);await a.publish(t,i,e),this.logger.info({plugin:this.name,event:i,exchange:t})}account(t,i){const e=this.match(t);if(!e)return;if(g(i,this.lifecycle))return;const a=this.config.twake_instance_skip_attribute;if(a&&first(this.value(i,a))?.toLowerCase()===(this.config.twake_instance_skip_value||"").toLowerCase())return;const s=first(this.value(i,"mail")),r=first(this.value(i,"uid"));if(!s||!r)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=e.groups?.org||(o?this.config.twake_instance_cozy_org_id:"")||void 0,h=e.groups?.org?s.split("@")[1]:o&&this.config.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(i=>i.test(t))],l=this.id(r,e);if(l)return{id:l,...o?{oidc:r}:{},twakeId:r,email:s,publicName:first(this.value(i,"displayName"))||first(this.value(i,"cn"))||r,locale:first(this.value(i,"preferredLanguage"))||this.locale,phone:first(this.value(i,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(i=>i.exec(t)).find(t=>null!==t)}id(t,i){const e={uid:t,...i.groups};return o((this.config.twake_instance_id||"{uid}").replace(/\{(\w+)\}/g,(t,i)=>e[i]??""))}value(t,i){const e=i.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===e);return a?t[a]:void 0}async findByMail(t,i){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${l(t)})`},this.config.ldap_base,i)).searchEntries.find(t=>this.patterns.some(i=>i.test(t.dn)))}}export{TwakeInstances as default};
|
|
1
|
+
import{Type as t}from"@sinclair/typebox";import e from"../../abstract/plugin.js";import{NotFoundError as i,BadRequestError as a}from"../../lib/errors.js";import{CozyStackProvider as n,ClouderyProvider as s,matrixId as r,instanceSlug as o}from"../../lib/instanceProviders.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{rdnValue as h,escapeLdapFilter as d}from"../../lib/utils.js";import{lifecycleAttributes as l,isTombstone as u}from"./lifecycleAttributes.js";import{twakeInstancesOptions as g}from"./twakeInstances.options.js";import{defineRoute as w}from"../../lib/route.js";const first=t=>{const e=Array.isArray(t)?t[0]:t;return(null==e?"":e.toString())||void 0},f=/(^|\/)twake\/(cozy|cloudery)Provision(\.js)?$/,m=t.Object({dn:t.Optional(t.String()),mail:t.Optional(t.String())}),p=t.Object({state:t.Union([t.Literal("ready"),t.Literal("pending")])});class TwakeInstances extends e{static optionDefinitions=g;name="twakeInstances";roles=["consistency","api"];dependencies={rabbitmq:"core/rabbitmq"};settings;patterns;provider;fqdnAttribute;sentAttribute;locale;lifecycle;offers=[];queues=new Map;announcing=new Map;constructor(t){super(t);const e=this.options(g);this.settings=e;const i=(t.config.plugin||[]).find(t=>f.test(t));if(i)throw new Error(`${this.name}: ${i} gives accounts their instance too; load one of them`);this.patterns=e.twake_instance_dn.map(t=>new RegExp(t,"i"));const a=new Set(["uid"]);for(const t of this.patterns)for(const[,e]of t.source.matchAll(/\(\?<(\w+)>/g))a.add(e);for(const[,t]of e.twake_instance_id.matchAll(/\{(\w+)\}/g))if(!a.has(t))throw new Error(`${this.name}: --twake-instance-id names {${t}}, which no --twake-instance-dn group captures`);this.lifecycle=l(t.config),this.fqdnAttribute=e.twake_instance_fqdn_attribute,this.sentAttribute=e.twake_instance_sent_attribute;const r=e.twake_instance_timeout;if("cozy-stack"===e.twake_instance_provider){if(!e.twake_instance_cozy_url||!e.twake_instance_cozy_domain)throw new Error(`${this.name}: --twake-instance-cozy-url and --twake-instance-cozy-domain are required`);this.provider=new n(e.twake_instance_cozy_url.replace(/\/$/,""),e.twake_instance_cozy_user,e.twake_instance_cozy_passphrase,e.twake_instance_cozy_domain,e.twake_instance_cozy_context,e.twake_instance_cozy_apps,r),this.locale=e.twake_instance_locale||"fr"}else{if("cloudery"!==e.twake_instance_provider)throw new Error(`${this.name}: --twake-instance-provider must be cloudery or cozy-stack`);{if(!e.twake_instance_cloudery_url||!e.twake_instance_cloudery_domain)throw new Error(`${this.name}: --twake-instance-cloudery-url and --twake-instance-cloudery-domain are required`);const t=e.twake_instance_cloudery_offer.split(";").map(t=>t.trim());if(t.length>1&&""===t[t.length-1]&&t.pop(),t.length>1&&t.length>this.patterns.length)throw new Error(`${this.name}: --twake-instance-cloudery-offer has more offers than --twake-instance-dn has expressions`);t.length>1&&(this.offers=t),this.provider=new s(e.twake_instance_cloudery_url.replace(/\/$/,""),e.twake_instance_cloudery_token,e.twake_instance_cloudery_domain,t.length>1?"":t[0],r),this.locale=e.twake_instance_locale||"en"}}}hooks={ldapaddrequest:([t,e,i])=>{if(this.patterns.some(e=>e.test(t))||this.isOrganization(t)){const t=[this.fqdnAttribute,this.sentAttribute,this.organizationFqdn].map(t=>t.toLowerCase());for(const i of Object.keys(e))t.includes(i.toLowerCase())&&delete e[i]}return[t,e,i]},ldapaddafter:async(t,e)=>{const i=this.account(t,e);i&&(await this.serialized(t,async()=>{try{await this.provide(t,e,i)}catch(e){this.logger.error({plugin:this.name,event:"create",dn:t,error:String(e)})}}),i.orgId&&await this.announcing.get(i.orgId.toLowerCase()))},ldapdeletedone:async t=>{const e=this.provider;if(e instanceof n)for(const i of Array.isArray(t)?t:[t]){const t=this.match(i),a=/^uid=/i.test(i)?h(i):"";t&&a&&await this.serialized(i,async()=>{try{await e.destroy(this.id(a,t),a)}catch(t){this.logger.error({plugin:this.name,event:"destroy",dn:i,error:String(t)})}})}}};async api(t){const e=this.requirePlugin("rabbitmq");if(!e||!await e.getRawClient())throw new Error(`${this.name}: RabbitMQ is required and unreachable`);const n=await this.server.ldap.schemaIndex(),r=[this.fqdnAttribute,this.sentAttribute,...this.settings.twake_instance_organization_base?[this.organizationDomain,this.organizationFqdn,this.organizationName]:[]].filter(t=>t&&!n.getAttributeType(t));if(r.length)throw new Error(`${this.name}: the directory schema defines no ${r.join(", ")}; load a schema that does, or name other attributes`);if(this.organizationFqdn&&this.organizationFqdn.toLowerCase()===this.organizationName.toLowerCase())throw new Error(`${this.name}: an organization's name and address cannot share the attribute ${this.organizationName}`);w(t,{method:"post",path:`${this.config.api_prefix}/v1/twake/instances/ensure`,body:m,responses:{200:p},handler:async({req:t,res:e,body:n})=>{let s=n.dn;if(void 0===s&&n.mail){const e=await this.findByMail(n.mail,t);if(!e)throw new i(`No account for ${n.mail}`);s=e.dn}if(!s)throw new a("Give the account as dn or mail");e.json({state:await this.ensureInstance(s,t)})}}),this.provider instanceof s&&await e.subscribe(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_workplace_created_key,this.settings.twake_instance_queue,t=>this.onWorkplaceCreated(t))}async ensureInstance(t,e){let n;const s=await this.serialized(t,async()=>{const s=await this.read(t,e);if(!s)throw new i(`No entry at ${t}`);const r=this.account(t,s);if(!r)throw new a(`${t} gets no instance`);if(n=r.orgId,first(this.value(s,this.sentAttribute)))return"ready";e&&await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:[]}},e);const o=first(this.value(s,this.fqdnAttribute));if(o){const i=o.toLowerCase()===this.provider.address(r)?{fqdn:o,ready:!0}:await this.provider.find(r,o);if(i?.ready)return await this.ready(t,s,r,i.fqdn,e),"ready"}return this.provide(t,s,r,e)});return n&&await this.announcing.get(n.toLowerCase()),s}async ensureOrganization(t){return this.serialized(`organization:${t.id}`,async()=>{const e=await this.findOrganization(t.id);if(!e)throw new i(`No organization ${t.id}`);t.name&&!first(this.value(e,this.organizationName))&&(await this.server.ldap.system.modify(e.dn,{replace:{[this.organizationName]:t.name}}),e[this.organizationName]=t.name);const a=this.organizationFqdn&&first(this.value(e,this.organizationFqdn)),n=this.organizationRequest(t),r=a?{fqdn:a,ready:!0}:await this.provider.find(n);if(first(this.value(e,this.sentAttribute)))return r?.ready?await this.organizationReady(e,r.fqdn):await this.releaseMembers(e.dn),"ready";let o=r?.ready&&r.fqdn;return r||(this.provider instanceof s&&await this.provider.createOrganization(t),o=await this.provider.create(n)),o?(await this.organizationReady(e,o),"ready"):"pending"})}organizationRequest(t){return{id:t.id,email:this.organizationMail(t.id,t.domain),publicName:t.name,locale:this.locale,orgId:t.id,orgDomain:t.domain,offer:this.settings.twake_instance_cloudery_organization_offer,organization:!0}}async requestOrganization(t,e){if(!(this.provider instanceof s))return;const i=await this.findOrganization(t);if(!i||first(this.value(i,this.sentAttribute)))return;const a=first(this.value(i,this.organizationName)),n=first(this.value(i,this.organizationDomain));if(!a||!n)return void this.logger.warn(`${this.name}: organization ${t} has no ${this.organizationName} or ${this.organizationDomain}, so its instance cannot be asked for`);const r=this.organizationRequest({id:t,name:a,domain:n}),o=await this.provider.find(r);if(o){const s=t.toLowerCase();if(!o.ready||this.announcing.has(s))return;return e&&await this.server.ldap.modify(i.dn,{},e),void(this.announcing.has(s)||this.announcing.set(s,this.ensureOrganization({id:t,name:a,domain:n}).catch(e=>{this.logger.error({plugin:this.name,event:"organization",id:t,error:String(e)})}).finally(()=>this.announcing.delete(s))))}await this.provider.createOrganization({id:t,name:a,domain:n});try{await this.provider.create(r)}catch(t){if(!await this.provider.find(r))throw t}}async onWorkplaceCreated(t){const{internalEmail:e,workplaceFqdn:i}=t;if(!e||!i)return;const a=t.twakeId&&await this.findOrganization(t.twakeId);if(a){const n=t.twakeId.toLowerCase(),s=first(this.value(a,this.organizationDomain)),r=e.toLowerCase();if(s?this.organizationMail(n,s)===r:r.startsWith(`${n}@`))return this.serialized(`organization:${t.twakeId}`,async()=>{const t=await this.read(a.dn);t&&await this.organizationReady(t,i)})}const n=await this.findByMail(e);n?await this.settle(n.dn,i):this.logger.debug(`${this.name}: no account here for ${e}`)}async settle(t,e){await this.serialized(t,async()=>{const i=await this.read(t),a=i&&this.account(t,i);i&&a&&(first(this.value(i,this.sentAttribute))||await this.ready(t,i,a,e))})}async provide(t,e,i,a){i.orgId&&await this.requestOrganization(i.orgId,a);const n=await this.provider.find(i),s=n?n.ready&&n.fqdn:await this.provider.create(i);return s?(await this.ready(t,e,i,s,a),"ready"):"pending"}async ready(t,e,i,a,n){const o={declaredClasses:this.config.user_class};if(first(this.value(e,this.fqdnAttribute))!==a&&await this.server.ldap.modify(t,{replace:{[this.fqdnAttribute]:a}},n,o),first(this.value(e,this.sentAttribute)))return;const c=this.provider instanceof s&&i.orgId&&await this.findOrganization(i.orgId);if(c&&!first(this.value(c,this.sentAttribute))){if(t.toLowerCase().endsWith(`,${c.dn.toLowerCase()}`))return void this.logger.info(`${this.name}: ${t} waits for organization ${i.orgId}`);this.logger.warn(`${this.name}: ${t} is not below its organization entry, so it is announced before organization ${i.orgId} is linked; clear its ${this.sentAttribute} and send it again once the organization is announced`)}const h=r(i.email);await this.announce(this.settings.twake_instance_auth_exchange,this.settings.twake_instance_user_created_key,{twakeId:i.twakeId,internalEmail:i.email,workplaceFqdn:a,...h?{matrixId:h}:{},...i.orgId?{organizationId:i.orgId}:{},...i.orgDomain?{domain:i.orgDomain,organizationDomain:i.orgDomain}:{},...i.phone?{mobile:i.phone}:{}}),await this.server.ldap.modify(t,{replace:{[this.sentAttribute]:(new Date).toISOString()}},n,o)}async organizationReady(t,e){const i=t.dn,a=first(this.value(t,this.organizationId));if(a&&this.provider instanceof s&&await this.provider.linkOrganization(a,e),first(this.value(t,this.sentAttribute)))return this.releaseMembers(i);this.organizationFqdn&&!first(this.value(t,this.organizationFqdn))&&await this.server.ldap.system.modify(i,{replace:{[this.organizationFqdn]:e}});const n=first(this.value(t,this.organizationDomain)),r=first(this.value(t,this.organizationName));await this.announce(this.settings.twake_instance_b2b_exchange,this.settings.twake_instance_organization_created_key,{organizationId:a,workplaceFqdn:e,...r?{organization:r}:{},...n?{domain:n}:{}}),await this.server.ldap.system.modify(i,{replace:{[this.sentAttribute]:(new Date).toISOString()}}),await this.releaseMembers(i)}async releaseMembers(t){const e=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(&(${this.fqdnAttribute}=*)(!(${this.sentAttribute}=*)))`},t);let i=0;for(const t of e.searchEntries)try{await this.release(t)}catch(e){i++,this.logger.error(`${this.name}: ${t.dn} not released: ${String(e)}`)}if(i)throw new Error(`${this.name}: ${i} member(s) of ${t} not released`)}async release(t){const e=t.dn,i=this.account(e,t),a=first(this.value(t,this.fqdnAttribute));if(!i||!a)return;const n=a.toLowerCase()===this.provider.address(i)?{fqdn:a,ready:!0}:await this.provider.find(i,a);if(n?.ready)return this.settle(e,n.fqdn);this.logger.warn(`${this.name}: ${e} stays held, its address ${a} is not confirmed; ensureInstance sends it once it is`)}async serialized(t,e){const i=t.toLowerCase(),a=(this.queues.get(i)??Promise.resolve()).catch(()=>{}).then(e),n=a.catch(()=>{});return this.queues.set(i,n),n.then(()=>{this.queues.get(i)===n&&this.queues.delete(i)}),a}get organizationId(){return this.settings.twake_instance_organization_id_attribute}get organizationDomain(){return this.settings.twake_instance_organization_domain_attribute}get organizationName(){return this.settings.twake_instance_organization_name_attribute}get organizationFqdn(){return this.settings.twake_instance_organization_fqdn_attribute}organizationMail(t,e){return`${t}@${e}`.toLowerCase()}isOrganization(t){const e=this.settings.twake_instance_organization_base;if(!e)return!1;return t.slice(t.indexOf(",")+1).toLowerCase()===e.toLowerCase()}async findOrganization(t){const e=this.settings.twake_instance_organization_base;if(!e)return;return(await this.server.ldap.system.search({paged:!1,scope:"one",filter:`(${this.organizationId}=${d(t)})`},e)).searchEntries[0]}async read(t,e){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t,e)).searchEntries[0]}catch(e){const i=c(e);if(32===i)return;if(34===i)throw new a(`Invalid DN: ${t}`);throw e}}async announce(t,e,i){const a=this.requirePlugin("rabbitmq");if(!a||!await a.getRawClient())throw new Error(`${this.name}: RabbitMQ unreachable, ${e} not sent`);await a.publish(t,e,i),this.logger.info({plugin:this.name,event:e,exchange:t})}account(t,e){const i=this.match(t);if(!i)return;if(u(e,this.lifecycle))return;const a=this.settings.twake_instance_skip_attribute;if(a&&first(this.value(e,a))?.toLowerCase()===this.settings.twake_instance_skip_value.toLowerCase())return;const s=first(this.value(e,"mail")),r=first(this.value(e,"uid"));if(!s||!r)return void this.logger.warn(`${this.name}: ${t} has no mail or uid, no instance`);const o=this.provider instanceof n,c=i.groups?.org||(o?this.settings.twake_instance_cozy_org_id:"")||void 0,h=i.groups?.org?s.split("@")[1]:o&&this.settings.twake_instance_cozy_org_domain||void 0,d=this.offers[this.patterns.findIndex(e=>e.test(t))],l=this.id(r,i);if(l)return{id:l,...o?{oidc:r}:{},twakeId:r,email:s,publicName:first(this.value(e,"displayName"))||first(this.value(e,"cn"))||r,locale:first(this.value(e,"preferredLanguage"))||this.locale,phone:first(this.value(e,"mobile")),...c?{orgId:c}:{},...h?{orgDomain:h}:{},...d?{offer:d}:{}};this.logger.warn(`${this.name}: ${t} gives no instance slug, no instance`)}match(t){return this.patterns.map(e=>e.exec(t)).find(t=>null!==t)}id(t,e){const i={uid:t,...e.groups};return o(this.settings.twake_instance_id.replace(/\{(\w+)\}/g,(t,e)=>i[e]??""))}value(t,e){const i=e.toLowerCase(),a=Object.keys(t).find(t=>t.toLowerCase()===i);return a?t[a]:void 0}async findByMail(t,e){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(mail=${d(t)})`},this.config.ldap_base,e)).searchEntries.find(t=>this.patterns.some(e=>e.test(t.dn)))}}export{m as EnsureInstanceBody,p as EnsureInstanceResponse,TwakeInstances as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import"node-fetch";import"../../lib/metrics.js";import{roleAttribute as e}from"../../config/schema.js";import{asyncHandler as a,isDnInBranch as i,parseDn as s,escapeLdapFilter as r,normalizeDn as n,isDummyMemberDn as o}from"../../lib/utils.js";import{ConflictError as l}from"../../lib/errors.js";import{extractLdapCode as d}from"../../lib/ldapCodes.js";import{currentOperation as m}from"../../lib/operation.js";import{wantJson as h}from"../../lib/expressFormatedResponses.js";import{domainBranches as u,mailDomains as c}from"../../lib/mailDomains.js";const g=[400,404],p=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return n(t)}catch{return t.toLowerCase()}},f=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>f.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return n(t)===n(e)}catch{return!1}};class James extends t{health(){return this.webadminHealth()}constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api","configurable"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async([t,e])=>{void 0===m()&&await this.followMove(t,e)},ldapadddone:([t,e])=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},g);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},g))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},g)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},g)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.config.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},g))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}},this.domainQueue=new Map,this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,a((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!h(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=`${this.webadminUrl}/quota/users/${r}`,o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}domainAttribute(t){let e,a=-1;for(const{base:r,attribute:n}of u(this.server))try{if(sameDn(t,r)||!i(t,r))continue;const o=s(r).length;o>a&&([e,a]=[n,o])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?c(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of u(this.server)){const a=t.length?t.find(t=>{try{return i(e,t)||i(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},p);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}async firstEntry(t,e,a,i){try{const s=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of s)for(const e of t.searchEntries||[])if(i(e))return e;return null}catch(t){if(32===d(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of u(this.server)){if(await this.firstEntry(a,`(${i}=${r(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const a=this.domainAttribute(t);if(!a)return;const i=await this.ldapGetAttributes(t,[a]),s=c(valuesOf(i??void 0,a));if(!s.length)return;const r=await this.domainLinkHolder(t,String(i.dn??t));if(r)throw this.logger.warn(`${e} of ${t} refused: organization ${r} links it`),new l(`Cannot ${e} ${t}: an organization still links mail domain ${s.join(", ")}`);for(const a of s)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=c(valuesOf(s,i));for(const e of c(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=c(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of c(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,a=this.server.loadedPlugins.ldapFlatGeneric,i=[t?.schema,...(a?.instances||[]).map(t=>t.schema)].map(t=>e(t,"domainLink"));i.push(this.config.enterprise_domain_link_attribute);const s=[];for(const t of i)t&&!s.some(e=>sameAttr(e,t))&&s.push(t);return s}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,n(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${r(e)})`)).join("")})`,o=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return o?String(o.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,a){if(await this.domainDeclaredElsewhere(e,t))return;const i=await this.addressAt(e);if(i)throw this.logger.warn(`${a}: ${i} still has an address at ${e}`),new l(`${a}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.config.james_mailing_list_branch||[];if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},g)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},g)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}getConfigApiData(){return{teamMailboxAnyDomain:Boolean(this.config.james_team_mailbox_any_domain)}}teamMailboxDomain(t,e){if(this.config.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.config.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,a,i=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[s,r]=await Promise.all([foreign(a),foreign(i)]);if(0===s.length&&0===r.length)return;const n=s.length?`${s.slice(0,5).join(", ")}${s.length>5?", …":""} `+(s.length>1?"are":"is")+" not":"",o=r.length?"some of the members it holds are not":"";throw new l(`The members of team mailbox ${t} must have an address at ${e}: `+[n,o].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${e}/identities`,o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,g,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const d=await this.generateSignature(t),m=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};d?m.htmlSignature=d:Object.assign(m,i);const h=l?.id?"update_identity":"create_identity",u=await this.requestLimit(()=>this.fetch(l?.id?`${n}/${l.id}`:n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));if(u.ok)this.logger.info({...s,step:h,result:"success",http_status:u.status});else{if(this.shouldRetry(u.status,g,r)){await this.warnAndWait(s,h,r,u);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:u.status,http_status_text:u.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/users/${t}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.config.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/address/forwards/${e}`,{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(`${this.webadminUrl}/users/${t}`);i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!o(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),d=l.filter(t=>!o.includes(t)),m=o.filter(t=>!l.includes(t)),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,p]=await Promise.all([Promise.all(h),Promise.all(u)]),f=[];for(const{dn:e,email:a}of c)a&&f.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},g));for(const{dn:e,email:a}of p)a&&f.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(f)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
|
|
1
|
+
import{Type as t}from"@sinclair/typebox";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{ConflictError as a}from"../../lib/errors.js";import{extractLdapCode as i}from"../../lib/ldapCodes.js";import{currentOperation as s}from"../../lib/operation.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";import{roleAttribute as n}from"../../config/schema.js";import{domainBranches as o,mailDomains as l}from"../../lib/mailDomains.js";import{isDnInBranch as m,parseDn as d,escapeLdapFilter as u,normalizeDn as h,isDummyMemberDn as c}from"../../lib/utils.js";import{jamesOptions as g,jamesOptionsOf as p}from"./james.options.js";import{defineRoute as f}from"../../lib/route.js";const b=[400,404],w=[500,502,503,504],pickDefaultIdentity=t=>(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0],members=t=>valuesOf(t??void 0,"member")??[],sameAttr=(t,e)=>t.split(";")[0].toLowerCase()===e.split(";")[0].toLowerCase(),valuesOf=(t,e)=>{if(!t)return;const a=Object.keys(t).filter(t=>sameAttr(t,e));return a.length?a.flatMap(e=>{return null==(a=t[e])?[]:(Array.isArray(a)?a:[a]).map(String);var a}):void 0},valuesAfter=(t,e,a,i)=>{let s=valuesOf(t,a)??[];const r=valuesOf(e.add,a);r&&(s=[...s,...r]);const n=valuesOf(e.replace,a);n&&(s=n);const o=e.delete;if(Array.isArray(o))o.some(t=>sameAttr(t,a))&&(s=[]);else{const t=valuesOf(o,a);if(t){const e=new Set(t.map(i));s=e.size?s.filter(t=>!e.has(i(t))):[]}}return s},memberKey=t=>{try{return h(t)}catch{return t.toLowerCase()}},y=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>y.includes(String(t).toLowerCase())),sameDn=(t,e)=>{try{return h(t)===h(e)}catch{return!1}},A=t.Object({user:t.String({minLength:1})});class James extends e{static optionDefinitions=g;name="james";roles=["consistency","api","configurable"];dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"};health(){return this.webadminHealth()}quotaAttr;aliasAttr;mailboxTypeAttr;forwardAttr;initDelay;constructor(t){const e=p(t.config);super(t,{url:e.james_webadmin_url,token:e.james_webadmin_token,concurrency:e.james_concurrency,urlOption:"james_webadmin_url",tokenOption:"james_webadmin_token",concurrencyOption:"james_concurrency"}),this.quotaAttr=this.config.quota_attribute||"",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=e.james_mailbox_type_attribute,this.forwardAttr=this.config.forward_attribute||"mailForwardingAddress",this.initDelay=e.james_init_delay,this.retryAttempts=Math.max(1,e.james_retry_attempts),this.retryDelay=Math.max(0,e.james_retry_delay)}get settings(){return this.options(g)}api(t){const e=this.config.api_prefix||"/api";f(t,{method:"get",path:`${e}/v1/users/:user/quota-usage`,params:A,handler:async({req:t,res:e})=>this.getUserQuota(t,e)}),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(t,e){if(!r(t,e))return;const a=t.params.user;try{const i=await this.findReadableUser(a,t,[this.mailAttr]);if(!i)return void e.status(404).json({error:`User ${a} not found`});const s=i[this.mailAttr];if(!s)return void e.status(400).json({error:`User ${a} has no mail attribute`});const r=Array.isArray(s)?String(s[0]):String(s),n=`${this.webadminUrl}/quota/users/${r}`,o=await this.requestLimit(()=>this.fetch(n,{method:"GET",headers:this.createHeaders()}));if(!o.ok)return void(404===o.status?e.status(404).json({error:`Quota not found for user ${a}`}):e.status(502).json({error:`Failed to retrieve quota from James: ${o.statusText}`}));const l=await o.json();e.json(l)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:a,error:t instanceof Error?t.message:String(t)}),e.status(500).json({error:"Internal server error while retrieving quota"})}}hooks={ldapaddrequest:async([t,e,a])=>{const i=this.teamMailboxDomain(valuesOf(e,this.mailAttr),valuesOf(e,this.mailboxTypeAttr));return i&&await this.assertMembersAt(t,i,valuesOf(e,"member")??[]),void 0!==a?[t,e,a]:[t,e]},ldapmodifyrequest:async([t,e,a,i])=>(await this.guardTeamMailboxChange(t,e),await this.guardDomainChange(t,e),[t,e,a,i]),ldapdeleterequest:async([t,e])=>{for(const e of Array.isArray(t)?t:[t])e&&await this.guardDomainRemoval(e,"delete");return[t,e]},ldaprenamerequest:async([t,e,a])=>(await this.guardDomainMove(t,e),[t,e,a]),ldaprenamedone:async(t,e)=>{void 0===s()&&await this.followMove(t,e)},ldapadddone:(t,e)=>this.provisionAccount(t,e),onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(!i)return void this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`);if(!s)return void this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);if(await this.isGroupDn(t))return;const r=await this.defaultSignatures(i),n=await this.ldapGetAttributes(t,[this.forwardAttr,this.displayNameAttr,"cn","givenName","sn",this.mailAttr]);await this.dropStaleForwards(t,s,this.ownForwards(n?.[this.forwardAttr])),await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},b);try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},b))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}const o=n&&this.getDisplayNameFromAttributes(n);o&&await this.updateJamesIdentity(t,s,o,r)},onLdapAliasChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...o.map(async a=>{await this.dropStaleForwards(t,a,[]),await this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},b)})])},onLdapQuotaChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=i>0;await this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,s?"PUT":"DELETE",t,s?i.toString():null,{oldQuota:a,newQuota:i},b)},onLdapForwardChange:async(t,e,a,i)=>{if(!this.settings.james_sync_forwards)return;if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},b))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:async(t,e,a)=>{e&&a&&!e[this.mailAttr]&&a[this.mailAttr]&&await this.provisionAccount(t,a),await this.followTeamMailbox(e,a),await this.followDomains(e,a)}};domainAttribute(t){let e,a=-1;for(const{base:i,attribute:s}of o(this.server))try{if(sameDn(t,i)||!m(t,i))continue;const r=d(i).length;r>a&&([e,a]=[s,r])}catch{}return e}domainsOf(t){const e=t?.dn&&this.domainAttribute(String(t.dn));return e?l(valuesOf(t,e)):[]}afterLoad(){if(!this.server.loadedPlugins.trash)return;const t=String(this.config.trash_watched_bases??"").split(";").map(t=>t.trim()).filter(Boolean);for(const{base:e}of o(this.server)){const a=t.length?t.find(t=>{try{return m(e,t)||m(t,e)}catch{return!1}}):"every branch";a&&this.logger.warn(`James: the trash watches the mail domains of ${e} (--trash-watched-bases: ${a}): a domain deleted there may skip the check that it is still in use; leave the domain branch out of the trash, see docs/usage/plugins/integrations/james-mail.md#trash`)}}async followDomains(t,e,a=!1){const i=this.domainsOf(t),s=this.domainsOf(e);if(!i.length&&!s.length)return;const r=String((e??t).dn),call=(t,e)=>this.callWebAdminApi("PUT"===e?"domain:create":"domain:delete",`${this.webadminUrl}/domains/${t}`,e,r,null,{domain:t},w);await Promise.all([...s.filter(t=>!i.includes(t)).map(t=>this.inDomainOrder(t,()=>call(t,"PUT"))),...i.filter(t=>!s.includes(t)).map(t=>this.inDomainOrder(t,async()=>{if(await this.domainDeclaredElsewhere(t,r))return;const e=a?await this.addressAt(t):null;e?this.logger.error({plugin:this.name,event:"domain:delete",dn:r,domain:t,result:"skipped",error:`${e} still has an address at ${t}: the domain is kept in James`}):await call(t,"DELETE")}))])}async followMove(t,e){const a=[this.domainAttribute(t),this.domainAttribute(e)].filter(t=>Boolean(t)).filter((t,e,a)=>a.findIndex(e=>sameAttr(e,t))===e);if(!a.length)return;const i=await this.ldapGetAttributes(e,a);i&&await this.followDomains({...i,dn:t},{...i,dn:e},!0)}async inDomainOrder(t,e){const run=async()=>{await e()},a=(this.domainQueue.get(t)??Promise.resolve()).then(run,run);this.domainQueue.set(t,a);try{await a}finally{this.domainQueue.get(t)===a&&this.domainQueue.delete(t)}}domainQueue=new Map;async firstEntry(t,e,a,s){try{const i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"sub",filter:e,attributes:a},t);for await(const t of i)for(const e of t.searchEntries||[])if(s(e))return e;return null}catch(t){if(32===i(t))return null;throw t}}async domainDeclaredElsewhere(t,e){try{for(const{base:a,attribute:i}of o(this.server)){if(await this.firstEntry(a,`(${i}=${u(t)})`,["1.1"],t=>{const a=String(t.dn),s=this.domainAttribute(a);return!sameDn(a,e)&&!!s&&sameAttr(s,i)}))return!0}return!1}catch(a){return this.logger.error({plugin:this.name,event:"domain:delete",dn:e,domain:t,result:"skipped",error:a instanceof Error?a.message:String(a)}),!0}}async guardDomainRemoval(t,e){const i=this.domainAttribute(t);if(!i)return;const s=await this.ldapGetAttributes(t,[i]),r=l(valuesOf(s??void 0,i));if(!r.length)return;const n=await this.domainLinkHolder(t,String(s.dn??t));if(n)throw this.logger.warn(`${e} of ${t} refused: organization ${n} links it`),new a(`Cannot ${e} ${t}: an organization still links mail domain ${r.join(", ")}`);for(const a of r)await this.assertNoAddressAt(t,a,`Cannot ${e} ${t}`)}async guardDomainMove(t,e){const a=this.domainAttribute(t);if(!a)return;const i=this.domainAttribute(e);if(!i)return this.guardDomainRemoval(t,"move");const s=await this.ldapGetAttributes(t,sameAttr(a,i)?[a]:[a,i])??void 0,r=l(valuesOf(s,i));for(const e of l(valuesOf(s,a)))r.includes(e)||await this.assertNoAddressAt(t,e,`Cannot move ${t}`)}async guardDomainChange(t,e){const a=this.domainAttribute(t);if(!a||!((t,e)=>[t.add,t.replace].some(t=>t&&Object.keys(t).some(t=>sameAttr(t,e)))||(Array.isArray(t.delete)?t.delete.some(t=>sameAttr(t,e)):Object.keys(t.delete??{}).some(t=>sameAttr(t,e))))(e,a))return;const i=await this.ldapGetAttributes(t,[a]);if(!i)return;const s=l(valuesAfter(i,e,a,t=>t.toLowerCase()));for(const e of l(valuesOf(i,a)))s.includes(e)||await this.assertNoAddressAt(t,e,`Cannot remove mail domain ${e} from ${t}`)}domainLinkAttributes(){const t=this.server.loadedPlugins.ldapOrganizations,e=this.server.loadedPlugins.ldapFlatGeneric,a=[t?.schema,...(e?.instances||[]).map(t=>t.schema)].map(t=>n(t,"domainLink"));a.push(this.config.enterprise_domain_link_attribute);const i=[];for(const t of a)t&&!i.some(e=>sameAttr(e,t))&&i.push(t);return i}async domainLinkHolder(t,e){const a=this.domainLinkAttributes();if(!a.length)return null;const i=[...new Set([t,e,h(t)])],s=`(|${a.flatMap(t=>i.map(e=>`(${t}=${u(e)})`)).join("")})`,r=await this.firstEntry(this.config.ldap_base||"",s,a,e=>a.some(a=>(valuesOf(e,a)??[]).some(e=>sameDn(e,t))));return r?String(r.dn):null}async addressAt(t){const e=[this.mailAttr,this.aliasAttr],atDomain=e=>this.extractMailDomain(this.normalizeAlias(e.trim()))?.toLowerCase()===t,a=await this.firstEntry(this.config.ldap_base||"",`(|${e.map(t=>`(${t}=*)`).join("")})`,e,t=>e.some(e=>(valuesOf(t,e)??[]).some(atDomain)));return a?String(a.dn):null}async assertNoAddressAt(t,e,i){if(await this.domainDeclaredElsewhere(e,t))return;const s=await this.addressAt(e);if(s)throw this.logger.warn(`${i}: ${s} still has an address at ${e}`),new a(`${i}: addresses at ${e} still exist, and James would refuse their mail`)}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t[this.mailAttr]);if(!e)return null;const a=String(t.dn),i=this.settings.james_mailing_list_branch;if(i.some(t=>a===t||a.endsWith(","+t)))return{mail:e,refused:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`};const s=e.lastIndexOf("@");return/^[#a-zA-Z0-9_-]{1,255}$/.test(e.slice(0,s))&&this.extractMailDomain(e)?{mail:e,refused:null}:{mail:e,refused:"A team mailbox address is a name of letters, digits, #, - and _ at a domain"}}async provisionAccount(t,e){const a=e[this.mailAttr],i=e[this.quotaAttr],s=e[this.aliasAttr];if(!a||isGroup(e.objectClass))return;const r=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),i){const e=Array.isArray(i)?Number(i[0]):Number(i);!isNaN(e)&&e>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${r}/size`,"PUT",t,e.toString(),{mail:r,quota:e},b)}const n=this.getAliases(s);await Promise.all([this.dropStaleForwards(t,r,this.ownForwards(e[this.forwardAttr])),...n.map(e=>this.dropStaleForwards(t,e,[]))]),n.length>0&&await Promise.all(n.map(e=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${r}/sources/${e}`,"PUT",t,null,{mail:r,alias:e},b)));const o=this.getDisplayNameFromAttributes(e);o&&await this.updateJamesIdentity(t,r,o)}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&i.refused!==a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,mail:i.mail,error:i.refused});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}teamMailboxQueue=new Map;getConfigApiData(){return{teamMailboxAnyDomain:this.settings.james_team_mailbox_any_domain}}teamMailboxDomain(t,e){if(this.settings.james_team_mailbox_any_domain||"teamMailbox"!==this.getMailboxType({[this.mailboxTypeAttr]:e}))return null;const a=this.attributeToString(t);return a&&this.extractMailDomain(a)?.toLowerCase()||null}async guardTeamMailboxChange(t,e){if(this.settings.james_team_mailbox_any_domain)return;const a=[this.mailAttr,this.mailboxTypeAttr,"member"];if(![e.add,e.replace].some(t=>t&&Object.keys(t).some(t=>a.some(e=>sameAttr(t,e)))))return;const i=await this.ldapGetAttributes(t,a);if(!i)return;const after=(t,a=t=>t.toLowerCase())=>valuesAfter(i,e,t,a),s=this.teamMailboxDomain(after(this.mailAttr),after(this.mailboxTypeAttr));if(!s)return;const r=new Set((valuesOf(i,"member")??[]).map(memberKey)),n=after("member",memberKey),o=n.filter(t=>!r.has(memberKey(t))),l=s!==this.teamMailboxDomain(valuesOf(i,this.mailAttr),valuesOf(i,this.mailboxTypeAttr));await this.assertMembersAt(t,s,o,l?n.filter(t=>r.has(memberKey(t))):[])}async assertMembersAt(t,e,i,s=[]){const foreign=async t=>(await this.getMemberEmails(t)).filter(t=>this.extractMailDomain(t)?.toLowerCase()!==e),[r,n]=await Promise.all([foreign(i),foreign(s)]);if(0===r.length&&0===n.length)return;const o=r.length?`${r.slice(0,5).join(", ")}${r.length>5?", …":""} `+(r.length>1?"are":"is")+" not":"",l=n.length?"some of the members it holds are not":"";throw new a(`The members of team mailbox ${t} must have an address at ${e}: `+[o,l].filter(Boolean).join(", and "))}teamMailboxUrl(t){const e=t.toLowerCase(),a=e.lastIndexOf("@");return`${this.webadminUrl}/domains/${e.slice(a+1)}/team-mailboxes/${encodeURIComponent(e.slice(0,a))}`}async teamMailboxMembers(t,e,a,i){if(!i.length)return;const s=this.teamMailboxUrl(e),r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${s}/members/${i}`,a,t,null,{teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.settings.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.hasOwn(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a,i={}){const s=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a,i));this.identityQueue.set(e,s);try{await s}finally{this.identityQueue.get(e)===s&&this.identityQueue.delete(e)}}identityQueue=new Map;async doUpdateJamesIdentity(t,e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${e}/identities`,o=await this.requestLimit(()=>this.fetch(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(o.ok)l=pickDefaultIdentity(await o.json());else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,b,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const m=await this.generateSignature(t),d=l?.id?{id:l.id,email:l.email,name:a}:{email:e,name:a,sortOrder:0};m?d.htmlSignature=m:Object.assign(d,i);const u=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>this.fetch(l?.id?`${n}/${l.id}`:n,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(d)}));if(h.ok)this.logger.info({...s,step:u,result:"success",http_status:h.status});else{if(this.shouldRetry(h.status,b,r)){await this.warnAndWait(s,u,r,h);continue}this.logger.error({...s,step:u,...r>1?{attempts:r}:{},http_status:h.status,http_status_text:h.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async defaultSignatures(t){try{const e=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/users/${t}/identities?default=true`,{method:"GET",headers:this.createHeaders()}));if(!e.ok)return{};const a=pickDefaultIdentity(await e.json()),i={};return a?.htmlSignature&&(i.htmlSignature=a.htmlSignature),a?.textSignature&&(i.textSignature=a.textSignature),i}catch(e){return this.logger.debug(`Could not read the identities of ${t}: ${e}`),{}}}ownForwards(t){return this.settings.james_sync_forwards?this.getAliases(t):[]}async dropStaleForwards(t,e,a){let i;try{const s=await this.requestLimit(()=>this.fetch(`${this.webadminUrl}/address/forwards/${e}`,{method:"GET",headers:this.createHeaders()}));if(!s.ok)return void(404!==s.status&&this.logger.warn({plugin:this.name,event:"dropStaleForward",result:"error",dn:t,address:e,step:"list_forwards",http_status:s.status,http_status_text:s.statusText}));const r=a.map(t=>t.toLowerCase());i=(await s.json()).map(t=>t.mailAddress).filter(t=>t&&!r.includes(t.toLowerCase()))}catch(t){return void this.logger.debug(`Could not read the forwards of ${e}: ${t}`)}await Promise.all(i.map(a=>this.callWebAdminApi("dropStaleForward",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{address:e,forward:a})))}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(t,e){const a={plugin:this.name,event:"deleteUserData",mail:t,fromStep:e};try{const i=new URL(`${this.webadminUrl}/users/${t}`);i.searchParams.set("action","deleteData"),e&&i.searchParams.set("fromStep",e);const s=await this.requestLimit(()=>this.fetch(i.toString(),{method:"POST",headers:this.createHeaders()}));if(!s.ok)return this.logger.error({...a,http_status:s.status,http_status_text:s.statusText}),null;const r=await s.json();return this.logger.info({...a,http_status:s.status,taskId:r.taskId}),r}catch(t){return this.logger.error({...a,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=Array.isArray(e)?String(e[0]):String(e),i=/^\s*cn\s*=\s*([^,]+)/i.exec(a);if(!i)return null;return{group:"group",mailinglist:"mailingList",teammailbox:"teamMailbox"}[i[1].trim().toLowerCase()]??null}async createTeamMailbox(t,e,a){const i=this.teamMailboxUrl(a);await this.callWebAdminApi("teamMailbox:create",i,"PUT",t,null,{teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))}async getMemberEmails(t){const e=t.filter(t=>!c(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],o=this._normalizeToArray(r),l=this._normalizeToArray(n),m=l.filter(t=>!o.includes(t)),d=o.filter(t=>!l.includes(t)),u=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),h=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,g]=await Promise.all([Promise.all(u),Promise.all(h)]),p=[];for(const{dn:e,email:a}of c)a&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},b));for(const{dn:e,email:a}of g)a&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{A as QuotaParams,James as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{defineOptions as e,opt as _,readOptions as a}from"../../config/options.js";const n=e({james_webadmin_url:_.string({env:"DM_JAMES_WEBADMIN_URL",default:"http://localhost:8000"}),james_webadmin_token:_.string({env:"DM_JAMES_WEBADMIN_TOKEN",default:""}),james_signature_template:_.string({env:"DM_JAMES_SIGNATURE_TEMPLATE",default:""}),james_concurrency:_.number({env:"DM_JAMES_CONCURRENCY",default:10,min:1}),james_init_delay:_.number({env:"DM_JAMES_INIT_DELAY",default:1e3}),james_retry_attempts:_.number({env:"DM_JAMES_RETRY_ATTEMPTS",default:5}),james_retry_delay:_.number({env:"DM_JAMES_RETRY_DELAY",default:1e3}),james_mailing_list_branch:_.array({env:"DM_JAMES_MAILING_LIST_BRANCHES",default:[],plural:"james-mailing-list-branches",kind:"dns"}),james_mailbox_type_attribute:_.string({env:"DM_JAMES_MAILBOX_TYPE_ATTRIBUTE",default:"twakeMailboxType"}),james_sync_forwards:_.boolean({env:"DM_JAMES_SYNC_FORWARDS",default:!1}),james_team_mailbox_any_domain:_.boolean({env:"DM_JAMES_TEAM_MAILBOX_ANY_DOMAIN",default:!1})});function jamesOptionsOf(e){return a(n,e)}export{n as jamesOptions,jamesOptionsOf};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{DEFAULT_LOCK_ATTRIBUTE as e,resolveLockConfig as t,holdsValue as r}from"../scim/mapping.js";function lifecycleAttributes(r){const l=
|
|
1
|
+
import{DEFAULT_LOCK_ATTRIBUTE as e,resolveLockConfig as t,holdsValue as r}from"../scim/mapping.js";import{scimOptionsOf as l}from"../scim/scim.options.js";import{twakeLifecycleOptionsOf as i}from"./twakeLifecycle.options.js";function lifecycleAttributes(r){const o=i(r),c=l(r),a=o.twake_lifecycle_deleted_at_format;if("iso8601"!==a&&"generalizedTime"!==a)throw new Error(`--twake-lifecycle-deleted-at-format must be iso8601 or generalizedTime, got '${a}'`);const n=o.twake_lifecycle_lock_attribute.trim(),u=c.scim_user_lock_attribute.trim()||e,f=t(n||u,o.twake_lifecycle_lock_value.trim()||(n&&n.toLowerCase()!==u.toLowerCase()?"":c.scim_user_lock_value),{attribute:"--twake-lifecycle-lock-attribute",value:"--twake-lifecycle-lock-value"});return{role:o.twake_lifecycle_role_attribute,lock:f.attribute,lockValue:f.value,deleted:o.twake_lifecycle_deleted_attribute,deletedValue:o.twake_lifecycle_deleted_value,deletedAt:o.twake_lifecycle_deleted_at_attribute,deletedAtFormat:a,reason:o.twake_lifecycle_reason_attribute}}function valueOf(e,t){if(!t)return;if(t in e)return e[t];const r=t.toLowerCase();for(const t of Object.keys(e))if(t.toLowerCase()===r)return e[t]}function first(e){const t=Array.isArray(e)?e[0]:e;if(null==t)return;const r=Buffer.isBuffer(t)?t.toString():String(t);return""===r?void 0:r}const isTombstone=(e,t)=>r(e,t.deleted,t.deletedValue),isLocked=(e,t)=>r(e,t.lock,t.lockValue);function formatDeletedAt(e,t){const r=e.toISOString();return"iso8601"===t?r:r.replace(/[-:T]/g,"").replace(/\.\d+Z$/,"Z")}function parseDeletedAt(e,t){if("iso8601"===t){const t=new Date(e);return isNaN(t.getTime())?void 0:t}const r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:[.,](\d+))?Z$/.exec(e);if(r)return new Date(Date.UTC(+r[1],+r[2]-1,+r[3],+r[4],+r[5],+(r[6]||0),Math.round(1e3*+`0.${r[7]||0}`)))}export{first,formatDeletedAt,isLocked,isTombstone,lifecycleAttributes,parseDeletedAt,valueOf};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";const
|
|
1
|
+
import{randomUUID as e}from"node:crypto";import t from"node:fs";import r from"../../abstract/plugin.js";import{extractLdapCode as s}from"../../lib/ldapCodes.js";import{unescapeDnValue as i,isDummyMemberDn as n,parseDn as o,normalizeDn as a}from"../../lib/utils.js";import{DEFAULT_LOCK_ATTRIBUTE as c,DEFAULT_LOCK_VALUE as l}from"../scim/mapping.js";import{lifecycleAttributes as h,isTombstone as u,isLocked as d,valueOf as f,first as m,parseDeletedAt as b}from"./lifecycleAttributes.js";import{twakeLifecycleOptions as g}from"./twakeLifecycle.options.js";const p=["created","roleChanged","disabled","enabled","updated","memberAdded","memberRemoved","deleted"],y=["$members","$added","$removed"];const isObject=e=>"object"==typeof e&&null!==e&&!Array.isArray(e);function optionalString(e,t,r){if(void 0!==e&&"string"!=typeof e)throw new Error(`${t}: "${r}" must be a string`)}const isStrings=e=>isObject(e)&&Object.values(e).every(e=>"string"==typeof e);function optionalCondition(e,t,r){if(void 0!==e){if(!isStrings(e))throw new Error(`${t}: "${r}" must be an object of strings`);return e}}const w=/^(?:[^$].*|\$(?!now$)[A-Za-z][A-Za-z0-9-]*)$/;function memberList(e){if(!isObject(e))return;const t=Object.entries(e);if(1!==t.length)return;const[r,s]=t[0];return y.includes(r)&&isStrings(s)&&Object.values(s).every(e=>w.test(e))?{list:r,fields:s}:void 0}function optionalPayload(e,t,r){if(void 0===e)return;const s=isObject(e)?Object.entries(e):void 0,i={};for(const[e,t]of s||[]){const r="string"==typeof t?t:memberList(t);if(void 0===r)break;i[e]=r}if(!s||Object.keys(i).length!==s.length)throw new Error(`${t}: "${r}" must be an object of strings, or of ${y.join(", ")} each mapped to an object of member attributes ($attr) or plain values`);return i}function dnKey(e){try{return a(e)}catch{return e.toLowerCase()}}function ruleAttributes(e){const t=/^\$(?:previous\.|changed\.)?([A-Za-z][A-Za-z0-9-]*)(?:\|domain)?$/;return e.flatMap(e=>Object.values(e.targets).flat()).flatMap(e=>[...Object.values(e.payload),...Object.keys(e.when)]).flatMap(e=>"string"==typeof e&&t.exec(e)?.[1]||[]).filter(e=>"now"!==e)}function parseRules(e){if(!e.trim())return[];const r=/^\s*[[{]/.test(e)?e:t.readFileSync(e,"utf8"),s=JSON.parse(r);if(!Array.isArray(s))throw new Error("--twake-lifecycle-rules must be a JSON array of rules");return s.map(e=>{if(!isObject(e))throw new Error("Every lifecycle rule must be an object");if("string"!=typeof e.dn)throw new Error('Every lifecycle rule needs a "dn" pattern');const{dn:t}=e,r=`Lifecycle rule ${t}`;optionalString(e.exchange,r,"exchange");const s=optionalPayload(e.payload,r,"payload");if(void 0!==e.events&&!isObject(e.events))throw new Error(`${r}: "events" must be an object`);const i={};for(const[r,n]of Object.entries(e.events||{})){if(!p.includes(r))throw new Error(`Unknown lifecycle event "${r}"; known: ${p.join(", ")}`);const o=`Lifecycle event "${r}" of ${t}`;i[r]=(Array.isArray(n)?n:[n]).map(t=>{if("string"!=typeof t&&!isObject(t))throw new Error(`${o}: a target is a routing key or an object`);const r="string"==typeof t?{routingKey:t}:t;optionalString(r.routingKey,o,"routingKey"),optionalString(r.exchange,o,"exchange");const i=r.exchange||e.exchange,n=r.routingKey;if(!i||!n)throw new Error(`${o} needs an exchange and a routing key`);return{exchange:i,routingKey:n,payload:optionalPayload(r.payload,o,"payload")||s||{},when:optionalCondition(r.when,o,"when")||{}}})}return{dn:new RegExp(t,"i"),targets:i}})}class TwakeLifecycleEvents extends r{static optionDefinitions=g;name="twakeLifecycleEvents";roles=["consistency"];dependencies={onLdapChange:"core/ldap/onChange",rabbitmq:"core/rabbitmq"};rules;attrs;memberAttribute;constructor(e){super(e);const t=this.options(g);if(this.attrs=h(this.config),this.memberAttribute=t.twake_lifecycle_member_attribute,this.rules=parseRules(t.twake_lifecycle_rules),this.followedOperationalAttributes=[this.attrs.lock,...ruleAttributes(this.rules)],0===this.rules.length)this.logger.warn(`${this.name}: --twake-lifecycle-rules is empty, nothing will be published`);else if(!this.config.rabbitmq_url)throw new Error(`${this.name}: --twake-lifecycle-rules needs --rabbitmq-url`)}async assertComposition(){if(0!==this.rules.length&&!await(this.requirePlugin("rabbitmq")?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`)}afterLoad(){const e=this.server.loadedPlugins.scim?.config;if(!e)return;const t=e.scim_user_lock_attribute?.trim()||c,r=e.scim_user_lock_value?.trim()||l,{lock:s,lockValue:i}=this.attrs;t.toLowerCase()===s.toLowerCase()&&r.toLowerCase()===i.toLowerCase()||this.logger.warn(`${this.name}: SCIM locks an account with ${t}: ${r}, these events read ${s}: ${i}, so a SCIM deactivation publishes no disabled`)}hooks={onLdapEntryChange:(e,t,r,s)=>this.onEntryChange(e,t,r,s),twakedeletionreplay:(e,t,r)=>this.replay(e,t,r)};async replay(e,t,r){const s=this.match(e);s&&u(t,this.attrs)&&await this.publish(s.rule,"deleted",{before:t,after:t,dn:s.groups,change:r})}match(e){const t=function(e){return o(e).map(e=>{const t=e.indexOf("=");if(-1===t)return e;const r=e.slice(t+1).trim();return`${e.slice(0,t).trim()}=${r.replace(/\\,/g,"\\2C")}`}).join(",")}(e);for(const e of this.rules){const r=e.dn.exec(t);if(!r)continue;const s={};for(const[e,t]of Object.entries(r.groups||{}))void 0!==t&&(s[e]=i(t));return{rule:e,groups:s}}}async onEntryChange(e,t,r,s){const i=this.match(e);if(!i||t&&u(t,this.attrs))return;const{rule:n}=i,o={before:t||{},after:r||t||{},dn:i.groups,change:s};if(!r)return this.publish(n,"deleted",o);if(u(r,this.attrs))return t?this.publish(n,"deleted",o):void 0;if(!t)return await this.publish(n,"created",o),void(d(r,this.attrs)&&await this.publish(n,"disabled",o));this.attrs.role&&!function(e,t){const set=e=>new Set((void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString().toLowerCase())),r=set(e),s=set(t);return r.size===s.size&&[...r].every(e=>s.has(e))}(f(t,this.attrs.role),f(r,this.attrs.role))&&await this.publish(n,"roleChanged",o);const a=d(r,this.attrs);a!==d(t,this.attrs)&&await this.publish(n,a?"disabled":"enabled",o),await this.publish(n,"updated",o);const c=this.memberDiff(r,t),l=this.memberDiff(t,r);c.length&&await this.publish(n,"memberAdded",{...o,added:c}),l.length&&await this.publish(n,"memberRemoved",{...o,removed:l})}members(e){const t=f(e,this.memberAttribute);return(void 0===t?[]:Array.isArray(t)?t:[t]).map(e=>e.toString()).filter(e=>!n(e,this.config.group_dummy_user))}memberDiff(e,t){const r=new Set(this.members(t).map(dnKey));return this.members(e).filter(e=>!r.has(dnKey(e)))}async memberPayloads({list:e,fields:t},r){const s="$added"===e?r.added||[]:"$removed"===e?r.removed||[]:this.members(r.after),i=Object.values(t).filter(e=>e.startsWith("$")).map(e=>e.slice(1));this.attrs.deleted&&i.push(this.attrs.deleted);const n=[];for(const e of s){const r=await this.readMember(e,i);if(!r||u(r,this.attrs))continue;const s={};for(const[e,i]of Object.entries(t)){const t=i.startsWith("$")?m(f(r,i.slice(1))):i;void 0!==t&&(s[e]=t)}n.push(s)}return n}async readMember(e,t){try{const{searchEntries:r}=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e);if(r[0])return r[0]}catch(e){if(32!==s(e))throw e}const[r]=o(e),n=r?.indexOf("=")??-1;if(-1!==n)return{[r.slice(0,n).trim()]:i(r.slice(n+1).trim())}}resolve(e,t){if(!e.startsWith("$"))return e;if("$now"===e)return(new Date).toISOString();let r=e.slice(1);const s=r.endsWith("|domain");let i;s&&(r=r.slice(0,-7));let n=r;if(r.startsWith("dn."))i=t.dn[r.slice(3)],n="";else if(r.startsWith("context."))i=t.change[r.slice(8)],n="";else if(r.startsWith("previous."))n=r.slice(9),i=m(f(t.before,n));else if(r.startsWith("changed.")){n=r.slice(8);const e=f(t.after,n);(function(e,t){const list=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>e.toString()).sort();return list(e).join("\0")===list(t).join("\0")})(f(t.before,n),e)||(i=m(e)??"")}else i=m(f(t.after,n));return void 0!==i&&this.attrs.deletedAt&&n.toLowerCase()===this.attrs.deletedAt.toLowerCase()&&(i=b(i,this.attrs.deletedAtFormat)?.toISOString()),s&&(i=i?.split("@")[1]),i}async publish(t,r,s){const i=t.targets[r];if(!i?.length)return;const n=this.requirePlugin("rabbitmq");if(n)for(const t of i){if(!Object.entries(t.when).every(([e,t])=>{const r=this.resolve(e,s);return t.startsWith("!")?r!==t.slice(1):r===t}))continue;const i={};let o=!1,a=!1,c=!1;try{for(const[e,r]of Object.entries(t.payload)){if("string"!=typeof r){const t=await this.memberPayloads(r,s);t.length||"$members"===r.list||(c=!0),i[e]=t;continue}const t=this.resolve(r,s);r.startsWith("$changed.")&&(o=!0,void 0!==t&&(a=!0)),void 0!==t&&(i[e]=t)}}catch(e){this.logger.error({plugin:this.name,event:r,routingKey:t.routingKey,result:"members unreadable",error:String(e)});continue}if(c||o&&!a)continue;const l=e(),h={plugin:this.name,event:r,exchange:t.exchange,routingKey:t.routingKey,messageId:l};if(await n.getRawClient())try{await n.publish(t.exchange,t.routingKey,i,{messageId:l}),this.logger.info({...h,result:"published"})}catch(e){this.logger.error({...h,result:"error",error:String(e)})}else this.logger.error({...h,result:"no broker"})}}}export{p as LIFECYCLE_EVENTS,TwakeLifecycleEvents as default,parseRules};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{Type as t}from"@sinclair/typebox";import{TwakePlugin as r}from"../../abstract/twakePlugin.js";import"../../abstract/plugin.js";import{wantJson as e}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as i,normalizeDn as s}from"../../lib/utils.js";import{migrationProxyOptions as a,migrationProxyOptionsOf as o}from"./migrationProxy.options.js";import{defineRoute as n}from"../../lib/route.js";const l=[500,502,503,504],h=t.Object({user:t.String({minLength:1})});class MigrationProxy extends r{static optionDefinitions=a;name="migrationProxy";roles=["consistency","api"];dependencies={onLdapChange:"core/ldap/onChange"};branches;aliasAttr;queues=new Map;constructor(t){const r=o(t.config);if(super(t,{url:r.migration_proxy_url,token:r.migration_proxy_token,concurrency:r.migration_proxy_concurrency,urlOption:"migration_proxy_url",tokenOption:"migration_proxy_token",concurrencyOption:"migration_proxy_concurrency"}),this.branches=r.migration_proxy_branch,!this.branches.length)throw new Error(`${this.name}: --migration-proxy-branch is required`);this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.retryAttempts=Math.max(1,r.migration_proxy_retry_attempts),this.retryDelay=Math.max(0,r.migration_proxy_retry_delay)}health(){return this.webadminHealth()}api(t){const r=this.config.api_prefix||"/api";n(t,{method:"get",path:`${r}/v1/users/:user/mail-migration`,params:h,handler:async({req:t,res:r})=>this.getMigrationStatus(t,r)})}hooks={onLdapEntryChange:(t,r,e)=>{if(this.branches.some(r=>i(t,r)))return this.inOrder(t,()=>this.follow(t,r,e))}};inOrder(t,r){let e;try{e=s(t)}catch{e=t.toLowerCase()}const i=(this.queues.get(e)??Promise.resolve()).then(r,r);return this.queues.set(e,i),i.finally(()=>{this.queues.get(e)===i&&this.queues.delete(e)})}addresses(t){if(!t)return{primary:null,all:[]};const r=this.getAliases(t[this.mailAttr]),e=new Set,i=[...r,...this.getAliases(t[this.aliasAttr])].filter(t=>{const r=t.toLowerCase();return!e.has(r)&&(e.add(r),!0)});return{primary:r[0]??null,all:i}}async follow(t,r,e){const i=this.addresses(r),s=this.addresses(e),lower=t=>new Set(t.map(t=>t.toLowerCase())),a=lower(i.all),o=lower(s.all),n=s.all.filter(t=>!a.has(t.toLowerCase())),l=i.all.filter(t=>!o.has(t.toLowerCase()));if(!n.length&&!l.length)return;let h;if(i.primary)if(s.primary){h="onLdapEntryChange:update";const r={plugin:this.name,event:h,dn:t,mail:i.primary,added:n,removed:l};let e;try{e=await this.isMigrated(i.primary,!0)}catch(t){return void this.logger.error({...r,result:"error",error:t instanceof Error?t.message:String(t)})}if(!e)return void this.logger.debug({...r,result:"ignored"})}else h="onLdapEntryChange:delete";else h="onLdapEntryChange:add";(await Promise.all(n.map(r=>this.call(h,t,"PUT",r)))).every(Boolean)&&await Promise.all(l.map(r=>this.call(h,t,"DELETE",r)))}userUrl(t){return`${this.webadminUrl}/migratedUsers/${encodeURIComponent(t)}`}call(t,r,e,i){return this.callWebAdminApi(t,this.userUrl(i),e,r,null,{mail:i},l)}async isMigrated(t,r=!1){for(let e=1;;e++){const i=await this.requestLimit(()=>this.fetch(this.userUrl(t),{method:"HEAD",headers:this.createHeaders(),signal:AbortSignal.timeout(1e4)}));if(404===i.status)return!1;if(i.ok)return!0;if(!r||!this.shouldRetry(i.status,l,e))throw new Error(`Migration proxy answered ${i.status} ${i.statusText}`);await this.waitBeforeRetry(e)}}async getMigrationStatus(t,r){if(!e(t,r))return;const i=t.params.user,s=await this.findReadableUser(i,t,[this.mailAttr]);if(!s)return void r.status(404).json({error:`User ${i} not found`});const a=this.attributeToString(s[this.mailAttr]);if(a)try{r.json({migrated:await this.isMigrated(a)})}catch(t){this.logger.error({plugin:this.name,event:"getMigrationStatus",username:i,error:t instanceof Error?t.message:String(t)}),r.status(502).json({error:"Failed to ask the migration proxy"})}else r.status(400).json({error:`User ${i} has no mail attribute`})}}export{h as MailMigrationParams,MigrationProxy as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{defineOptions as r,opt as n,readOptions as _}from"../../config/options.js";const t=r({migration_proxy_url:n.string({env:"DM_MIGRATION_PROXY_URL",default:""}),migration_proxy_token:n.string({env:"DM_MIGRATION_PROXY_TOKEN",default:""}),migration_proxy_branch:n.array({env:"DM_MIGRATION_PROXY_BRANCHES",default:[],plural:"migration-proxy-branches",kind:"dns"}),migration_proxy_concurrency:n.number({env:"DM_MIGRATION_PROXY_CONCURRENCY",default:10,min:1}),migration_proxy_retry_attempts:n.number({env:"DM_MIGRATION_PROXY_RETRY_ATTEMPTS",default:5}),migration_proxy_retry_delay:n.number({env:"DM_MIGRATION_PROXY_RETRY_DELAY",default:1e3})});function migrationProxyOptionsOf(r){return _(t,r)}export{t as migrationProxyOptions,migrationProxyOptionsOf};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as e}from"node:crypto";import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{extractLdapCode as a}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as r,unescapeDnValue as n,escapeDnValue as o,escapeLdapFilter as h}from"../../lib/utils.js";import{organizationIn as c,dnKey as d,movedInto as l,ORG as p,branchPattern as u,values as g,parentOf as m,read as f,rdnValue as w,notFound as b,RouteError as y,invalid as v,bodyOf as k,isDisplayName as O,NAME_RULE as $}from"./groups.js";import{isTombstone as _,valueOf as I,lifecycleAttributes as S}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),M=[...C].reverse(),L="twake.space.sync.requested",q=["name"],j=["uid","displayName","mail","jobTitle","role"],A=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new y(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw v(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw v(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw v(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw v(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const i=new Set;return e.map(e=>{const{[t]:s,role:r}=e??{};if("string"!=typeof s||0===s.length||!isRole(r))throw v(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(i.has(s.toLowerCase()))throw v(`${s} is listed twice in ${a}`);return i.add(s.toLowerCase()),{name:s,role:r}})}class TwakeSpaces extends t{constructor(e){if(super(e),this.name="twakeSpaces",this.roles=["api"],this.dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"},this.domains=new Map,this.following=Promise.resolve(),this.publishing=Promise.resolve(),this.unlinking=new Map,this.announced=new Map,this.handing=new Map,this.hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,I(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,I(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,a])=>{const i=l(this.spacePattern,e,t);if(void 0===i)return[e,t,a];const r=Object.values(this.roleAttributes),n=await this.entry(e,r);if(n&&r.some(e=>void 0!==this.foreign(i,I(n,e))))throw new s(`The space holds users or groups of another organization than ${i}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=c(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(d(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(d(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(d(s));e&&delete e.op;const i=a?.get(d(s));i&&this.queue(s,()=>this.handOver(i.org,s,i.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const i=this.orgPattern?.exec(r(e).join(","))?.groups;i&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(n(i.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const o=c(this.userPattern,e);void 0!==o&&this.lifecycle.deleted&&t&&s&&!_(t,this.lifecycle)&&_(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(o,e,await this.administered(o,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(p))throw new Error(`${this.name}: --twake-space-base must hold ${p}`);this.spacePattern=u(this.spaceBase),this.userPattern=u(this.config.twake_group_user_base),this.lifecycle=S(this.config),this.displayName=this.config.twake_space_display_name_attribute||"twakeDisplayName",this.roleAttributes={admin:this.config.twake_space_admin_attribute||"twakeSpaceAdmin",editor:this.config.twake_space_editor_attribute||"twakeSpaceEditor",viewer:this.config.twake_space_viewer_attribute||"twakeSpaceViewer"},this.userRole=this.config.twake_space_user_role_attribute||"",this.exchange=this.config.twake_space_exchange||"space",this.domainAttribute=this.config.twake_instance_organization_domain_attribute||"twakeDomain",this.config.twake_group_organization_dn&&(this.orgPattern=u(this.config.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.config.twake_group_base||"",this.spaceBase].map(e=>{const t=r(e).reverse();return e=>r(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(p)?type(e)===type(t[s]):d(e)===d(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return c(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(p,o(e))}spaceDn(e,t){return`cn=${o(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===a(e))return;throw e}}checkHolders(e,t){const a=this.organizationOf(e);if(!a)return;const i=this.foreign(a,t);if(void 0!==i)throw new s(`${i} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(d);return g(t).find(e=>!s.includes(d(m(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId;e.get(t,route("listing spaces",(e,t,s)=>this.listRoute(e,t,s))),e.post(t,route("creating space",(e,t,s)=>this.createRoute(e,t,s))),e.get(`${t}/:spaceId`,route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))})),e.patch(`${t}/:spaceId`,route("renaming space",async(e,t,s)=>{const a=k(e),i=Object.keys(a).filter(e=>"name"!==e);if(i.length)throw v(`Only "name" can be updated; unsupported field(s): ${i.join(", ")}`);if(!O(a.name))throw v($);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)})),e.delete(`${t}/:spaceId`,route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===a(e))throw b("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,j),i=await this.space(s,spaceId(e),e),r=i.holders.filter(e=>"member"===e.kind),n=new Map(r.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:i.id,...await this.groups.memberPage(a,s,r.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=k(e),i=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/members/:userId`,route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/members/:userId`,route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)})),e.get(`${t}/:spaceId/groups`,route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),i=a.holders.filter(e=>"group"===e.kind),r=new Map((await this.orgGroups(s,i.map(e=>e.name),[],e)).map(e=>[d(e.dn),this.groups.groupOf(s,e).displayName])),n=i.map(({name:e,role:t,dn:s})=>({id:e,name:r.get(d(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})})),e.post(`${t}/:spaceId/groups`,route("linking space groups",async(e,t,s)=>{const a=k(e),i=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,i,e)),done(t)})),e.patch(`${t}/:spaceId/groups/:groupId`,route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(k(e))),done(t)})),e.delete(`${t}/:spaceId/groups/:groupId`,route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)})),await(this.rabbitmq?.subscribe(this.exchange,L,this.config.twake_space_sync_queue||`${L}.ldap-rest`,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[d(this.groups.userBaseOf(e)),"member"],[d(this.groups.groupBaseOf(e)),"group"]]),i=f(t,"cn")||"",r=new Map;for(const e of M)for(const i of g(I(t,this.roleAttributes[e]))){const t=d(i),n=a.get(d(m(i)));if(!n||s.has(t))continue;const o=r.get(t);o?o.roles.push(e):r.set(t,{kind:n,name:w(i),role:e,roles:[e],dn:i})}return{id:i,name:f(t,this.displayName)??i,holders:[...r.values()]}}async spaces(e,t,s,i,r){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),i)).searchEntries}catch(e){if(32===a(e))return[];throw e}})(),r??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(d))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${h(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw b("space");return a}async write(e,t,s,i){try{await this.server.ldap.modify(this.spaceDn(e,t),s,i)}catch(e){if(32===a(e))throw b("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=a(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,i=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(f(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!i.has(e.toLowerCase())))throw b("user");return i}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const i=this.groups.cn,r=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);r.push(...await this.groups.groups(e,`(|${o.map(e=>`(${i}=${h(e)})`).join("")})`,[i,this.groupName,...s],a))}return r}async follow(e,t,s,a){let i=this.organizationOf(e);if(void 0!==i){const[e,r]=[t,s].map(e=>e?this.entryOf(i,e,new Set):void 0),n=[...new Set([e,r].flatMap(e=>e?[e.id]:[]))],o=[e,r].map(e=>new Map(e?.holders.map(e=>[d(e.dn),e.role]))),h=[e,r].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(d(e.dn))!==o[1].get(d(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),l=await this.orgGroups(i,[...c],["member"]),p=(r??e).id,u=await this.settle(i,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...l.flatMap(e=>g(e.member))],{id:p,space:e},await this.hidden(i));return void(this.rabbitmq&&await this.announce(i,u,a,n,{was:e,is:r,linked:l}))}if(i=this.groups.organizationOf(e),void 0===i||!t)return;const r=g(t.member),n=g(s?.member),[o,h]=[r,n].map(e=>new Set(e.map(d))),c=s?void 0:this.unlinking.get(d(e));c&&this.unlinking.delete(d(e));const l=await this.hidden(i),p=await this.settle(i,[...r.filter(e=>!h.has(d(e))),...n.filter(e=>!o.has(d(e)))],{group:e,members:o,spaces:c?.spaces},l);if(!this.rabbitmq||!c&&!p.length)return;const u=(await this.spaces(i,this.holding([e]),void 0,void 0,l)).map(e=>e.id);c&&this.unlinked(e,c,u,a),await this.announce(i,p,a,[...c?.spaces.map(e=>e.id)??[],...u])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${h(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const i of s)try{await this.again(()=>this.handOverOnce(e,t,i,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:i,error:String(e)})}}async handOverOnce(e,t,s,a){const i=d(t),[r]=await this.spaces(e,"(objectClass=*)",s),n=r?.holders.filter(e=>"member"===e.kind&&d(e.dn)!==i)??[];if(!r||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>d(t.dn)===d(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[w(e)]),i=w(e);return{org:t,id:i,name:a&&f(a,this.groupName)||i,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:i,id:r,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=d(this.spaceDn(i,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(d(e))),this.publishEvent(i,t,"group.unlinked",a,{groups:[{id:r,name:n,role:s}]})}}async settle(e,t,s,a){const i=d(this.groups.userBaseOf(e)),r=new Map(t.filter(e=>d(m(e))===i).map(e=>[d(e),e])),n=[];for(const[t,i]of r)if(!a.has(t))try{const t=await this.rolesOf(e,i,a),r=this.userRole?await this.writeRoles(i,t):await this.rolesOf(e,i,a,s);r&&n.push({dn:i,before:r,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:i,error:String(e)})}return n}async userIn(e,t,s,a){const i=(await this.groups.groups(e,`(member=${h(t)})`,[this.groups.cn],s)).map(e=>d(e.dn)),r=new Set([d(t),...i]),n=[t,...i];if(a&&"group"in a){const e=d(a.group);r.delete(e),a.members.has(d(t))&&r.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>r.has(d(e.dn)))?.role}}async announce(e,t,s,a,i){const r=new Set;if(i){const{was:a,is:n,linked:o}=i,h=(n??a).id,c=new Map(o.map(e=>[d(e.dn),f(e,this.groupName)||w(e.dn)])),groupOf=e=>({id:e.name,name:c.get(d(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),l=d(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&d(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])p.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[d(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(d(t.dn));e.delete(d(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(d(e.dn)))return!0;const t=this.unlinking.get(d(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===h.toLowerCase())&&(void 0!==t.op&&(t.owed??(t.owed=[])).push({id:h,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,i]=[a,n].map(e=>new Set(e.holders.map(e=>d(e.dn)))),r=[...p].filter(e=>t.has(e)&&i.has(e));r.length&&this.announced.set(l,new Set(r))}else if(r.add(h.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(h);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(d(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[i,o]=[t.get(a),s.get(a)];if(i===o||r.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:i?o?"member.role.changed":"member.removed":"member.added",role:o??i,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:i,role:r,caught:n}of o){const o=h.get(d(t));o&&this.publishEvent(e,a,i,n?{}:s,{members:[{...o,role:r}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",i=new Map((await this.groups.users(e,[...new Set(t.map(w))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(f(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=i.get(w(e).toLowerCase());if(!t)return[];const r={uuid:f(t,"entryUUID"),username:f(t,s),email:f(t,a),firstName:f(t,"givenName"),lastName:f(t,"sn")};return[[d(e),r]]}))}publishEvent(e,t,s,a,i={}){this.enqueue(e,s,{id:t,...i,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const i=this.rabbitmq;if(!i)return Promise.resolve();const r=this.publishing.then(async()=>this.publish(i,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=r.catch(()=>{}),r}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,L,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let i=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof y))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;i=!0}await this.sync(s,a,i)}async organizations(){const e=r(this.spaceBase),t=e.findIndex(e=>e.includes(p)),s=e.slice(0,t),a=[],i=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of i)for(const{dn:t}of e.searchEntries){const e=c(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),i=s?[]:await this.spaces(e,"(objectClass=*)",t),r=[];void 0===t||i.length||r.push(this.enqueue(e,"deleted",{id:t},a));for(const t of i)r.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&r.push(this.enqueue(e,"sync.completed",{spaceIds:i.map(e=>e.id)},a));const n=(await Promise.allSettled(r)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=d(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),i=new Map(a.map(e=>[d(e.dn),{name:f(e,this.groupName)||w(e.dn),members:g(e.member)}])),r=new Map,hold=(e,t)=>{r.has(d(e))||r.set(d(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of i.get(d(e.dn))?.members??[])d(m(t))===s&&hold(t,e.role);const n=[...r.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(d(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:i.get(d(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.config.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const i=this.domainAttribute;try{const s=await this.entry(t.replace(p,o(e)),[i]);a=s&&f(s,i)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const i={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...i,result:"no broker"}),new Error(`${this.name}: no broker for ${i.routingKey}`);try{await t.publish(i.exchange,i.routingKey,a,{messageId:i.messageId})}catch(e){throw this.logger.error({...i,result:"error",error:String(e)}),e}this.logger.info({...i,result:"published"})}async rolesOf(e,t,s,a){const{filter:i,roleIn:r}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,i,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=r(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(d(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||C.indexOf(t)>C.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,i=[...t].map(([e,t])=>`${e}:${t}`),r=new Set(i.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=g(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>A.test(e)&&!r.has(e.toLowerCase())),h=i.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=A.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=a(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(f(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw b("group");return a}async add(e,t,s,a,i){await this.again(()=>this.addOnce(e,t,s,a,i))}async addOnce(e,t,s,a,i){const r=await this.space(t,e.params.spaceId,e),n=await i(),o=new Map(r.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const i=o.get(e);if(void 0===i)h.push(t);else if(i!==a)throw"member"===s?conflict(`${e} is already a member, as ${i}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${i}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,r.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const i=await this.space(t,e.params.spaceId,e),r="member"===s?e.params.userId:e.params.groupId,n=i.holders.find(e=>e.kind===s&&e.name.toLowerCase()===r.toLowerCase());if(!n)throw b(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!i.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,i.id,{delete:h,add:c},e),o&&!(await this.space(t,i.id,e)).holders.some(isAdmin))throw await this.write(t,i.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,q);let i="(cn=*)";a.search&&(i=`(&${i}(${this.displayName}=*${h(a.search)}*))`);const r=e.query.user;let n;if(void 0!==r){if("string"!=typeof r||!r)throw v("user must be a username");const[t]=(await this.liveUsers(s,[r],e)).values(),a=await this.userIn(s,t,e);i=`(&${i}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,i,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const i=k(t);if(!O(i.name))throw v($);const r=this.groups.maxPage,n=holdersOf(i.members,"username",r),o=holdersOf(i.groups??[],"id",r);if(!n.some(e=>"admin"===e.role))throw v("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),l={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:d,[this.displayName]:i.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];l[e]=[...g(l[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),l,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
|
|
1
|
+
import{randomUUID as e}from"node:crypto";import{Type as t}from"@sinclair/typebox";import s from"../../abstract/plugin.js";import{BadRequestError as a}from"../../lib/errors.js";import{extractLdapCode as r}from"../../lib/ldapCodes.js";import{currentOperation as i}from"../../lib/operation.js";import{parseDn as n,unescapeDnValue as o,escapeDnValue as h,rdnValue as c,escapeLdapFilter as d}from"../../lib/utils.js";import{ORG as p,branchPattern as l,organizationIn as u,dnKey as m,movedInto as g,withOrg as f,values as w,parentOf as b,read as y,notFound as v,RouteError as k,invalid as O,bodyOf as S,isDisplayName as $,NAME_RULE as _}from"./groups.js";import{lifecycleAttributes as I,isTombstone as q,valueOf as L}from"./lifecycleAttributes.js";import{twakeGroupsOptionsOf as C}from"./twakeGroups.options.js";import{twakeInstancesOptionsOf as j}from"./twakeInstances.options.js";import{twakeSpacesOptions as M}from"./twakeSpaces.options.js";import{defineRoute as A}from"../../lib/route.js";const x=["viewer","editor","admin"],isRole=e=>x.includes(e),P=[...x].reverse(),z="twake.space.sync.requested",D=["name"],E=["uid","displayName","mail","jobTitle","role"],R=new RegExp(`:(${x.join("|")})$`,"i"),conflict=(e,t)=>new k(409,e,t),lastAdmin=()=>conflict("A space keeps at least one admin","LAST_ADMIN"),isAdmin=e=>"member"===e.kind&&"admin"===e.role;function roleOf(e){if(!isRole(e.role))throw O(`role must be one of: ${x.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw O(`${t} cannot exceed ${s} entries per request`)}function namesOf(e,t,s){if(!Array.isArray(e)||0===e.length||!e.every(e=>"string"==typeof e&&e.length>0))throw O(`${t} must be a non-empty array of non-empty strings`);return checkLength(e,t,s),[...new Map(e.map(e=>[e.toLowerCase(),e])).values()]}function holdersOf(e,t,s){const a="id"===t?"groups":"members";if(!Array.isArray(e))throw O(`${a} must be an array of { ${t}, role }`);checkLength(e,a,s);const r=new Set;return e.map(e=>{const{[t]:s,role:i}=e??{};if("string"!=typeof s||0===s.length||!isRole(i))throw O(`${a} must be an array of { ${t}, role }, role one of: ${x.join(", ")}`);if(r.has(s.toLowerCase()))throw O(`${s} is listed twice in ${a}`);return r.add(s.toLowerCase()),{name:s,role:i}})}const N=t.Object({id:t.String({minLength:1})}),B=t.Object({id:t.String({minLength:1}),spaceId:t.String({minLength:1})}),G=t.Object({id:t.String({minLength:1}),spaceId:t.String({minLength:1}),userId:t.String({minLength:1})}),U=t.Object({id:t.String({minLength:1}),spaceId:t.String({minLength:1}),groupId:t.String({minLength:1})});class TwakeSpaces extends s{static optionDefinitions=M;name="twakeSpaces";roles=["api"];dependencies={twakeGroups:"core/twake/groups",onLdapChange:"core/ldap/onChange"};spaceBase;spacePattern;userPattern;lifecycle;displayName;roleAttributes;userRole;exchange;domainAttribute;orgPattern;groupSettings;instanceSettings;domains=new Map;following=Promise.resolve();publishing=Promise.resolve();unlinking=new Map;announced=new Map;handing=new Map;constructor(e){super(e);const t=this.options(M);if(this.groupSettings=C(e.config),this.instanceSettings=j(e.config),this.spaceBase=t.twake_space_base,2!==this.spaceBase.split(p).length)throw new Error(`${this.name}: --twake-space-base must hold ${p} once`);this.spacePattern=l(this.spaceBase),this.userPattern=l(this.groupSettings.twake_group_user_base||""),this.lifecycle=I(this.config),this.displayName=t.twake_space_display_name_attribute,this.roleAttributes={admin:t.twake_space_admin_attribute,editor:t.twake_space_editor_attribute,viewer:t.twake_space_viewer_attribute},this.userRole=t.twake_space_user_role_attribute,this.exchange=t.twake_space_exchange,this.domainAttribute=this.instanceSettings.twake_instance_organization_domain_attribute,this.groupSettings.twake_group_organization_dn&&(this.orgPattern=l(this.groupSettings.twake_group_organization_dn)),this.config.rabbitmq_url&&(this.dependencies.rabbitmq="core/rabbitmq")}get follows(){return Boolean(this.userRole||this.config.rabbitmq_url)}get groups(){return this.server.loadedPlugins.twakeGroups}get tombstone(){return this.server.loadedPlugins.twakeTombstone}hooks={ldapaddrequest:([e,t,s])=>{for(const s of Object.values(this.roleAttributes))this.checkHolders(e,L(t,s));return[e,t,s]},ldapmodifyrequest:([e,t,s,a])=>{for(const s of Object.values(this.roleAttributes))for(const a of[t.add,t.replace])a&&this.checkHolders(e,L(a,s));return[e,t,s,a]},ldaprenamerequest:async([e,t,s])=>{const r=g(this.spacePattern,e,t);if(void 0===r)return[e,t,s];const i=Object.values(this.roleAttributes),n=await this.entry(e,i);if(n&&i.some(e=>void 0!==this.foreign(r,L(n,e))))throw new a(`The space holds users or groups of another organization than ${r}`);return[e,t,s]},ldapdeleterequest:async([e,t])=>{const s=i();if(void 0!==s)for(const t of[e].flat()){const e=u(this.userPattern,t);if(void 0!==e){if(this.tombstone?.keeps(t))continue;const a=await this.administered(e,t);if(!a.length)continue;this.handing.has(s)||this.handing.set(s,new Map),this.handing.get(s).set(m(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(m(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=i(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(m(s));e&&delete e.op;const r=a?.get(m(s));r&&this.queue(s,()=>this.handOver(r.org,s,r.ids,t))}},ldapdeleteend:e=>{this.handing.delete(e);for(const[t,s]of this.unlinking)if(s.op===e){this.unlinking.delete(t);for(const{id:e,context:t,fields:a}of s.owed??[])this.publishEvent(s.org,e,"group.unlinked",t,a)}},onLdapEntryChange:(e,t,s,a)=>{const r=this.orgPattern?.exec(n(e).join(","))?.groups;r&&this.rabbitmq&&(this.publishing=this.publishing.then(()=>{this.domains.delete(o(r.org).toLowerCase())})),this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const i=u(this.userPattern,e);void 0!==i&&this.lifecycle.deleted&&t&&s&&!q(t,this.lifecycle)&&q(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(i,e,await this.administered(i,e),a))}};async assertComposition(){if(this.config.rabbitmq_url&&!await(this.rabbitmq?.getRawClient()))throw new Error(`${this.name}: RabbitMQ at --rabbitmq-url cannot be reached`);if(this.config.rabbitmq_url&&this.orgPattern&&!(await this.server.ldap.schemaIndex()).getAttributeType(this.domainAttribute))throw new Error(`${this.name}: the directory schema defines no ${this.domainAttribute}; load a schema that does, or set --twake-instance-organization-domain-attribute`);if(!this.follows||!this.server.loadedPlugins.trash)return;const type=e=>e.split("=")[0].toLowerCase(),e=[this.groupSettings.twake_group_base,this.spaceBase].map(e=>{const t=n(e).reverse();return e=>n(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(p)?type(e)===type(t[s]):m(e)===m(t[s]))}),t=String(this.config.trash_watched_bases||"").split(";").map(e=>e.trim()).filter(Boolean);if(0===t.length||t.some(t=>e.some(e=>e(t))))throw new Error(`${this.name}: core/ldap/trash watches the organization groups or spaces, and the deletion of one would not be followed: no user role value or event would say it went. Leave the group and space branches out of --trash-watched-bases`)}queue(e,t){this.following=this.following.then(()=>t().catch(t=>{this.logger.error({plugin:this.name,event:"follow",dn:e,error:String(t)})}))}organizationOf(e){return u(this.spacePattern,e)}spaceBaseOf(e){return f(this.spaceBase,e)}spaceDn(e,t){return`cn=${h(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:t},e)).searchEntries[0]}catch(e){if(32===r(e))return;throw e}}checkHolders(e,t){const s=this.organizationOf(e);if(!s)return;const r=this.foreign(s,t);if(void 0!==r)throw new a(`${r} is neither a user nor a group of organization ${s}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(m);return w(t).find(e=>!s.includes(m(b(e))))}async api(e){const t=`${this.config.api_prefix}/v1/organizations/:id/spaces`,route=(e,t)=>this.groups.organizationRoute(e,t,this.name),done=e=>{e.json({success:!0})},spaceId=e=>e.params.spaceId,s=route("listing spaces",(e,t,s)=>this.listRoute(e,t,s));A(e,{method:"get",path:t,params:N,handler:({req:e,res:t})=>s(e,t)});const a=route("creating space",(e,t,s)=>this.createRoute(e,t,s));A(e,{method:"post",path:t,params:N,handler:({req:e,res:t})=>a(e,t)});const i=route("getting space",async(e,t,s)=>{t.json(this.spaceOf(s,await this.space(s,spaceId(e),e)))});A(e,{method:"get",path:`${t}/:spaceId`,params:B,handler:({req:e,res:t})=>i(e,t)});const n=route("renaming space",async(e,t,s)=>{const a=S(e),r=Object.keys(a).filter(e=>"name"!==e);if(r.length)throw O(`Only "name" can be updated; unsupported field(s): ${r.join(", ")}`);if(!$(a.name))throw O(_);await this.write(s,spaceId(e),{replace:{[this.displayName]:a.name}},e),done(t)});A(e,{method:"patch",path:`${t}/:spaceId`,params:B,handler:({req:e,res:t})=>n(e,t)});const o=route("deleting space",async(e,t,s)=>{try{await this.server.ldap.delete(this.spaceDn(s,spaceId(e)),e)}catch(e){if(32===r(e))throw v("space");throw e}done(t)});A(e,{method:"delete",path:`${t}/:spaceId`,params:B,handler:({req:e,res:t})=>o(e,t)});const h=route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,E),r=await this.space(s,spaceId(e),e),i=r.holders.filter(e=>"member"===e.kind),n=new Map(i.map(e=>[e.name.toLowerCase(),e.role]));t.json({organizationId:s,id:r.id,...await this.groups.memberPage(a,s,i.map(e=>e.name),e=>({role:n.get(e.toLowerCase())}),e)})});A(e,{method:"get",path:`${t}/:spaceId/members`,params:B,handler:({req:e,res:t})=>h(e,t)});const c=route("adding space members",async(e,t,s)=>{const a=S(e),r=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,r,e)),done(t)});A(e,{method:"post",path:`${t}/:spaceId/members`,params:B,handler:({req:e,res:t})=>c(e,t)});const d=route("changing space member role",async(e,t,s)=>{await this.change(e,s,"member",roleOf(S(e))),done(t)});A(e,{method:"patch",path:`${t}/:spaceId/members/:userId`,params:G,handler:({req:e,res:t})=>d(e,t)});const p=route("removing space member",async(e,t,s)=>{await this.change(e,s,"member"),done(t)});A(e,{method:"delete",path:`${t}/:spaceId/members/:userId`,params:G,handler:({req:e,res:t})=>p(e,t)});const l=route("listing space groups",async(e,t,s)=>{const a=await this.space(s,spaceId(e),e),r=a.holders.filter(e=>"group"===e.kind),i=new Map((await this.orgGroups(s,r.map(e=>e.name),[],e)).map(e=>[m(e.dn),this.groups.groupOf(s,e).displayName])),n=r.map(({name:e,role:t,dn:s})=>({id:e,name:i.get(m(s))??e,role:t})).sort((e,t)=>e.name.localeCompare(t.name));t.json({organizationId:s,id:a.id,groups:n})});A(e,{method:"get",path:`${t}/:spaceId/groups`,params:B,handler:({req:e,res:t})=>l(e,t)});const u=route("linking space groups",async(e,t,s)=>{const a=S(e),r=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,r,e)),done(t)});A(e,{method:"post",path:`${t}/:spaceId/groups`,params:B,handler:({req:e,res:t})=>u(e,t)});const g=route("changing space group role",async(e,t,s)=>{await this.change(e,s,"group",roleOf(S(e))),done(t)});A(e,{method:"patch",path:`${t}/:spaceId/groups/:groupId`,params:U,handler:({req:e,res:t})=>g(e,t)});const f=route("unlinking space group",async(e,t,s)=>{await this.change(e,s,"group"),done(t)});A(e,{method:"delete",path:`${t}/:spaceId/groups/:groupId`,params:U,handler:({req:e,res:t})=>f(e,t)}),await(this.rabbitmq?.subscribe(this.exchange,z,this.options(M).twake_space_sync_queue,e=>this.onSyncRequested(e)))}spaceOf(e,t){return{id:t.id,name:t.name,organizationId:e,members:t.holders.filter(e=>"member"===e.kind).map(({name:e,role:t})=>({username:e,role:t})),groups:t.holders.filter(e=>"group"===e.kind).map(({name:e,role:t})=>({id:e,role:t}))}}entryOf(e,t,s){const a=new Map([[m(this.groups.userBaseOf(e)),"member"],[m(this.groups.groupBaseOf(e)),"group"]]),r=y(t,"cn")||"",i=new Map;for(const e of P)for(const r of w(L(t,this.roleAttributes[e]))){const t=m(r),n=a.get(m(b(r)));if(!n||s.has(t))continue;const o=i.get(t);o?o.roles.push(e):i.set(t,{kind:n,name:c(r),role:e,roles:[e],dn:r})}return{id:r,name:y(t,this.displayName)??r,holders:[...i.values()]}}async spaces(e,t,s,a,i){const[n,o]=await Promise.all([(async()=>{try{return(await this.server.ldap.search({paged:!1,scope:void 0===s?"one":"base",filter:t,attributes:["cn",this.displayName,...Object.values(this.roleAttributes)]},void 0===s?this.spaceBaseOf(e):this.spaceDn(e,s),a)).searchEntries}catch(e){if(32===r(e))return[];throw e}})(),i??this.hidden(e)]);return n.map(t=>this.entryOf(e,t,o))}async hidden(e){return new Set((await this.groups.tombstonesOf(e)).map(m))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${d(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw v("space");return a}async write(e,t,s,a){try{await this.server.ldap.modify(this.spaceDn(e,t),s,a)}catch(e){if(32===r(e))throw v("space");throw e}}async again(e){for(let t=0;;t++)try{return await e()}catch(e){const s=r(e);if(16!==s&&20!==s||t>=2)throw e}}async liveUsers(e,t,s){const a=this.groups.userAttribute,r=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(y(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!r.has(e.toLowerCase())))throw v("user");return r}get groupName(){return this.groupSettings.twake_group_display_name_attribute}async orgGroups(e,t,s=[],a){const r=this.groups.cn,i=[];for(let n=0;n<t.length;n+=this.groups.maxPage){const o=t.slice(n,n+this.groups.maxPage);i.push(...await this.groups.groups(e,`(|${o.map(e=>`(${r}=${d(e)})`).join("")})`,[r,this.groupName,...s],a))}return i}async follow(e,t,s,a){let r=this.organizationOf(e);if(void 0!==r){const[e,i]=[t,s].map(e=>e?this.entryOf(r,e,new Set):void 0),n=[...new Set([e,i].flatMap(e=>e?[e.id]:[]))],o=[e,i].map(e=>new Map(e?.holders.map(e=>[m(e.dn),e.role]))),h=[e,i].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(m(e.dn))!==o[1].get(m(e.dn))),c=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),d=await this.orgGroups(r,[...c],["member"]),p=(i??e).id,l=await this.settle(r,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...d.flatMap(e=>w(e.member))],{id:p,space:e},await this.hidden(r));return void(this.rabbitmq&&await this.announce(r,l,a,n,{was:e,is:i,linked:d}))}if(r=this.groups.organizationOf(e),void 0===r||!t)return;const i=w(t.member),n=w(s?.member),[o,h]=[i,n].map(e=>new Set(e.map(m))),c=s?void 0:this.unlinking.get(m(e));c&&this.unlinking.delete(m(e));const d=await this.hidden(r),p=await this.settle(r,[...i.filter(e=>!h.has(m(e))),...n.filter(e=>!o.has(m(e)))],{group:e,members:o,spaces:c?.spaces},d);if(!this.rabbitmq||!c&&!p.length)return;const l=(await this.spaces(r,this.holding([e]),void 0,void 0,d)).map(e=>e.id);c&&this.unlinked(e,c,l,a),await this.announce(r,p,a,[...c?.spaces.map(e=>e.id)??[],...l])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${d(t)})`;return(await this.spaces(e,s)).map(e=>e.id)}catch(e){return this.logger.error({plugin:this.name,event:"handOver",dn:t,error:String(e)}),[]}}async handOver(e,t,s,a){for(const r of s)try{await this.again(()=>this.handOverOnce(e,t,r,a))}catch(e){this.logger.error({plugin:this.name,event:"handOver",space:r,error:String(e)})}}async handOverOnce(e,t,s,a){const r=m(t),[i]=await this.spaces(e,"(objectClass=*)",s),n=i?.holders.filter(e=>"member"===e.kind&&m(e.dn)!==r)??[];if(!i||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.system.delete(this.spaceDn(e,s),void 0,{context:a});const o=n.some(e=>"editor"===e.role)?"editor":"viewer",h=n.filter(e=>e.role===o).map(e=>e.dn);await this.server.ldap.system.modify(this.spaceDn(e,s),{delete:{[this.roleAttributes[o]]:h},add:{[this.roleAttributes.admin]:h}},void 0,{context:a})}async deletedGroup(e){const t=this.groups.organizationOf(e);if(void 0!==t)try{const s=(await this.spaces(t,this.holding([e]))).flatMap(t=>{const s=t.holders.find(t=>m(t.dn)===m(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[c(e)]),r=c(e);return{org:t,id:r,name:a&&y(a,this.groupName)||r,spaces:s}}catch(t){return void this.logger.error({plugin:this.name,event:"deleting",dn:e,error:String(t)})}}unlinked(e,t,s,a){const{org:r,id:i,name:n,spaces:o}=t,h=new Set(s.map(e=>e.toLowerCase()));for(const{id:t,role:s}of o){const o=m(this.spaceDn(r,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(m(e))),this.publishEvent(r,t,"group.unlinked",a,{groups:[{id:i,name:n,role:s}]})}}async settle(e,t,s,a){const r=m(this.groups.userBaseOf(e)),i=new Map(t.filter(e=>m(b(e))===r).map(e=>[m(e),e])),n=[];for(const[t,r]of i)if(!a.has(t))try{const t=await this.rolesOf(e,r,a),i=this.userRole?await this.writeRoles(r,t):await this.rolesOf(e,r,a,s);i&&n.push({dn:r,before:i,after:t})}catch(e){this.logger.error({plugin:this.name,event:"settle",dn:r,error:String(e)})}return n}async userIn(e,t,s,a){const r=(await this.groups.groups(e,`(member=${d(t)})`,[this.groups.cn],s)).map(e=>m(e.dn)),i=new Set([m(t),...r]),n=[t,...r];if(a&&"group"in a){const e=m(a.group);i.delete(e),a.members.has(m(t))&&i.add(e),n.push(a.group)}return{filter:this.holding(n),roleIn:e=>e.holders.find(e=>i.has(m(e.dn)))?.role}}async announce(e,t,s,a,r){const i=new Set;if(r){const{was:a,is:n,linked:o}=r,h=(n??a).id,d=new Map(o.map(e=>[m(e.dn),y(e,this.groupName)||c(e.dn)])),groupOf=e=>({id:e.name,name:d.get(m(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),p=m(this.spaceDn(e,h)),l=new Set;for(const t of[a,n]){const s=t&&m(this.spaceDn(e,t.id));for(const e of s&&this.announced.get(s)||[])l.add(e);s&&this.announced.delete(s)}if(a&&n){a.name!==n.name&&publish("updated",{name:n.name});const e=new Map(a.holders.filter(e=>"group"===e.kind).map(e=>[m(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(m(t.dn));e.delete(m(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(l.delete(m(e.dn)))return!0;const t=this.unlinking.get(m(e.dn));return!!t?.spaces.some(e=>e.id.toLowerCase()===h.toLowerCase())&&(void 0!==t.op&&(t.owed??=[]).push({id:h,context:s,fields:{groups:[groupOf(e)]}}),!0)};for(const t of e.values())byDeletion(t)||publish("group.unlinked",{groups:[groupOf(t)]});const[t,r]=[a,n].map(e=>new Set(e.holders.map(e=>m(e.dn)))),i=[...l].filter(e=>t.has(e)&&r.has(e));i.length&&this.announced.set(p,new Set(i))}else if(i.add(h.toLowerCase()),n){const s=t.flatMap(e=>{const t=e.after.get(h);return t?[{dn:e.dn,role:t}]:[]}),a=await this.profiles(e,s.map(e=>e.dn));publish("created",{name:n.name,members:s.flatMap(({dn:e,role:t})=>{const s=a.get(m(e));return s?[{...s,role:t}]:[]}),groups:n.holders.filter(e=>"group"===e.kind).map(groupOf)})}else publish("deleted")}const n=new Set(a.map(e=>e.toLowerCase())),o=t.flatMap(({dn:e,before:t,after:s})=>[...new Set([...t.keys(),...s.keys()])].flatMap(a=>{const[r,o]=[t.get(a),s.get(a)];if(r===o||i.has(a.toLowerCase()))return[];const h=!n.has(a.toLowerCase());if(h&&!this.userRole)return[];return[{dn:e,id:a,event:r?o?"member.role.changed":"member.removed":"member.added",role:o??r,caught:h}]}));if(!o.length)return;const h=await this.profiles(e,o.map(e=>e.dn));for(const{dn:t,id:a,event:r,role:i,caught:n}of o){const o=h.get(m(t));o&&this.publishEvent(e,a,r,n?{}:s,{members:[{...o,role:i}]})}}async profiles(e,t){if(!t.length)return new Map;const s=this.groups.userAttribute,a=this.config.mail_attribute||"mail",r=new Map((await this.groups.users(e,[...new Set(t.map(c))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(y(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=r.get(c(e).toLowerCase());if(!t)return[];const i={uuid:y(t,"entryUUID"),username:y(t,s),email:y(t,a),firstName:y(t,"givenName"),lastName:y(t,"sn")};return[[m(e),i]]}))}publishEvent(e,t,s,a,r={}){this.enqueue(e,s,{id:t,...r,actor:a.actor},(new Date).toISOString())}enqueue(e,t,s,a){const r=this.rabbitmq;if(!r)return Promise.resolve();const i=this.publishing.then(async()=>this.publish(r,t,{organizationId:e,...await this.domainOf(e),...s,timestamp:a}));return this.publishing=i.catch(()=>{}),i}async onSyncRequested(t){const malformed=()=>new Error(`${this.name}: malformed sync request ${JSON.stringify(t)}`);if("object"!=typeof t||!t||Array.isArray(t))throw malformed();const{organizationId:s,id:a}=t,valid=e=>void 0===e||"string"==typeof e&&e.length>0;if(!valid(s)||!valid(a)||void 0===s&&void 0!==a)throw malformed();if(void 0===s){for(const t of await this.organizations())await this.rabbitmq.publish(this.exchange,z,{organizationId:t,timestamp:(new Date).toISOString()},{messageId:e()});return}let r=!1;try{await this.groups.checkOrganization(s)}catch(e){if(!(e instanceof k))throw e;if(this.logger.info({plugin:this.name,event:"sync",organization:s,result:e.code}),410!==e.status)return;r=!0}await this.sync(s,a,r)}async organizations(){const e=n(this.spaceBase),t=e.findIndex(e=>e.includes(p)),s=e.slice(0,t),a=[],r=await this.server.ldap.system.search({paged:{pageSize:500},scope:"one",filter:"(objectClass=*)",attributes:["dn"]},e.slice(t+1).join(","));for await(const e of r)for(const{dn:t}of e.searchEntries){const e=u(this.spacePattern,["cn=x",...s,t].join(","));void 0!==e&&a.push(e)}return a}async sync(e,t,s){const a=(new Date).toISOString(),r=s?[]:await this.spaces(e,"(objectClass=*)",t),i=[];void 0===t||r.length||i.push(this.enqueue(e,"deleted",{id:t},a));for(const t of r)i.push(this.enqueue(e,"synced",await this.whole(e,t),a));void 0===t&&i.push(this.enqueue(e,"sync.completed",{spaceIds:r.map(e=>e.id)},a));const n=(await Promise.allSettled(i)).find(e=>"rejected"===e.status);if(n)throw n.reason}async whole(e,t){const s=m(this.groups.userBaseOf(e)),a=await this.orgGroups(e,t.holders.filter(e=>"group"===e.kind).map(e=>e.name),["member"]),r=new Map(a.map(e=>[m(e.dn),{name:y(e,this.groupName)||c(e.dn),members:w(e.member)}])),i=new Map,hold=(e,t)=>{i.has(m(e))||i.set(m(e),{dn:e,role:t})};for(const e of t.holders)if("member"===e.kind)hold(e.dn,e.role);else for(const t of r.get(m(e.dn))?.members??[])m(b(t))===s&&hold(t,e.role);const n=[...i.values()],o=await this.profiles(e,n.map(e=>e.dn));return{id:t.id,name:t.name,members:n.flatMap(({dn:e,role:t})=>{const s=o.get(m(e));return s?[{...s,role:t}]:[]}),groups:t.holders.filter(e=>"group"===e.kind).map(e=>({id:e.name,name:r.get(m(e.dn))?.name??e.name,role:e.role}))}}async domainOf(e){const t=this.groupSettings.twake_group_organization_dn;if(!t)return{};const s=e.toLowerCase();let a=this.domains.get(s);if(void 0===a){const r=this.domainAttribute;try{const s=await this.entry(f(t,e),[r]);a=s&&y(s,r)}catch(t){this.logger.warn({plugin:this.name,event:"organizationDomain",organization:e,error:String(t)})}a&&this.domains.set(s,a)}return a?{organizationDomain:a}:{}}get rabbitmq(){return this.config.rabbitmq_url?this.requirePlugin("rabbitmq"):null}async publish(t,s,a){const r={plugin:this.name,exchange:this.exchange,routingKey:`twake.space.${s}`,messageId:e()};if(!await t.getRawClient())throw this.logger.error({...r,result:"no broker"}),new Error(`${this.name}: no broker for ${r.routingKey}`);try{await t.publish(r.exchange,r.routingKey,a,{messageId:r.messageId})}catch(e){throw this.logger.error({...r,result:"error",error:String(e)}),e}this.logger.info({...r,result:"published"})}async rolesOf(e,t,s,a){const{filter:r,roleIn:i}=await this.userIn(e,t,void 0,a);let n=await this.spaces(e,r,void 0,void 0,s);if(a&&"id"in a){const e=a.id.toLowerCase();n=n.filter(t=>t.id.toLowerCase()!==e),a.space&&n.push(a.space)}const o=new Map(n.flatMap(e=>{const t=i(e);return t?[[e.id,t]]:[]}));if(a&&"group"in a&&a.members.has(m(t)))for(const{id:e,role:t}of a.spaces??[]){const s=o.get(e);(!s||x.indexOf(t)>x.indexOf(s))&&o.set(e,t)}return o}async writeRoles(e,t){const s=this.userRole,a=[...t].map(([e,t])=>`${e}:${t}`),i=new Set(a.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const r=w(L(t,s)),n=new Set(r.map(e=>e.toLowerCase())),o=r.filter(e=>R.test(e)&&!i.has(e.toLowerCase())),h=a.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.system.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(r.flatMap(e=>{const t=R.exec(e)?.[1].toLowerCase();return t?[[e.slice(0,e.lastIndexOf(":")),t]]:[]}))}catch(e){const s=r(e);if(32===s)return;if(16!==s&&20!==s||t>=2)throw e}}async groupDns(e,t,s){const a=new Map((await this.orgGroups(e,t,[],s)).map(e=>[(y(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw v("group");return a}async add(e,t,s,a,r){await this.again(()=>this.addOnce(e,t,s,a,r))}async addOnce(e,t,s,a,r){const i=await this.space(t,e.params.spaceId,e),n=await r(),o=new Map(i.holders.filter(e=>e.kind===s).map(e=>[e.name.toLowerCase(),e.role])),h=[];for(const[e,t]of n){const r=o.get(e);if(void 0===r)h.push(t);else if(r!==a)throw"member"===s?conflict(`${e} is already a member, as ${r}`,"MEMBER_EXISTS"):conflict(`${e} is already linked, as ${r}`,"GROUP_ALREADY_LINKED")}h.length&&await this.write(t,i.id,{add:{[this.roleAttributes[a]]:h}},e)}async change(e,t,s,a){await this.again(()=>this.changeOnce(e,t,s,a))}async changeOnce(e,t,s,a){const r=await this.space(t,e.params.spaceId,e),i="member"===s?e.params.userId:e.params.groupId,n=r.holders.find(e=>e.kind===s&&e.name.toLowerCase()===i.toLowerCase());if(!n)throw v(s);if(1===n.roles.length&&n.role===a)return;const o="member"===s&&"admin"===n.role&&"admin"!==a;if(o&&!r.holders.some(e=>e!==n&&isAdmin(e)))throw lastAdmin();const h=Object.fromEntries(n.roles.filter(e=>e!==a).map(e=>[this.roleAttributes[e],n.dn])),c=a&&!n.roles.includes(a)?{[this.roleAttributes[a]]:n.dn}:void 0;if(await this.write(t,r.id,{delete:h,add:c},e),o&&!(await this.space(t,r.id,e)).holders.some(isAdmin))throw await this.write(t,r.id,{add:h,delete:c},e),lastAdmin()}async listRoute(e,t,s){const a=this.groups.page(e,D);let r="(cn=*)";a.search&&(r=`(&${r}(${this.displayName}=*${d(a.search)}*))`);const i=e.query.user;let n;if(void 0!==i){if("string"!=typeof i||!i)throw O("user must be a username");const[t]=(await this.liveUsers(s,[i],e)).values(),a=await this.userIn(s,t,e);r=`(&${r}${a.filter})`,n=a.roleIn}const o=this.groups.sorted((await this.spaces(s,r,void 0,e)).map(e=>({...this.spaceOf(s,e),...n&&{role:n(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:o.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:o.length,totalPages:Math.ceil(o.length/a.limit)}})}async createRoute(t,s,a){const r=S(t);if(!$(r.name))throw O(_);const i=this.groups.maxPage,n=holdersOf(r.members,"username",i),o=holdersOf(r.groups??[],"id",i);if(!n.some(e=>"admin"===e.role))throw O("A space needs an admin among its members","ADMIN_REQUIRED");const[h,c]=await Promise.all([this.liveUsers(a,n.map(e=>e.name),t),this.groupDns(a,o.map(e=>e.name),t)]),d=e(),p={objectClass:this.options(M).twake_space_class,cn:d,[this.displayName]:r.name};for(const[e,t]of[[n,h],[o,c]])for(const{name:s,role:a}of e){const e=this.roleAttributes[a];p[e]=[...w(p[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,d),p,t),s.status(201).json(this.spaceOf(a,await this.space(a,d,t)))}}export{x as SPACE_ROLES,U as SpaceGroupParams,G as SpaceMemberParams,N as SpaceOrganizationParams,B as SpaceParams,TwakeSpaces as default};
|