ldap-rest 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as _,dirname as a}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins","identifiers"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains","identifiers"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls","identifiers",!0],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",0,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(a(e(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes","identifiers"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes","identifiers"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--organization-schema","DM_ORGANIZATION_SCHEMA",""],["--enterprise-domain-name-attribute","DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE","associatedDomain"],["--enterprise-domain-link-attribute","DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE",""],["--storage-backend","DM_STORAGE_BACKEND",""],["--storage-sweep-interval","DM_STORAGE_SWEEP_INTERVAL",600,"number"],["--storage-ldap-base","DM_STORAGE_LDAP_BASE",""],["--storage-ldap-object-class","DM_STORAGE_LDAP_OBJECT_CLASS","applicationProcess"],["--storage-file-directory","DM_STORAGE_FILE_DIRECTORY",""],["--storage-postgres-url","DM_STORAGE_POSTGRES_URL",""],["--storage-postgres-table","DM_STORAGE_POSTGRES_TABLE","ldap_rest_storage"],["--storage-valkey-url","DM_STORAGE_VALKEY_URL",""],["--storage-valkey-prefix","DM_STORAGE_VALKEY_PREFIX","ldap-rest:"],["--bcl-retention","DM_BCL_RETENTION",604800,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes","identifiers"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(a(e(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes","identifiers"],["--static-path","DM_STATIC_PATH",_(a(e(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--ldap-flat-auto-repair","DM_LDAP_FLAT_AUTO_REPAIR",!0,"boolean"],["--ldap-raw-base","DM_LDAP_RAW_BASE",[],"array","--ldap-raw-bases","dns"],["--ldap-raw-hidden-attribute","DM_LDAP_RAW_HIDDEN_ATTRIBUTES",[],"array","--ldap-raw-hidden-attributes","identifiers"],["--ldap-raw-show-secrets","DM_LDAP_RAW_SHOW_SECRETS",!1,"boolean"],["--ldap-raw-max-results","DM_LDAP_RAW_MAX_RESULTS",200,"number"],["--ldap-raw-schema-cache-ttl","DM_LDAP_RAW_SCHEMA_CACHE_TTL",3600,"number"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches","dns"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--calendar-firstname-attribute","DM_CALENDAR_FIRSTNAME_ATTRIBUTE","givenName"],["--calendar-lastname-attribute","DM_CALENDAR_LASTNAME_ATTRIBUTE","sn"],["--cozy-admin-url","DM_COZY_ADMIN_URL",""],["--cozy-admin-user","DM_COZY_ADMIN_USER","admin"],["--cozy-admin-passphrase","DM_COZY_ADMIN_PASSPHRASE",""],["--cozy-org-id","DM_COZY_ORG_ID",""],["--cozy-org-domain","DM_COZY_ORG_DOMAIN",""],["--cozy-default-locale","DM_COZY_DEFAULT_LOCALE","fr"],["--cozy-context-name","DM_COZY_CONTEXT_NAME","default"],["--cozy-apps","DM_COZY_APPS","home,drive,settings,notes,dataproxy"],["--cozy-auth-exchange","DM_COZY_AUTH_EXCHANGE","auth"],["--cozy-b2b-exchange","DM_COZY_B2B_EXCHANGE","b2b"],["--cozy-user-created-routing-key","DM_COZY_USER_CREATED_ROUTING_KEY","user.created"],["--cozy-user-deleted-routing-key","DM_COZY_USER_DELETED_ROUTING_KEY","domain.user.deleted"],["--cloudery-manager-url","DM_CLOUDERY_MANAGER_URL",""],["--cloudery-manager-token","DM_CLOUDERY_MANAGER_TOKEN",""],["--cloudery-offer","DM_CLOUDERY_OFFER","b2b_twake_default"],["--cloudery-domain","DM_CLOUDERY_DOMAIN",""],["--cloudery-user-branch","DM_CLOUDERY_USER_BRANCH",""],["--cloudery-org-id-header","DM_CLOUDERY_ORG_ID_HEADER","x-cloudery-org-id"],["--cloudery-org-role-header","DM_CLOUDERY_ORG_ROLE_HEADER","x-cloudery-org-role"],["--cloudery-default-org-role","DM_CLOUDERY_DEFAULT_ORG_ROLE","member"],["--cloudery-fqdn-attribute","DM_CLOUDERY_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--cloudery-org-id-attribute","DM_CLOUDERY_ORG_ID_ATTRIBUTE","twakeOrganizationId"],["--cloudery-org-role-attribute","DM_CLOUDERY_ORG_ROLE_ATTRIBUTE","twakeOrganizationRole"],["--cloudery-phones-attribute","DM_CLOUDERY_PHONES_ATTRIBUTE","twakePhones"],["--cloudery-invited-attribute","DM_CLOUDERY_INVITED_ATTRIBUTE","twakeInvited"],["--cloudery-default-locale","DM_CLOUDERY_DEFAULT_LOCALE","en"],["--cloudery-workflow-poll-interval-ms","DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS",2e3,"number"],["--cloudery-workflow-max-attempts","DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS",60,"number"],["--twake-lifecycle-role-attribute","DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE",""],["--twake-lifecycle-lock-attribute","DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE",""],["--twake-lifecycle-lock-value","DM_TWAKE_LIFECYCLE_LOCK_VALUE",""],["--twake-lifecycle-deleted-attribute","DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE",""],["--twake-lifecycle-deleted-value","DM_TWAKE_LIFECYCLE_DELETED_VALUE","TRUE"],["--twake-lifecycle-deleted-at-attribute","DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE",""],["--twake-lifecycle-deleted-at-format","DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT","iso8601"],["--twake-lifecycle-reason-attribute","DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE",""],["--twake-lifecycle-rules","DM_TWAKE_LIFECYCLE_RULES",""],["--twake-lifecycle-member-attribute","DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE","member"],["--twake-group-base","DM_TWAKE_GROUP_BASE",""],["--twake-group-user-base","DM_TWAKE_GROUP_USER_BASE",""],["--twake-group-organization-dn","DM_TWAKE_GROUP_ORGANIZATION_DN",""],["--twake-group-organization-status-attribute","DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE","twakeOrgStatus"],["--twake-group-organization-deleted-value","DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE","deleted"],["--twake-group-display-name-attribute","DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-group-color-attribute","DM_TWAKE_GROUP_COLOR_ATTRIBUTE","twakeGroupColor"],["--twake-group-created-at-attribute","DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE","twakeCreatedAt"],["--twake-group-max-page-limit","DM_TWAKE_GROUP_MAX_PAGE_LIMIT",1e3,"number"],["--twake-group-member-fields","DM_TWAKE_GROUP_MEMBER_FIELDS",{},"json"],["--twake-space-base","DM_TWAKE_SPACE_BASE",""],["--twake-space-class","DM_TWAKE_SPACE_CLASS",["top","twakeSpace"],"array",null,"identifiers"],["--twake-space-display-name-attribute","DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-space-admin-attribute","DM_TWAKE_SPACE_ADMIN_ATTRIBUTE","twakeSpaceAdmin"],["--twake-space-editor-attribute","DM_TWAKE_SPACE_EDITOR_ATTRIBUTE","twakeSpaceEditor"],["--twake-space-viewer-attribute","DM_TWAKE_SPACE_VIEWER_ATTRIBUTE","twakeSpaceViewer"],["--twake-space-user-role-attribute","DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE",""],["--twake-space-exchange","DM_TWAKE_SPACE_EXCHANGE","space"],["--twake-tombstone-dn","DM_TWAKE_TOMBSTONE_DN",[],"array",null,"dns"],["--twake-tombstone-default-reason","DM_TWAKE_TOMBSTONE_DEFAULT_REASON","deleted"],["--twake-tombstone-reason-header","DM_TWAKE_TOMBSTONE_REASON_HEADER","x-deletion-reason"],["--twake-tombstone-reasons","DM_TWAKE_TOMBSTONE_REASONS",[],"array",null,"identifiers"],["--twake-tombstone-clear-attributes","DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES",[],"array",null,"identifiers"],["--twake-tombstone-erase-min-age","DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE",2592e3,"number"],["--twake-tombstone-group-bases","DM_TWAKE_TOMBSTONE_GROUP_BASES",[],"array",null,"dns"],["--twake-instance-dn","DM_TWAKE_INSTANCE_DN",[],"array",null,"dns"],["--twake-instance-skip-attribute","DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE",""],["--twake-instance-skip-value","DM_TWAKE_INSTANCE_SKIP_VALUE",""],["--twake-instance-provider","DM_TWAKE_INSTANCE_PROVIDER","cloudery"],["--twake-instance-id","DM_TWAKE_INSTANCE_ID","{uid}"],["--twake-instance-fqdn-attribute","DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--twake-instance-sent-attribute","DM_TWAKE_INSTANCE_SENT_ATTRIBUTE","twakeCreatedEventAt"],["--twake-instance-locale","DM_TWAKE_INSTANCE_LOCALE",""],["--twake-instance-timeout","DM_TWAKE_INSTANCE_TIMEOUT",3e4,"number"],["--twake-instance-cloudery-url","DM_TWAKE_INSTANCE_CLOUDERY_URL",""],["--twake-instance-cloudery-token","DM_TWAKE_INSTANCE_CLOUDERY_TOKEN",""],["--twake-instance-cloudery-offer","DM_TWAKE_INSTANCE_CLOUDERY_OFFER","b2b_twake_default"],["--twake-instance-cloudery-domain","DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN",""],["--twake-instance-cloudery-organization-offer","DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER",""],["--twake-instance-cozy-url","DM_TWAKE_INSTANCE_COZY_URL",""],["--twake-instance-cozy-user","DM_TWAKE_INSTANCE_COZY_USER","admin"],["--twake-instance-cozy-passphrase","DM_TWAKE_INSTANCE_COZY_PASSPHRASE",""],["--twake-instance-cozy-domain","DM_TWAKE_INSTANCE_COZY_DOMAIN",""],["--twake-instance-cozy-context","DM_TWAKE_INSTANCE_COZY_CONTEXT","default"],["--twake-instance-cozy-apps","DM_TWAKE_INSTANCE_COZY_APPS","home,drive,settings,notes,dataproxy"],["--twake-instance-cozy-org-id","DM_TWAKE_INSTANCE_COZY_ORG_ID",""],["--twake-instance-cozy-org-domain","DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN",""],["--twake-instance-auth-exchange","DM_TWAKE_INSTANCE_AUTH_EXCHANGE","auth"],["--twake-instance-b2b-exchange","DM_TWAKE_INSTANCE_B2B_EXCHANGE","b2b"],["--twake-instance-user-created-key","DM_TWAKE_INSTANCE_USER_CREATED_KEY","user.created"],["--twake-instance-organization-created-key","DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY","organization.created"],["--twake-instance-workplace-created-key","DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY","workplace.created"],["--twake-instance-queue","DM_TWAKE_INSTANCE_QUEUE","workplace.created.ldap-rest"],["--twake-instance-organization-base","DM_TWAKE_INSTANCE_ORGANIZATION_BASE",""],["--twake-instance-organization-id-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE","ou"],["--twake-instance-organization-domain-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE","twakeDomain"],["--twake-instance-organization-name-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE","description"],["--twake-instance-organization-fqdn-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE",""],["--rabbitmq-url","DM_RABBITMQ_URL",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--applicative-account-attribute","DM_APPLICATIVE_ACCOUNT_ATTRIBUTES",["objectClass","cn","sn","givenName","displayName","description"],"array","--applicative-account-attributes","identifiers"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes","identifiers"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--llng-username-header","DM_LLNG_USERNAME_HEADER",""],["--auth-path-prefix","DM_AUTH_PATH_PREFIX",[],"array","--auth-path-prefixes"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-fake-user","DM_AUTH_FAKE_USER",""],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps","phrases"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs","phrases"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-route","DM_AUTHZ_PER_ROUTES",[],"array","--authz-per-routes"],["--authz-unresolved-user","DM_AUTHZ_UNRESOLVED_USER","deny"],["--authz-identity","DM_AUTHZ_IDENTITY","req.user"],["--authz-for","DM_AUTHZ_FOR",[],"array",null,"identifiers"],["--authz-combine","DM_AUTHZ_COMBINE",!1,"boolean"],["--authz-scope-source","DM_AUTHZ_SCOPE_SOURCE",""],["--authz-filter-attached-entries","DM_AUTHZ_FILTER_ATTACHED_ENTRIES",!1,"boolean"],["--authz-transit-branch","DM_AUTHZ_TRANSIT_BRANCH",""],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-dynamic-bypass","DM_AUTHZ_DYNAMIC_BYPASS",[],"array",null,"identifiers"],["--authz-dynamic-base","DM_AUTHZ_DYNAMIC_BASE",""],["--authz-dynamic-cache-ttl","DM_AUTHZ_DYNAMIC_CACHE_TTL",60,"number"],["--authz-dynamic-token-attribute","DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE","userPassword"],["--authz-dynamic-config-attribute","DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE","description"],["--authz-dynamic-tenant-attribute","DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE","cn"],["--authz-dynamic-reload-endpoint","DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",!1,"boolean"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-username-claim","DM_OIDC_USERNAME_CLAIM","sub"],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies","identifiers"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""],["--scim-prefix","DM_SCIM_PREFIX","/scim/v2"],["--scim-user-base","DM_SCIM_USER_BASE",""],["--scim-group-base","DM_SCIM_GROUP_BASE",""],["--scim-user-base-template","DM_SCIM_USER_BASE_TEMPLATE",""],["--scim-group-base-template","DM_SCIM_GROUP_BASE_TEMPLATE",""],["--scim-base-map","DM_SCIM_BASE_MAP",""],["--scim-user-base-header","DM_SCIM_USER_BASE_HEADER",""],["--scim-group-base-header","DM_SCIM_GROUP_BASE_HEADER",""],["--scim-base-header-root","DM_SCIM_BASE_HEADER_ROOT",""],["--scim-user-object-class","DM_SCIM_USER_OBJECT_CLASSES",["top","inetOrgPerson","organizationalPerson","person"],"array","--scim-user-object-classes","identifiers"],["--scim-user-rdn-attribute","DM_SCIM_USER_RDN_ATTRIBUTE","uid"],["--scim-user-lock-attribute","DM_SCIM_USER_LOCK_ATTRIBUTE","pwdAccountLockedTime"],["--scim-user-lock-value","DM_SCIM_USER_LOCK_VALUE",""],["--scim-group-object-class","DM_SCIM_GROUP_OBJECT_CLASSES",["top","groupOfNames"],"array","--scim-group-object-classes","identifiers"],["--scim-group-rdn-attribute","DM_SCIM_GROUP_RDN_ATTRIBUTE","cn"],["--scim-group-external-id-attribute","DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE",""],["--scim-id-attribute","DM_SCIM_ID_ATTRIBUTE","rdn"],["--scim-user-mapping","DM_SCIM_USER_MAPPING",""],["--scim-group-mapping","DM_SCIM_GROUP_MAPPING",""],["--scim-max-results","DM_SCIM_MAX_RESULTS",200,"number"],["--scim-max-scanned","DM_SCIM_MAX_SCANNED",1e4,"number"],["--scim-bulk-max-operations","DM_SCIM_BULK_MAX_OPERATIONS",100,"number"],["--scim-bulk-max-payload-size","DM_SCIM_BULK_MAX_PAYLOAD_SIZE",1048576,"number"],["--scim-etag","DM_SCIM_ETAG",!1,"boolean"],["--scim-base-url","DM_SCIM_BASE_URL",""]];export{t as default};
1
+ import{fileURLToPath as e}from"url";import{join as _,dirname as a}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins","identifiers"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains","identifiers"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls","identifiers",!0],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",0,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(a(e(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes","identifiers"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes","identifiers"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--organization-schema","DM_ORGANIZATION_SCHEMA",""],["--enterprise-domain-name-attribute","DM_ENTERPRISE_DOMAIN_NAME_ATTRIBUTE","associatedDomain"],["--enterprise-domain-link-attribute","DM_ENTERPRISE_DOMAIN_LINK_ATTRIBUTE",""],["--storage-backend","DM_STORAGE_BACKEND",""],["--storage-sweep-interval","DM_STORAGE_SWEEP_INTERVAL",600,"number"],["--storage-ldap-base","DM_STORAGE_LDAP_BASE",""],["--storage-ldap-object-class","DM_STORAGE_LDAP_OBJECT_CLASS","applicationProcess"],["--storage-file-directory","DM_STORAGE_FILE_DIRECTORY",""],["--storage-postgres-url","DM_STORAGE_POSTGRES_URL",""],["--storage-postgres-table","DM_STORAGE_POSTGRES_TABLE","ldap_rest_storage"],["--storage-valkey-url","DM_STORAGE_VALKEY_URL",""],["--storage-valkey-prefix","DM_STORAGE_VALKEY_PREFIX","ldap-rest:"],["--bcl-retention","DM_BCL_RETENTION",604800,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes","identifiers"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(a(e(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes","identifiers"],["--static-path","DM_STATIC_PATH",_(a(e(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--ldap-flat-auto-repair","DM_LDAP_FLAT_AUTO_REPAIR",!0,"boolean"],["--ldap-raw-base","DM_LDAP_RAW_BASE",[],"array","--ldap-raw-bases","dns"],["--ldap-raw-hidden-attribute","DM_LDAP_RAW_HIDDEN_ATTRIBUTES",[],"array","--ldap-raw-hidden-attributes","identifiers"],["--ldap-raw-show-secrets","DM_LDAP_RAW_SHOW_SECRETS",!1,"boolean"],["--ldap-raw-max-results","DM_LDAP_RAW_MAX_RESULTS",200,"number"],["--ldap-raw-schema-cache-ttl","DM_LDAP_RAW_SCHEMA_CACHE_TTL",3600,"number"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches","dns"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--calendar-firstname-attribute","DM_CALENDAR_FIRSTNAME_ATTRIBUTE","givenName"],["--calendar-lastname-attribute","DM_CALENDAR_LASTNAME_ATTRIBUTE","sn"],["--cozy-admin-url","DM_COZY_ADMIN_URL",""],["--cozy-admin-user","DM_COZY_ADMIN_USER","admin"],["--cozy-admin-passphrase","DM_COZY_ADMIN_PASSPHRASE",""],["--cozy-org-id","DM_COZY_ORG_ID",""],["--cozy-org-domain","DM_COZY_ORG_DOMAIN",""],["--cozy-default-locale","DM_COZY_DEFAULT_LOCALE","fr"],["--cozy-context-name","DM_COZY_CONTEXT_NAME","default"],["--cozy-apps","DM_COZY_APPS","home,drive,settings,notes,dataproxy"],["--cozy-auth-exchange","DM_COZY_AUTH_EXCHANGE","auth"],["--cozy-b2b-exchange","DM_COZY_B2B_EXCHANGE","b2b"],["--cozy-user-created-routing-key","DM_COZY_USER_CREATED_ROUTING_KEY","user.created"],["--cozy-user-deleted-routing-key","DM_COZY_USER_DELETED_ROUTING_KEY","domain.user.deleted"],["--cloudery-manager-url","DM_CLOUDERY_MANAGER_URL",""],["--cloudery-manager-token","DM_CLOUDERY_MANAGER_TOKEN",""],["--cloudery-offer","DM_CLOUDERY_OFFER","b2b_twake_default"],["--cloudery-domain","DM_CLOUDERY_DOMAIN",""],["--cloudery-user-branch","DM_CLOUDERY_USER_BRANCH",""],["--cloudery-org-id-header","DM_CLOUDERY_ORG_ID_HEADER","x-cloudery-org-id"],["--cloudery-org-role-header","DM_CLOUDERY_ORG_ROLE_HEADER","x-cloudery-org-role"],["--cloudery-default-org-role","DM_CLOUDERY_DEFAULT_ORG_ROLE","member"],["--cloudery-fqdn-attribute","DM_CLOUDERY_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--cloudery-org-id-attribute","DM_CLOUDERY_ORG_ID_ATTRIBUTE","twakeOrganizationId"],["--cloudery-org-role-attribute","DM_CLOUDERY_ORG_ROLE_ATTRIBUTE","twakeOrganizationRole"],["--cloudery-phones-attribute","DM_CLOUDERY_PHONES_ATTRIBUTE","twakePhones"],["--cloudery-invited-attribute","DM_CLOUDERY_INVITED_ATTRIBUTE","twakeInvited"],["--cloudery-default-locale","DM_CLOUDERY_DEFAULT_LOCALE","en"],["--cloudery-workflow-poll-interval-ms","DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS",2e3,"number"],["--cloudery-workflow-max-attempts","DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS",60,"number"],["--twake-lifecycle-role-attribute","DM_TWAKE_LIFECYCLE_ROLE_ATTRIBUTE",""],["--twake-lifecycle-lock-attribute","DM_TWAKE_LIFECYCLE_LOCK_ATTRIBUTE",""],["--twake-lifecycle-lock-value","DM_TWAKE_LIFECYCLE_LOCK_VALUE",""],["--twake-lifecycle-deleted-attribute","DM_TWAKE_LIFECYCLE_DELETED_ATTRIBUTE",""],["--twake-lifecycle-deleted-value","DM_TWAKE_LIFECYCLE_DELETED_VALUE","TRUE"],["--twake-lifecycle-deleted-at-attribute","DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE",""],["--twake-lifecycle-deleted-at-format","DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT","iso8601"],["--twake-lifecycle-reason-attribute","DM_TWAKE_LIFECYCLE_REASON_ATTRIBUTE",""],["--twake-lifecycle-rules","DM_TWAKE_LIFECYCLE_RULES",""],["--twake-lifecycle-member-attribute","DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE","member"],["--twake-group-base","DM_TWAKE_GROUP_BASE",""],["--twake-group-user-base","DM_TWAKE_GROUP_USER_BASE",""],["--twake-group-organization-dn","DM_TWAKE_GROUP_ORGANIZATION_DN",""],["--twake-group-organization-status-attribute","DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE","twakeOrgStatus"],["--twake-group-organization-deleted-value","DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE","deleted"],["--twake-group-display-name-attribute","DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-group-color-attribute","DM_TWAKE_GROUP_COLOR_ATTRIBUTE","twakeGroupColor"],["--twake-group-created-at-attribute","DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE","twakeCreatedAt"],["--twake-group-max-page-limit","DM_TWAKE_GROUP_MAX_PAGE_LIMIT",1e3,"number"],["--twake-group-member-fields","DM_TWAKE_GROUP_MEMBER_FIELDS",{},"json"],["--twake-space-base","DM_TWAKE_SPACE_BASE",""],["--twake-space-class","DM_TWAKE_SPACE_CLASS",["top","twakeSpace"],"array",null,"identifiers"],["--twake-space-display-name-attribute","DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE","twakeDisplayName"],["--twake-space-admin-attribute","DM_TWAKE_SPACE_ADMIN_ATTRIBUTE","twakeSpaceAdmin"],["--twake-space-editor-attribute","DM_TWAKE_SPACE_EDITOR_ATTRIBUTE","twakeSpaceEditor"],["--twake-space-viewer-attribute","DM_TWAKE_SPACE_VIEWER_ATTRIBUTE","twakeSpaceViewer"],["--twake-space-user-role-attribute","DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE",""],["--twake-space-exchange","DM_TWAKE_SPACE_EXCHANGE","space"],["--twake-space-sync-queue","DM_TWAKE_SPACE_SYNC_QUEUE","twake.space.sync.requested.ldap-rest"],["--twake-tombstone-dn","DM_TWAKE_TOMBSTONE_DN",[],"array",null,"dns"],["--twake-tombstone-default-reason","DM_TWAKE_TOMBSTONE_DEFAULT_REASON","deleted"],["--twake-tombstone-reason-header","DM_TWAKE_TOMBSTONE_REASON_HEADER","x-deletion-reason"],["--twake-tombstone-reasons","DM_TWAKE_TOMBSTONE_REASONS",[],"array",null,"identifiers"],["--twake-tombstone-clear-attributes","DM_TWAKE_TOMBSTONE_CLEAR_ATTRIBUTES",[],"array",null,"identifiers"],["--twake-tombstone-erase-min-age","DM_TWAKE_TOMBSTONE_ERASE_MIN_AGE",2592e3,"number"],["--twake-tombstone-group-bases","DM_TWAKE_TOMBSTONE_GROUP_BASES",[],"array",null,"dns"],["--twake-instance-dn","DM_TWAKE_INSTANCE_DN",[],"array",null,"dns"],["--twake-instance-skip-attribute","DM_TWAKE_INSTANCE_SKIP_ATTRIBUTE",""],["--twake-instance-skip-value","DM_TWAKE_INSTANCE_SKIP_VALUE",""],["--twake-instance-provider","DM_TWAKE_INSTANCE_PROVIDER","cloudery"],["--twake-instance-id","DM_TWAKE_INSTANCE_ID","{uid}"],["--twake-instance-fqdn-attribute","DM_TWAKE_INSTANCE_FQDN_ATTRIBUTE","twakeWorkspaceUrl"],["--twake-instance-sent-attribute","DM_TWAKE_INSTANCE_SENT_ATTRIBUTE","twakeCreatedEventAt"],["--twake-instance-locale","DM_TWAKE_INSTANCE_LOCALE",""],["--twake-instance-timeout","DM_TWAKE_INSTANCE_TIMEOUT",3e4,"number"],["--twake-instance-cloudery-url","DM_TWAKE_INSTANCE_CLOUDERY_URL",""],["--twake-instance-cloudery-token","DM_TWAKE_INSTANCE_CLOUDERY_TOKEN",""],["--twake-instance-cloudery-offer","DM_TWAKE_INSTANCE_CLOUDERY_OFFER","b2b_twake_default"],["--twake-instance-cloudery-domain","DM_TWAKE_INSTANCE_CLOUDERY_DOMAIN",""],["--twake-instance-cloudery-organization-offer","DM_TWAKE_INSTANCE_CLOUDERY_ORGANIZATION_OFFER",""],["--twake-instance-cozy-url","DM_TWAKE_INSTANCE_COZY_URL",""],["--twake-instance-cozy-user","DM_TWAKE_INSTANCE_COZY_USER","admin"],["--twake-instance-cozy-passphrase","DM_TWAKE_INSTANCE_COZY_PASSPHRASE",""],["--twake-instance-cozy-domain","DM_TWAKE_INSTANCE_COZY_DOMAIN",""],["--twake-instance-cozy-context","DM_TWAKE_INSTANCE_COZY_CONTEXT","default"],["--twake-instance-cozy-apps","DM_TWAKE_INSTANCE_COZY_APPS","home,drive,settings,notes,dataproxy"],["--twake-instance-cozy-org-id","DM_TWAKE_INSTANCE_COZY_ORG_ID",""],["--twake-instance-cozy-org-domain","DM_TWAKE_INSTANCE_COZY_ORG_DOMAIN",""],["--twake-instance-auth-exchange","DM_TWAKE_INSTANCE_AUTH_EXCHANGE","auth"],["--twake-instance-b2b-exchange","DM_TWAKE_INSTANCE_B2B_EXCHANGE","b2b"],["--twake-instance-user-created-key","DM_TWAKE_INSTANCE_USER_CREATED_KEY","user.created"],["--twake-instance-organization-created-key","DM_TWAKE_INSTANCE_ORGANIZATION_CREATED_KEY","organization.created"],["--twake-instance-workplace-created-key","DM_TWAKE_INSTANCE_WORKPLACE_CREATED_KEY","workplace.created"],["--twake-instance-queue","DM_TWAKE_INSTANCE_QUEUE","workplace.created.ldap-rest"],["--twake-instance-organization-base","DM_TWAKE_INSTANCE_ORGANIZATION_BASE",""],["--twake-instance-organization-id-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE","ou"],["--twake-instance-organization-domain-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE","twakeDomain"],["--twake-instance-organization-name-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE","description"],["--twake-instance-organization-fqdn-attribute","DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE",""],["--rabbitmq-url","DM_RABBITMQ_URL",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--applicative-account-attribute","DM_APPLICATIVE_ACCOUNT_ATTRIBUTES",["objectClass","cn","sn","givenName","displayName","description"],"array","--applicative-account-attributes","identifiers"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes","identifiers"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--llng-username-header","DM_LLNG_USERNAME_HEADER",""],["--auth-path-prefix","DM_AUTH_PATH_PREFIX",[],"array","--auth-path-prefixes"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-fake-user","DM_AUTH_FAKE_USER",""],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps","phrases"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs","phrases"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-route","DM_AUTHZ_PER_ROUTES",[],"array","--authz-per-routes"],["--authz-unresolved-user","DM_AUTHZ_UNRESOLVED_USER","deny"],["--authz-identity","DM_AUTHZ_IDENTITY","req.user"],["--authz-for","DM_AUTHZ_FOR",[],"array",null,"identifiers"],["--authz-combine","DM_AUTHZ_COMBINE",!1,"boolean"],["--authz-scope-source","DM_AUTHZ_SCOPE_SOURCE",""],["--authz-filter-attached-entries","DM_AUTHZ_FILTER_ATTACHED_ENTRIES",!1,"boolean"],["--authz-transit-branch","DM_AUTHZ_TRANSIT_BRANCH",""],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-dynamic-bypass","DM_AUTHZ_DYNAMIC_BYPASS",[],"array",null,"identifiers"],["--authz-dynamic-base","DM_AUTHZ_DYNAMIC_BASE",""],["--authz-dynamic-cache-ttl","DM_AUTHZ_DYNAMIC_CACHE_TTL",60,"number"],["--authz-dynamic-token-attribute","DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE","userPassword"],["--authz-dynamic-config-attribute","DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE","description"],["--authz-dynamic-tenant-attribute","DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE","cn"],["--authz-dynamic-reload-endpoint","DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",!1,"boolean"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-username-claim","DM_OIDC_USERNAME_CLAIM","sub"],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies","identifiers"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""],["--scim-prefix","DM_SCIM_PREFIX","/scim/v2"],["--scim-user-base","DM_SCIM_USER_BASE",""],["--scim-group-base","DM_SCIM_GROUP_BASE",""],["--scim-user-base-template","DM_SCIM_USER_BASE_TEMPLATE",""],["--scim-group-base-template","DM_SCIM_GROUP_BASE_TEMPLATE",""],["--scim-base-map","DM_SCIM_BASE_MAP",""],["--scim-user-base-header","DM_SCIM_USER_BASE_HEADER",""],["--scim-group-base-header","DM_SCIM_GROUP_BASE_HEADER",""],["--scim-base-header-root","DM_SCIM_BASE_HEADER_ROOT",""],["--scim-user-object-class","DM_SCIM_USER_OBJECT_CLASSES",["top","inetOrgPerson","organizationalPerson","person"],"array","--scim-user-object-classes","identifiers"],["--scim-user-rdn-attribute","DM_SCIM_USER_RDN_ATTRIBUTE","uid"],["--scim-user-lock-attribute","DM_SCIM_USER_LOCK_ATTRIBUTE","pwdAccountLockedTime"],["--scim-user-lock-value","DM_SCIM_USER_LOCK_VALUE",""],["--scim-group-object-class","DM_SCIM_GROUP_OBJECT_CLASSES",["top","groupOfNames"],"array","--scim-group-object-classes","identifiers"],["--scim-group-rdn-attribute","DM_SCIM_GROUP_RDN_ATTRIBUTE","cn"],["--scim-group-external-id-attribute","DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE",""],["--scim-id-attribute","DM_SCIM_ID_ATTRIBUTE","rdn"],["--scim-user-mapping","DM_SCIM_USER_MAPPING",""],["--scim-group-mapping","DM_SCIM_GROUP_MAPPING",""],["--scim-max-results","DM_SCIM_MAX_RESULTS",200,"number"],["--scim-max-scanned","DM_SCIM_MAX_SCANNED",1e4,"number"],["--scim-bulk-max-operations","DM_SCIM_BULK_MAX_OPERATIONS",100,"number"],["--scim-bulk-max-payload-size","DM_SCIM_BULK_MAX_PAYLOAD_SIZE",1048576,"number"],["--scim-etag","DM_SCIM_ETAG",!1,"boolean"],["--scim-base-url","DM_SCIM_BASE_URL",""]];export{t as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,h=o?t[o]:void 0,l=normalize(d),c=normalize(h);l.length===c.length&&l.every((e,t)=>e===c[t])||(a[o||s]=[l.length?d:null,c.length?h:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);for(const[a,s]of Object.entries(i))if(this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===s||"onLdapForwardChange"===s||"onLdapAliasChange"===s)this.notifyAttributeChangeWithMail(this.config[a],s,e,n,r);else if("onLdapMailChange"===s)this.notifyAttributeChange(this.config[a],s,e,n);else if("onLdapDriveQuotaChange"===s){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const s=this.reconstructDisplayName(a),o=this.reconstructDisplayName(r);s!==o&&t(this.server.hooks.onLdapDisplayNameChange,e,s,o)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",h=a[d];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else{if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];l=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,l,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,l,e,a)}}reconstructDisplayName(e){const getValue=t=>{const n=e?.[t];return n?Array.isArray(n)?n.length>0?String(n[0]):null:String(n):null},t=getValue("cn");if(t)return t;const n=getValue("givenName"),i=getValue("sn");if(n||i){const e=[];return n&&e.push(n),i&&e.push(i),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
1
+ import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,h=o?t[o]:void 0,l=normalize(d),u=normalize(h);l.length===u.length&&l.every((e,t)=>e===u[t])||(a[o||s]=[l.length?d:null,u.length?h:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);for(const[a,s]of Object.entries(i))if(this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===s||"onLdapForwardChange"===s||"onLdapAliasChange"===s)this.notifyAttributeChangeWithMail(this.config[a],s,e,n,r);else if("onLdapMailChange"===s)this.notifyAttributeChange(this.config[a],s,e,n);else if("onLdapDriveQuotaChange"===s){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const s=this.reconstructDisplayName(a),o=this.reconstructDisplayName(r);s!==o&&t(this.server.hooks.onLdapDisplayNameChange,e,s,o)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",h=a[d];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else{if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];l=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,l,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,l,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
@@ -1 +1 @@
1
- import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as o,validateDnValue as h,escapeDnValue as c,getRdn as d,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as _,ConflictError as b}from"../../lib/errors.js";import{extractLdapCode as y}from"../../lib/ldapCodes.js";import{assertClientMaySet as $,modifiedAttributeNames as v,missingRequiredAttribute as O,checkDnValues as C,matchesPattern as E}from"../../config/schema.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new z("An organization link cannot be deleted");if(e)throw new z("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,i.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(r(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;$(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${c(a.ou)},${s}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new z("dn is required");$(this.schema,v(a)),await n(i,this.modifyOrganization.bind(this),s,a,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new z("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new z("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new _(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new z(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(d(i))&&l(i,s)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let c=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?g(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!l(i,s)||p(i)===t||p(f(i))===t)return;throw new z(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new z(`Organization path must end with its own name, preceded by separator "${n}"`);c=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(c)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new z(`Invalid organization path ${e}: no organization holds "${c}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new z("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new _("Top organization not found");const[,a]=await w(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new _(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=y(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,{dn:`more-organizations-${t}`,cn:["... more organizations than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:n.length.toString()}]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==y(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);return this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`),[...r.searchEntries.slice(0,a),{dn:`more-${e}`,cn:["... more elements than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:Math.min(r.searchEntries.length,a).toString()}]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,{dn:`more-${e}`,cn:[`... ${r-a} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:r.toString(),_displayedCount:a.toString()}]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));const a=O(this.schema,i,this.server.loadedPlugins);if(a)throw new z(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new z(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new z(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new z(`Field ${t} must be a single value`);return await C(t,a,i,t=>this.entryExists(t)),E(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new _(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new _(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new z(`Target ${i} is not an organizational unit`);const n=`${d(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new _(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new _(`Source organization ${t} not found`);if(n===t)throw new z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new z("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50,s=u(i);e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)(|(ou=*${s}*)(description=*${s}*)))`,n,a));const r=this.config.ldap_top_organization,o=f(r),h=u(t),c=`(&(${this.config.ldap_organization_link_attribute}=${h})(|(uid=*${s}*)(cn=*${s}*)(mail=*${s}*)(sn=*${s}*)(givenName=*${s}*)))`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${o}`),e.push(...await this.linkedEntities(o,c,n,t,a)),e}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
1
+ import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as o,validateDnValue as h,escapeDnValue as c,getRdn as d,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as _,NotFoundError as y,ConflictError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{assertClientMaySet as v,modifiedAttributeNames as O,missingRequiredAttribute as C,checkDnValues as E,matchesPattern as A}from"../../config/schema.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new _("An organization link cannot be deleted");if(e)throw new _("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,i.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(r(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new _('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;v(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${c(a.ou)},${s}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new _("dn is required");v(this.schema,O(a)),await n(i,this.modifyOrganization.bind(this),s,a,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new _("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new _("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new _("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new y(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new _(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(d(i))&&l(i,s)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let c=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?g(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!l(i,s)||p(i)===t||p(f(i))===t)return;throw new _(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new _(`Organization path must end with its own name, preceded by separator "${n}"`);c=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(c)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new _(`Invalid organization path ${e}: no organization holds "${c}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new _("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new y("Top organization not found");const[,a]=await w(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new y(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,{dn:`more-organizations-${t}`,cn:["... more organizations than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:n.length.toString()}]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==$(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);return this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`),[...r.searchEntries.slice(0,a),{dn:`more-${e}`,cn:["... more elements than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:Math.min(r.searchEntries.length,a).toString()}]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,{dn:`more-${e}`,cn:[`... ${r-a} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:r.toString(),_displayedCount:a.toString()}]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new _(this.invalidValueMessage(t));const a=C(this.schema,i,this.server.loadedPlugins);if(a)throw new _(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new _(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new _(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new _(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new _(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new _(`Field ${t} must be a single value`);return await E(t,a,i,t=>this.entryExists(t)),A(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new y(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new y(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new _(`Target ${i} is not an organizational unit`);const n=`${d(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new y(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new y(`Source organization ${t} not found`);if(n===t)throw new _("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new _("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=f(s),o=u(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
@@ -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 o}from"../../lib/ldapCodes.js";import{parseDn as i,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,unescapeDnValue as p,normalizeDn as h}from"../../lib/utils.js";import d 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{valueOf as w,lifecycleAttributes as y,first as b}from"./lifecycleAttributes.js";const read=(t,e)=>b(w(t,e)),_="{org}",O=["displayName","description","createdAt"],v=["uid","displayName","mail","jobTitle"],$={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"},R="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 j=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&j.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 h(t)}catch{return t.toLowerCase()}}function parentOf(t){return i(t).slice(1).join(",")}function rdnValue(t){const[e]=i(t),r=e.indexOf("=");return r>0?p(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=i(t).join(",").split(_).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 k=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(i(e).join(",")));return r?.groups?p(r.groups.org):void 0}class TwakeGroups extends d{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>(this.checkMembers(t,e.member),void 0!==this.organizationOf(t)&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),[t,e,r]),ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const i=movedInto(this.groupPattern,t,r);if(void 0===i)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"base",attributes:["member"],[k]:!0},t);a=e[0]?.member}catch(t){if(32!==o(t))throw t}if(void 0!==this.foreign(i,a))throw new e(`The group holds members of another organization than ${i}`);return[t,r,s]},ldapsearchfilter:async([t,e,r])=>[r?.[k]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(i(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(_))throw new Error(`${this.name}: ${t} must hold ${_}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=y(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={...$,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:j.source},[this.createdAt]:{type:"string"}};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}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`)}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(_,a(t))}userBaseOf(t){return this.userBase.replace(_,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const o=this.foreign(s,r);if(void 0!==o)throw new e(`${o} 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.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===o(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);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,o=this.name){return async(i,a)=>{try{const t=i.params.id;await this.checkOrganization(t),await e(i,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:o,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){if(!this.orgPattern)return;const e=this.config.twake_group_organization_status_attribute||"twakeOrgStatus";let r;try{r=(await this.ldap.search({paged:!1,scope:"base",attributes:[e]},this.orgPattern.replace(_,a(t)))).searchEntries[0]}catch(t){if(32!==o(t))throw t}if(!r)throw notFound("organization");const s=this.config.twake_group_organization_deleted_value||"deleted";if(read(r,e)===s)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=>!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: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===o(t))return[];throw t}}async readGroup(t,e){try{const{searchEntries:r}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e));return r[0]&&this.groupOf(t,r[0])}catch(t){if(32===o(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===o(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let o=`(${this.cn}=*)`;if(s.search){const t=c(s.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)))`}const i=this.sorted((await this.groups(r,o)).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(N);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)");if(await this.named(s,o.name))throw new RouteError(409,"Group already exists","GROUP_EXISTS");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),await this.addGroup(this.groupDn(s,i),[],a,e);const n=await this.readGroup(s,i);r.status(201).json(n)}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(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){const t=await this.named(r,a);if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS");u[this.displayName]=a}void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.onGroup(()=>this.modifyGroup(this.groupDn(r,o),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,v),o=t.params.groupId,i=await this.readGroup(r,o);if(!i)throw notFound("group");e.json({organizationId:r,id:o,...await this.memberPage(s,r,i.members)})}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,s){const{deleted:i,deletedValue:a}=this.attrs,n=i?`(!(${i}=${c(a)}))`:"",u=[];for(let i=0;i<e.length;i+=this.maxPage){const a=`(&(|${e.slice(i,i+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);u.push(...e)}catch(t){if(32!==o(t))throw t}}return u}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),R])],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(I)){const s=read(i,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(i,R)?.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=await this.readGroup(r,o);if(!i)throw notFound("group");const a=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()],n=new Map((await this.users(r,a,[this.userAttribute])).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(a.some(t=>!n.has(t)))throw notFound("user");const c=new Set(i.members.map(t=>t.toLowerCase())),u=a.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),p=this.groupDn(r,o);u.length&&(await this.onGroup(()=>this.ldap.modify(p,{add:{member:u}},t)),await this.dropPlaceholder(p,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(o(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),i=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:i}},t)}catch(e){const r=o(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,_ 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{BadRequestError as e,AUTHZ_REFUSED as r,HttpError as s}from"../../lib/errors.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{parseDn as i,escapeDnValue as a,isDummyMemberDn as n,escapeLdapFilter as c,isAuthzRefusal as u,unescapeDnValue as p,normalizeDn as h}from"../../lib/utils.js";import d 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{valueOf as w,lifecycleAttributes as y,first as b}from"./lifecycleAttributes.js";const read=(t,e)=>b(w(t,e)),_="{org}",O=["displayName","description","createdAt"],v=["uid","displayName","mail","jobTitle"],$={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"},R="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 j=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,isColor=t=>"string"==typeof t&&j.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 h(t)}catch{return t.toLowerCase()}}function parentOf(t){return i(t).slice(1).join(",")}function rdnValue(t){const[e]=i(t),r=e.indexOf("=");return r>0?p(e.slice(r+1).trim()):t}function branchPattern(t){const[e,r]=i(t).join(",").split(_).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 k=Symbol("whole member lists");function organizationIn(t,e){const r=t.exec(parentOf(i(e).join(",")));return r?.groups?p(r.groups.org):void 0}class TwakeGroups extends d{constructor(r){super(r),this.name="twakeGroups",this.hooks={ldapaddrequest:([t,e,r])=>(this.checkMembers(t,e.member),void 0!==this.organizationOf(t)&&void 0!==w(e,this.displayName)&&void 0===w(e,this.createdAt)&&(e={...e,[this.createdAt]:(new Date).toISOString()}),[t,e,r]),ldapmodifyrequest:([t,e,r,s])=>(this.checkMembers(t,e.add?.member),this.checkMembers(t,e.replace?.member),[t,e,r,s]),ldaprenamerequest:async([t,r,s])=>{const i=movedInto(this.groupPattern,t,r);if(void 0===i)return[t,r,s];let a;try{const{searchEntries:e}=await this.ldap.search({paged:!1,scope:"base",attributes:["member"],[k]:!0},t);a=e[0]?.member}catch(t){if(32!==o(t))throw t}if(void 0!==this.foreign(i,a))throw new e(`The group holds members of another organization than ${i}`);return[t,r,s]},ldapsearchfilter:async([t,e,r])=>[r?.[k]?t:await this.hideTombstones(t),e,r],scimgroupcreate:([e,r,s])=>(s??(s=new m(this.config).groupBase(r)),[this.groupPattern.test(i(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(_))throw new Error(`${this.name}: ${t} must hold ${_}`);this.groupPattern=branchPattern(this.groupBase),this.attrs=y(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={...$,...this.config.twake_group_member_fields}}adaptSchema(t){const e={[this.displayName]:{type:"string",required:!0},[this.color]:{type:"string",test:j.source},[this.createdAt]:{type:"string"}};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}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`)}organizationOf(t){return organizationIn(this.groupPattern,t)}groupBaseOf(t){return this.groupBase.replace(_,a(t))}userBaseOf(t){return this.userBase.replace(_,a(t))}checkMembers(t,r){const s=this.organizationOf(t);if(!s)return;const o=this.foreign(s,r);if(void 0!==o)throw new e(`${o} 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.search({paged:!1,scope:"one",filter:`(${e}=${c(r)})`,attributes:["dn"]},this.userBaseOf(t));return s.map(t=>t.dn)}catch(t){if(32===o(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);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,o=this.name){return async(i,a)=>{try{const t=i.params.id;await this.checkOrganization(t),await e(i,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:o,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){if(!this.orgPattern)return;const e=this.config.twake_group_organization_status_attribute||"twakeOrgStatus";let r;try{r=(await this.ldap.search({paged:!1,scope:"base",attributes:[e]},this.orgPattern.replace(_,a(t)))).searchEntries[0]}catch(t){if(32!==o(t))throw t}if(!r)throw notFound("organization");const s=this.config.twake_group_organization_deleted_value||"deleted";if(read(r,e)===s)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=>!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: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===o(t))return[];throw t}}async readGroup(t,e){try{const{searchEntries:r}=await this.ldap.search({paged:!1,scope:"base"},this.groupDn(t,e));return r[0]&&this.groupOf(t,r[0])}catch(t){if(32===o(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===o(t))throw notFound("group");throw t}}async listRoute(t,e,r){const s=this.page(t,O);let o=`(${this.cn}=*)`;if(s.search){const t=c(s.search);o=`(&${o}(|(${this.displayName}=*${t}*)(description=*${t}*)(&(!(${this.displayName}=*))(${this.cn}=*${t}*))))`}const i=this.sorted((await this.groups(r,o)).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(N);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)");if(await this.named(s,o.name))throw new RouteError(409,"Group already exists","GROUP_EXISTS");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),await this.addGroup(this.groupDn(s,i),[],a,e);const n=await this.readGroup(s,i);r.status(201).json(n)}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(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){const t=await this.named(r,a);if(t&&t!==o)throw new RouteError(409,"Group already exists","GROUP_EXISTS");u[this.displayName]=a}void 0!==n&&(u.description=n||[]),void 0!==c&&(u[this.color]=c||[]),await this.onGroup(()=>this.modifyGroup(this.groupDn(r,o),{replace:u},t)),e.json({success:!0})}async membersRoute(t,e,r){const s=this.page(t,v),o=t.params.groupId,i=await this.readGroup(r,o);if(!i)throw notFound("group");e.json({organizationId:r,id:o,...await this.memberPage(s,r,i.members)})}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,s){const{deleted:i,deletedValue:a}=this.attrs,n=i?`(!(${i}=${c(a)}))`:"",u=[];for(let i=0;i<e.length;i+=this.maxPage){const a=`(&(|${e.slice(i,i+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);u.push(...e)}catch(t){if(32!==o(t))throw t}}return u}async profiles(t,e,r){const s=[...new Set([...Object.values(this.memberFields),...Object.values(I),R])],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(I)){const s=read(i,e);void 0!==s&&(r[t]=s)}Object.keys(r).length&&(e.name=r),e.isTechnical="TRUE"===read(i,R)?.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=await this.readGroup(r,o);if(!i)throw notFound("group");const a=[...new Map(s.map(t=>[t.toLowerCase(),t])).keys()],n=new Map((await this.users(r,a,[this.userAttribute])).map(t=>{const e=read(t,this.userAttribute)||"";return[e.toLowerCase(),e]}));if(a.some(t=>!n.has(t)))throw notFound("user");const c=new Set(i.members.map(t=>t.toLowerCase())),u=a.filter(t=>!c.has(t)).map(t=>this.userDn(r,n.get(t))),p=this.groupDn(r,o);u.length&&(await this.onGroup(()=>this.ldap.modify(p,{add:{member:u}},t)),await this.dropPlaceholder(p,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(o(t)??0))throw t}}async removeMemberRoute(t,e,r){const s=this.groupDn(r,t.params.groupId),i=this.userDn(r,t.params.userId);try{await this.ldap.modify(s,{delete:{member:i}},t)}catch(e){const r=o(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,_ as ORG,RouteError,bodyOf,branchPattern,TwakeGroups as default,dnKey,invalid,isDisplayName,movedInto,notFound,organizationIn,parentOf,rdnValue,read,values};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.quotaAttr=this.config.quota_attribute||"mailQuotaSize",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}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],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(r,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(t=>t.email===i)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(e),d=`${this.webadminUrl}/users/${i}/identities/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const u=await this.requestLimit(()=>t(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));u.ok?this.logger.info({...s,result:"success",http_status:u.status}):this.logger.error({...s,step:"update_identity",http_status:u.status,http_status_text:u.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(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.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 i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)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]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,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{James as default};
1
+ import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute||"mailQuotaSize",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}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],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(`${r}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(n.ok){const t=await n.json();l=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==n.status)return void this.logger.error({...s,step:"get_default_identity",http_status:n.status,http_status_text:n.statusText});const o=await this.generateSignature(e),m=l?.id?{id:l.id,email:l.email,name:a}:{email:i,name:a,sortOrder:0};o&&(m.htmlSignature=o);const d=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>t(l?.id?`${r}/${l.id}`:r,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));h.ok?this.logger.info({...s,step:d,result:"success",http_status:h.status}):this.logger.error({...s,step:d,http_status:h.status,http_status_text:h.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(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.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 i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)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]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,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{James as default};
@@ -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 r}from"../../lib/operation.js";import{escapeDnValue as i,escapeLdapFilter as n,parseDn as o}from"../../lib/utils.js";import{organizationIn as h,dnKey as c,movedInto as l,ORG as d,branchPattern as p,values as u,parentOf as g,read as m,rdnValue as f,notFound as w,invalid as b,bodyOf as y,isDisplayName as v,NAME_RULE as k,RouteError as O}from"./groups.js";import{isTombstone as $,valueOf as I,lifecycleAttributes as _}from"./lifecycleAttributes.js";const C=["viewer","editor","admin"],isRole=e=>C.includes(e),L=[...C].reverse(),M=["name"],S=["uid","displayName","mail","jobTitle","role"],j=new RegExp(`:(${C.join("|")})$`,"i"),conflict=(e,t)=>new O(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 b(`role must be one of: ${C.join(", ")}`);return e.role}function checkLength(e,t,s){if(e.length>s)throw b(`${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 b(`${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 b(`${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 b(`${a} must be an array of { ${t}, role }, role one of: ${C.join(", ")}`);if(r.has(s.toLowerCase()))throw b(`${s} is listed twice in ${a}`);return r.add(s.toLowerCase()),{name:s,role:i}})}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.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 r=l(this.spacePattern,e,t);if(void 0===r)return[e,t,a];const i=Object.values(this.roleAttributes),n=await this.entry(e,i);if(n&&i.some(e=>void 0!==this.foreign(r,I(n,e))))throw new s(`The space holds users or groups of another organization than ${r}`);return[e,t,a]},ldapdeleterequest:async([e,t])=>{const s=r();if(void 0!==s)for(const t of[e].flat()){const e=h(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(c(t),{org:e,ids:a})}else if(this.rabbitmq){const e=await this.deletedGroup(t);e&&this.unlinking.set(c(t),{...e,op:s})}}return[e,t]},ldapdeletedone:(e,t={})=>{const s=r(),a=void 0===s?void 0:this.handing.get(s);for(const s of[e].flat()){const e=this.unlinking.get(c(s));e&&delete e.op;const r=a?.get(c(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)=>{this.follows&&this.queue(e,()=>this.follow(e,t,s,a));const r=h(this.userPattern,e);void 0!==r&&this.lifecycle.deleted&&t&&s&&!$(t,this.lifecycle)&&$(s,this.lifecycle)&&this.queue(e,async()=>this.handOver(r,e,await this.administered(r,e),a))}},this.spaceBase=this.config.twake_space_base||"",!this.spaceBase.includes(d))throw new Error(`${this.name}: --twake-space-base must hold ${d}`);this.spacePattern=p(this.spaceBase),this.userPattern=p(this.config.twake_group_user_base),this.lifecycle=_(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.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.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=o(e).reverse();return e=>o(e).reverse().slice(0,t.length).every((e,s)=>t[s].includes(d)?type(e)===type(t[s]):c(e)===c(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 h(this.spacePattern,e)}spaceBaseOf(e){return this.spaceBase.replace(d,i(e))}spaceDn(e,t){return`cn=${i(t)},${this.spaceBaseOf(e)}`}async entry(e,t){try{return(await this.server.ldap.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 r=this.foreign(a,t);if(void 0!==r)throw new s(`${r} is neither a user nor a group of organization ${a}`)}foreign(e,t){const s=[this.groups.userBaseOf(e),this.groups.groupBaseOf(e)].map(c);return u(t).find(e=>!s.includes(c(g(e))))}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=y(e),r=Object.keys(a).filter(e=>"name"!==e);if(r.length)throw b(`Only "name" can be updated; unsupported field(s): ${r.join(", ")}`);if(!v(a.name))throw b(k);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 w("space");throw e}done(t)})),e.get(`${t}/:spaceId/members`,route("listing space members",async(e,t,s)=>{const a=this.groups.page(e,S),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)})})),e.post(`${t}/:spaceId/members`,route("adding space members",async(e,t,s)=>{const a=y(e),r=namesOf(a.usernames,"usernames",this.groups.maxPage);await this.add(e,s,"member",roleOf(a),()=>this.liveUsers(s,r,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(y(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),r=a.holders.filter(e=>"group"===e.kind),i=new Map((await this.orgGroups(s,r.map(e=>e.name),[],e)).map(e=>[c(e.dn),this.groups.groupOf(s,e).displayName])),n=r.map(({name:e,role:t,dn:s})=>({id:e,name:i.get(c(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=y(e),r=namesOf(a.groupIds,"groupIds",this.groups.maxPage);await this.add(e,s,"group",roleOf(a),()=>this.groupDns(s,r,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(y(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)}))}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([[c(this.groups.userBaseOf(e)),"member"],[c(this.groups.groupBaseOf(e)),"group"]]),r=m(t,"cn")||"",i=new Map;for(const e of L)for(const r of u(I(t,this.roleAttributes[e]))){const t=c(r),n=a.get(c(g(r)));if(!n||s.has(t))continue;const o=i.get(t);o?o.roles.push(e):i.set(t,{kind:n,name:f(r),role:e,roles:[e],dn:r})}return{id:r,name:m(t,this.displayName)??r,holders:[...i.values()]}}async spaces(e,t,s,r,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),r)).searchEntries}catch(e){if(32===a(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(c))}holding(e){return`(|${e.flatMap(e=>Object.values(this.roleAttributes).map(t=>`(${t}=${n(e)})`)).join("")})`}async space(e,t,s){const[a]=await this.spaces(e,"(objectClass=*)",t,s);if(!a)throw w("space");return a}async write(e,t,s,r){try{await this.server.ldap.modify(this.spaceDn(e,t),s,r)}catch(e){if(32===a(e))throw w("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,r=new Map((await this.groups.users(e,t,[a],s)).map(e=>[(m(e,a)||"").toLowerCase(),e.dn]));if(t.some(e=>!r.has(e.toLowerCase())))throw w("user");return r}get groupName(){return this.config.twake_group_display_name_attribute||"twakeDisplayName"}async orgGroups(e,t,s=[],a){const r=this.groups.cn,i=[];for(let o=0;o<t.length;o+=this.groups.maxPage){const h=t.slice(o,o+this.groups.maxPage);i.push(...await this.groups.groups(e,`(|${h.map(e=>`(${r}=${n(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=>[c(e.dn),e.role]))),h=[e,i].flatMap(e=>e?.holders??[]).filter(e=>n.length>1||o[0].get(c(e.dn))!==o[1].get(c(e.dn))),l=new Set(h.filter(e=>"group"===e.kind).map(e=>e.name)),d=await this.orgGroups(r,[...l],["member"]),p=(i??e).id,g=await this.settle(r,[...h.filter(e=>"member"===e.kind).map(e=>e.dn),...d.flatMap(e=>u(e.member))],{id:p,space:e},await this.hidden(r));return void(this.rabbitmq&&await this.announce(r,g,a,n,{was:e,is:i,linked:d}))}if(r=this.groups.organizationOf(e),void 0===r||!t)return;const i=u(t.member),n=u(s?.member),[o,h]=[i,n].map(e=>new Set(e.map(c))),l=s?void 0:this.unlinking.get(c(e));l&&this.unlinking.delete(c(e));const d=await this.hidden(r),p=await this.settle(r,[...i.filter(e=>!h.has(c(e))),...n.filter(e=>!o.has(c(e)))],{group:e,members:o,spaces:l?.spaces},d);if(!this.rabbitmq||!l&&!p.length)return;const g=(await this.spaces(r,this.holding([e]),void 0,void 0,d)).map(e=>e.id);l&&this.unlinked(e,l,g,a),await this.announce(r,p,a,[...l?.spaces.map(e=>e.id)??[],...g])}async administered(e,t){try{const s=`(${this.roleAttributes.admin}=${n(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=c(t),[i]=await this.spaces(e,"(objectClass=*)",s),n=i?.holders.filter(e=>"member"===e.kind&&c(e.dn)!==r)??[];if(!i||n.some(e=>"admin"===e.role))return;if(!n.length)return void await this.server.ldap.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.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=>c(t.dn)===c(e));return s?[{id:t.id,role:s.role}]:[]});if(!s.length)return;const[a]=await this.orgGroups(t,[f(e)]),r=f(e);return{org:t,id:r,name:a&&m(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=c(this.spaceDn(r,t));h.has(t.toLowerCase())&&(this.announced.has(o)||this.announced.set(o,new Set),this.announced.get(o).add(c(e))),this.publishEvent(r,t,"group.unlinked",a,{groups:[{id:i,name:n,role:s}]})}}async settle(e,t,s,a){const r=c(this.groups.userBaseOf(e)),i=new Map(t.filter(e=>c(g(e))===r).map(e=>[c(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=${n(t)})`,[this.groups.cn],s)).map(e=>c(e.dn)),i=new Set([c(t),...r]),o=[t,...r];if(a&&"group"in a){const e=c(a.group);i.delete(e),a.members.has(c(t))&&i.add(e),o.push(a.group)}return{filter:this.holding(o),roleIn:e=>e.holders.find(e=>i.has(c(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,l=new Map(o.map(e=>[c(e.dn),m(e,this.groupName)||f(e.dn)])),groupOf=e=>({id:e.name,name:l.get(c(e.dn))??e.name,role:e.role}),publish=(t,a)=>this.publishEvent(e,h,t,s,a),d=c(this.spaceDn(e,h)),p=new Set;for(const t of[a,n]){const s=t&&c(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=>[c(e.dn),e]));for(const t of n.holders.filter(e=>"group"===e.kind)){const s=e.get(c(t.dn));e.delete(c(t.dn)),s?.role!==t.role&&publish(s?"group.role.changed":"group.linked",{groups:[groupOf(t)]})}const byDeletion=e=>{if(p.delete(c(e.dn)))return!0;const t=this.unlinking.get(c(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,r]=[a,n].map(e=>new Set(e.holders.map(e=>c(e.dn)))),i=[...p].filter(e=>t.has(e)&&r.has(e));i.length&&this.announced.set(d,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(c(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(c(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(f))],[s,"entryUUID",a,"givenName","sn"])).map(e=>[(m(e,s)||"").toLowerCase(),e]));return new Map(t.flatMap(e=>{const t=r.get(f(e).toLowerCase());if(!t)return[];const i={uuid:m(t,"entryUUID"),username:m(t,s),email:m(t,a),firstName:m(t,"givenName"),lastName:m(t,"sn")};return[[c(e),i]]}))}publishEvent(e,t,s,a,r={}){const i=this.rabbitmq;if(!i)return;const n={organizationId:e,id:t,...r,actor:a.actor,timestamp:(new Date).toISOString()};this.publishing=this.publishing.then(()=>this.publish(i,s,n))}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()};try{if(!await t.getRawClient())return void this.logger.error({...r,result:"no broker"});await t.publish(r.exchange,r.routingKey,a,{messageId:r.messageId}),this.logger.info({...r,result:"published"})}catch(e){this.logger.error({...r,result:"error",error:String(e)})}}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(c(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,r=[...t].map(([e,t])=>`${e}:${t}`),i=new Set(r.map(e=>e.toLowerCase()));for(let t=0;;t++)try{const[t]=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[s]},e)).searchEntries;if(!t)return;const a=u(I(t,s)),n=new Set(a.map(e=>e.toLowerCase())),o=a.filter(e=>j.test(e)&&!i.has(e.toLowerCase())),h=r.filter(e=>!n.has(e.toLowerCase()));return(o.length||h.length)&&await this.server.ldap.modify(e,{...o.length&&{delete:{[s]:o}},...h.length&&{add:{[s]:h}}}),new Map(a.flatMap(e=>{const t=j.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=>[(m(e,this.groups.cn)||"").toLowerCase(),e.dn]));if(t.some(e=>!a.has(e.toLowerCase())))throw w("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 w(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,M);let r="(cn=*)";a.search&&(r=`(&${r}(${this.displayName}=*${n(a.search)}*))`);const i=e.query.user;let o;if(void 0!==i){if("string"!=typeof i||!i)throw b("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})`,o=a.roleIn}const h=this.groups.sorted((await this.spaces(s,r,void 0,e)).map(e=>({...this.spaceOf(s,e),...o&&{role:o(e)}})),a.sortBy,a.desc);t.json({organizationId:s,spaces:h.slice(a.offset,a.offset+a.limit),pagination:{page:a.page,limit:a.limit,total:h.length,totalPages:Math.ceil(h.length/a.limit)}})}async createRoute(t,s,a){const r=y(t);if(!v(r.name))throw b(k);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 b("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)]),l=e(),d={objectClass:this.config.twake_space_class||["top","twakeSpace"],cn:l,[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];d[e]=[...u(d[e]),t.get(s.toLowerCase())]}await this.server.ldap.add(this.spaceDn(a,l),d,t),s.status(201).json(this.spaceOf(a,await this.space(a,l,t)))}}export{C as SPACE_ROLES,TwakeSpaces as default};
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.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.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.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.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.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.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};
@@ -237,6 +237,7 @@ export interface Config {
237
237
  twake_space_viewer_attribute?: string;
238
238
  twake_space_user_role_attribute?: string;
239
239
  twake_space_exchange?: string;
240
+ twake_space_sync_queue?: string;
240
241
  twake_instance_dn?: string[];
241
242
  twake_instance_skip_attribute?: string;
242
243
  twake_instance_skip_value?: string;
@@ -36,9 +36,15 @@ declare class OnLdapChange extends DmPlugin {
36
36
  notifyAttributeChange(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, stringOnly?: boolean): void;
37
37
  notifyAttributeChangeWithMail(attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, after: Entry | null): void;
38
38
  /**
39
- * Reconstruct display name from cn, givenName, and sn attributes
39
+ * The name the James plugin gives an identity, in the same order: the
40
+ * display name attribute, then cn, then givenName + sn
41
+ * (`getDisplayNameFromAttributes` in plugins/twake/james.ts). Reading cn
42
+ * first, as this did, missed a change of displayName alone, the attribute
43
+ * the console and the schemas edit. The James plugin's last resort, the
44
+ * mail, is left out: a mail change already has its own hook, and an entry
45
+ * without any name would turn every mail change into a name change.
40
46
  * @param entry - The entry, before or after the change
41
- * @returns The reconstructed display name or null
47
+ * @returns The display name or null
42
48
  */
43
49
  reconstructDisplayName(entry: Entry | null): string | null;
44
50
  }
@@ -113,7 +113,7 @@ export default class TwakeGroups extends LdapGroups {
113
113
  get userAttribute(): string;
114
114
  userDn(org: string, username: string): string;
115
115
  /** Every route answers 404 for a missing organization, 410 for a deleted one. */
116
- private checkOrganization;
116
+ checkOrganization(org: string): Promise<void>;
117
117
  /** Page, search and sort of a list route, or a refusal of them. */
118
118
  page(req: Request, sortable: string[]): Page;
119
119
  sorted<T extends Record<string, unknown>>(items: T[], sortBy: string | undefined, desc: boolean): T[];
@@ -85,7 +85,25 @@ export default class James extends TwakePlugin {
85
85
  getMailFromDN(dn: string): Promise<string | null>;
86
86
  getDisplayNameFromDN(dn: string): Promise<string | null>;
87
87
  generateSignature(dn: string): Promise<string | null>;
88
+ /**
89
+ * Name the user's default identity after the directory.
90
+ *
91
+ * The default identity is Twake Mail's: the deletable one with the lowest
92
+ * sortOrder (`GET /users/{mail}/identities?default=true`). It is updated
93
+ * when there is one and created otherwise. The server-set identity
94
+ * (mayDelete false, named after the address) is never touched: a PUT on it
95
+ * makes Twake Mail add, a few seconds later, a deletable identity named
96
+ * "givenName sn", so the user ended up with two identities for one
97
+ * address, one of them named after neither the directory's display name
98
+ * nor the address.
99
+ *
100
+ * Calls for one address run one after the other: an add triggers this twice
101
+ * (ldapadddone, then the display name "change" onChange publishes), and two
102
+ * concurrent first calls would both find no default and both create one.
103
+ */
88
104
  updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
105
+ private identityQueue;
106
+ private doUpdateJamesIdentity;
89
107
  /**
90
108
  * Delete user data via James WebAdmin API
91
109
  * Calls POST /users/{mail}?action=deleteData
@@ -30,6 +30,9 @@ export default class TwakeSpaces extends DmPlugin {
30
30
  readonly roleAttributes: Record<SpaceRole, string>;
31
31
  private readonly userRole;
32
32
  private readonly exchange;
33
+ private readonly domainAttribute;
34
+ private readonly orgPattern?;
35
+ private readonly domains;
33
36
  /**
34
37
  * Changes are followed one at a time, each from the directory as it is
35
38
  * then, so the order they are followed in does not matter.
@@ -62,6 +65,9 @@ export default class TwakeSpaces extends DmPlugin {
62
65
  * core/rabbitmq connects lazily and hands back no client when it cannot:
63
66
  * every event would then be lost, so the server does not start.
64
67
  *
68
+ * The organization domain is only read, and a search for an attribute the
69
+ * schema does not define finds none: every event would go out without it.
70
+ *
65
71
  * core/ldap/trash moves a deleted group or space away instead of deleting
66
72
  * it: nothing follows the move, so users would keep the roles it gave,
67
73
  * and no event would say it went.
@@ -78,7 +84,7 @@ export default class TwakeSpaces extends DmPlugin {
78
84
  private checkHolders;
79
85
  /** A holder that is neither a user nor a group of the organization. */
80
86
  private foreign;
81
- api(app: Express): void;
87
+ api(app: Express): Promise<void>;
82
88
  /** A space as the routes answer it, strongest role first. */
83
89
  private spaceOf;
84
90
  /**
@@ -161,6 +167,26 @@ export default class TwakeSpaces extends DmPlugin {
161
167
  /** Users as space events name them, by DN key, live users only. */
162
168
  private profiles;
163
169
  private publishEvent;
170
+ private enqueue;
171
+ /**
172
+ * Every organization is asked in a request of its own, through the queue:
173
+ * the broker closes the channel of a consumer that holds a message longer
174
+ * than its consumer_timeout (30 minutes by default), and delivers every
175
+ * message in flight on it again.
176
+ */
177
+ private onSyncRequested;
178
+ /** The entries where `--twake-space-base` puts an organization. */
179
+ private organizations;
180
+ /**
181
+ * The events bear the time before the read, so an event of a later write
182
+ * is newer. Fails once they are all out if one was not sent, so the
183
+ * request is retried.
184
+ */
185
+ private sync;
186
+ /** A space as `twake.space.created` gives it. */
187
+ private whole;
188
+ /** Kept once found, until the organization entry changes. */
189
+ private domainOf;
164
190
  private get rabbitmq();
165
191
  private publish;
166
192
  /** A user's roles, in the directory as it is or with a change undone. */
@@ -267,7 +267,7 @@ Monitors LDAP modifications and triggers hooks for attribute changes. No configu
267
267
  - `onLdapQuotaChange` - Quota attribute changes
268
268
  - `onLdapAliasChange` - Alias attribute changes
269
269
  - `onLdapForwardChange` - Forward attribute changes
270
- - `onLdapDisplayNameChange` - Display name changes (cn, givenName, sn)
270
+ - `onLdapDisplayNameChange` - Display name changes (`--display-name-attribute`, else cn, else givenName and sn)
271
271
 
272
272
  #### `core/ldap/departmentSync`
273
273
 
@@ -606,18 +606,20 @@ See [groups](plugins/integrations/groups.md).
606
606
 
607
607
  Requires `core/twake/groups`, whose options it reads for users, groups and the
608
608
  organization entry, and `core/ldap/onChange`; with `--rabbitmq-url`, it
609
- requires `core/rabbitmq`.
610
-
611
- | CLI | Env | Default | Description |
612
- | -------------------------------------- | --------------------------------------- | ------------------ | -------------------------------------------------- |
613
- | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, with `{org}` |
614
- | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
615
- | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
616
- | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
617
- | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
618
- | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
619
- | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
620
- | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
609
+ requires `core/rabbitmq` and reads the organization domain from
610
+ `--twake-instance-organization-domain-attribute`.
611
+
612
+ | CLI | Env | Default | Description |
613
+ | -------------------------------------- | --------------------------------------- | -------------------------------------- | -------------------------------------------------- |
614
+ | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, with `{org}` |
615
+ | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
616
+ | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
617
+ | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
618
+ | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
619
+ | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
620
+ | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
621
+ | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
622
+ | `--twake-space-sync-queue` | `DM_TWAKE_SPACE_SYNC_QUEUE` | `twake.space.sync.requested.ldap-rest` | Queue of the space sync requests |
621
623
 
622
624
  See [spaces](plugins/integrations/spaces.md).
623
625
 
@@ -62,8 +62,9 @@ A group reads:
62
62
  display name reads its `cn`.
63
63
 
64
64
  - `GET /groups?page&limit&search&sortBy&sortOrder`: `search` (2 characters
65
- or more; an empty one lists everything) matches the display name and the
66
- description; `sortBy` is `displayName`, `description` or `createdAt`. Answers
65
+ or more; an empty one lists everything) matches the display name — the
66
+ `cn` when the group has none — and the description; `sortBy` is
67
+ `displayName`, `description` or `createdAt`. Answers
67
68
  `{ organizationId, groups, pagination: { page, limit, total, totalPages } }`.
68
69
  - `POST /groups` with `{ name, description?, color? }`: `name` is at most 256
69
70
  characters, not blank, without control characters, and unique in the
@@ -146,6 +146,34 @@ When a user's quota changes:
146
146
  5000000000
147
147
  ```
148
148
 
149
+ ### Display Name and Identity
150
+
151
+ When a user is added, or their display name changes, the plugin names their
152
+ default identity in Twake Mail after the directory. The name is the first one
153
+ found in `--display-name-attribute` (`displayName` by default), `cn`, then
154
+ `givenName` and `sn`. The optional signature is `--james-signature-template`.
155
+
156
+ 1. **James plugin** reads the default identity, the deletable one with the
157
+ lowest `sortOrder`:
158
+
159
+ ```http
160
+ GET /users/jdoe@company.com/identities?default=true
161
+ ```
162
+
163
+ 2. It updates that identity, or creates it when the user has none (404):
164
+
165
+ ```http
166
+ PUT /users/jdoe@company.com/identities/{id}
167
+ POST /users/jdoe@company.com/identities
168
+ {"email": "jdoe@company.com", "name": "John Doe", "sortOrder": 0}
169
+ ```
170
+
171
+ The server-set identity, which James derives from the address and which
172
+ cannot be deleted, is left alone: an update of it makes Twake Mail add a
173
+ second identity for the same address, named "givenName sn". Calls for one
174
+ address run one after the other, so that the two hooks an add fires create a
175
+ single identity.
176
+
149
177
  ### Email Aliases Management
150
178
 
151
179
  The plugin automatically manages email aliases stored in LDAP and syncs them to James.
@@ -69,6 +69,7 @@ every write, whichever API makes it, shortly after it.
69
69
  ```json
70
70
  {
71
71
  "organizationId": "acme",
72
+ "organizationDomain": "acme.com",
72
73
  "id": "3b9e2c71-5d4a-4f0e-9c8b-1a2d6e7f8091",
73
74
  "members": [
74
75
  {
@@ -89,6 +90,16 @@ Every event carries `organizationId`, `id` (the space), `actor` (who made
89
90
  the write, when known) and `timestamp`. A member is described as above, its
90
91
  `uuid` the user's `entryUUID`; a group is `{ id, name, role }`.
91
92
 
93
+ `organizationDomain` is the organization's domain, read on its entry
94
+ (`--twake-group-organization-dn`) from
95
+ `--twake-instance-organization-domain-attribute` (default `twakeDomain`), the
96
+ option `twake/instances` reads too. It is kept until the entry is changed
97
+ through this server: another replica, or a write made straight to the
98
+ directory, keeps the old domain until restart. It is left out when
99
+ `--twake-group-organization-dn` is empty or the entry has no such value. The
100
+ server does not start when the directory schema does not define that
101
+ attribute.
102
+
92
103
  Writes made close together blur the actor: the member events of two writes
93
104
  of one space or group followed together may go out under the first one's.
94
105
  With `--twake-space-user-role-attribute`, the follow of a write may find a
@@ -111,11 +122,50 @@ announce.
111
122
  followed, is unlinked too, under its id for a name. Renaming a linked group
112
123
  publishes nothing.
113
124
 
125
+ - `synced`: the whole space, as `created` gives it, published by a sync.
126
+ - `sync.completed`: `spaceIds`, the ids of every space of the organization,
127
+ after its `synced` events. It has no `id`.
128
+
114
129
  A user who is deleted or becomes a tombstone publishes no member event.
115
130
 
131
+ ## Sync
132
+
133
+ An app repairs its copy of the spaces by publishing
134
+ `twake.space.sync.requested` on the exchange:
135
+
136
+ - `{ "organizationId", "id" }`: the space's `synced`, or `deleted` when it
137
+ is gone;
138
+ - `{ "organizationId" }`: a `synced` for each space of the organization,
139
+ then `sync.completed`;
140
+ - `{}`: a `twake.space.sync.requested` with `organizationId` for each
141
+ organization, the entries one level under the parent of `{org}` in
142
+ `--twake-space-base`, published on the exchange, so every organization is
143
+ synced as above.
144
+
145
+ A request reaches every queue bound to its key, `twake.space.#` included.
146
+ An app that follows the events binds `twake.space.*`, `twake.space.member.#`,
147
+ `twake.space.group.#` and `twake.space.sync.completed`, or ignores
148
+ `twake.space.sync.requested`.
149
+
150
+ The plugin consumes these requests from the `--twake-space-sync-queue`
151
+ quorum queue, with a dead letter queue `<queue>.dlq`. Every replica consumes
152
+ it, several requests at once, so the events of two syncs of one organization
153
+ may interleave: their timestamps order them. A request it cannot read goes
154
+ to the dead letter queue. A missing organization is skipped. A deleted one
155
+ has no space left: its `sync.completed` lists none, and a space asked is
156
+ `deleted`. A sync whose events the broker does not take fails, and its
157
+ request is retried, then goes to the dead letter queue.
158
+
159
+ The events of a sync carry no `actor`. They all carry the time just before
160
+ the spaces were read, so, with synchronized clocks, the event of a write
161
+ followed after that is newer.
162
+ An app that applies only events newer than the last one keeps the right
163
+ state, and keeps a space created after a `sync.completed` that does not list
164
+ it.
165
+
116
166
  The server does not start when the broker cannot be reached. An event the
117
- broker cannot take once the server runs is logged with `result: "error"` and
118
- not sent again.
167
+ broker cannot take once the server runs is logged with `result: "error"`;
168
+ the event of a write is not sent again.
119
169
 
120
170
  Member events compare each moved user's roles before and after a change:
121
171
 
@@ -210,6 +260,9 @@ organization's users, groups and organization entry.
210
260
  Unset, no user entry is written.
211
261
  - `--twake-space-exchange` (default `space`): the topic exchange of the
212
262
  events, published only when `--rabbitmq-url` is set.
263
+ - `--twake-space-sync-queue` (default
264
+ `twake.space.sync.requested.ldap-rest`): the queue of the sync requests,
265
+ consumed only when `--rabbitmq-url` is set.
213
266
 
214
267
  ## Dependencies
215
268
 
@@ -36,15 +36,15 @@ A write no request is behind, such as a scheduled task's, gets `{}`. The LDAP
36
36
 
37
37
  The other hooks are derived from it:
38
38
 
39
- | Hook | Fires when | Parameters |
40
- | ------------------------- | ---------------------------------------------------------- | -------------------------------------- |
41
- | `onLdapChange` | any attribute changed | `(dn, changes)` |
42
- | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` |
43
- | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` |
44
- | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` |
45
- | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` |
46
- | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` |
47
- | `onLdapDisplayNameChange` | the name built from `cn`, or `givenName` and `sn`, changed | `(dn, oldName, newName)` |
39
+ | Hook | Fires when | Parameters |
40
+ | ------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------- |
41
+ | `onLdapChange` | any attribute changed | `(dn, changes)` |
42
+ | `onLdapMailChange` | `--mail-attribute` changed | `(dn, oldMail, newMail)` |
43
+ | `onLdapQuotaChange` | `--quota-attribute` changed | `(dn, mail, oldQuota, newQuota)` |
44
+ | `onLdapAliasChange` | `--alias-attribute` changed | `(dn, mail, oldAliases, newAliases)` |
45
+ | `onLdapForwardChange` | `--forward-attribute` changed | `(dn, mail, oldForwards, newForwards)` |
46
+ | `onLdapDriveQuotaChange` | `--drive-quota-attribute` changed | `(dn, oldQuota, newQuota)` |
47
+ | `onLdapDisplayNameChange` | the name from `--display-name-attribute`, else `cn`, else `givenName` and `sn`, changed | `(dn, oldName, newName)` |
48
48
 
49
49
  `changes` maps each attribute that changed to `[oldValues, newValues]`, the
50
50
  full values on each side, `null` where the attribute is absent:
@@ -4,6 +4,18 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.16.0
8
+
9
+ ### Space events need the organization domain attribute
10
+
11
+ **Who is affected:** a deployment of `core/twake/spaces` with `--rabbitmq-url`
12
+ and `--twake-group-organization-dn`.
13
+
14
+ The space events now carry the organization's domain, read from
15
+ `--twake-instance-organization-domain-attribute` (default `twakeDomain`). The
16
+ server does not start when the directory schema does not define that
17
+ attribute: load a schema that does, or point the option at one it defines.
18
+
7
19
  ## To 0.15.0
8
20
 
9
21
  ### A DN option read from the environment splits on `;` only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -775,7 +775,6 @@
775
775
  "eslint-plugin-import": "^2.32.0",
776
776
  "eslint-plugin-node": "^11.1.0",
777
777
  "eslint-plugin-prettier": "^5.5.4",
778
- "fast-glob": "^3.3.3",
779
778
  "globals": "^14.0.0",
780
779
  "js-yaml": "^4.1.1",
781
780
  "mocha": "^12.0.0",
@@ -786,6 +785,7 @@
786
785
  "rollup-plugin-postcss": "^4.0.2",
787
786
  "sort-package-json": "^3.4.0",
788
787
  "supertest": "^7.1.4",
788
+ "tinyglobby": "^0.2.17",
789
789
  "tslib": "^2.8.1",
790
790
  "tsx": "^4.20.5",
791
791
  "typescript": "^5.9.2"