ldap-rest 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/bin/index.js +1 -1
  2. package/dist/config/args.js +1 -1
  3. package/dist/lib/authz/base.js +1 -1
  4. package/dist/lib/errors.js +1 -1
  5. package/dist/lib/expressFormatedResponses.js +1 -1
  6. package/dist/lib/utils.js +1 -1
  7. package/dist/plugins/auth/authzScope.js +1 -1
  8. package/dist/plugins/ldap/bulkImport.js +1 -1
  9. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  10. package/dist/plugins/ldap/groups.js +1 -1
  11. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  12. package/dist/src/config/args.d.ts +6 -0
  13. package/dist/src/config/schema.d.ts +5 -3
  14. package/dist/src/lib/authz/base.d.ts +39 -0
  15. package/dist/src/lib/errors.d.ts +35 -2
  16. package/dist/src/lib/utils.d.ts +12 -0
  17. package/dist/src/plugins/auth/authzScope.d.ts +19 -0
  18. package/dist/src/plugins/ldap/bulkImport.d.ts +10 -0
  19. package/dist/src/plugins/ldap/enterpriseRules.d.ts +25 -7
  20. package/dist/src/plugins/ldap/groups.d.ts +29 -5
  21. package/dist/src/plugins/ldap/passwordPolicy.d.ts +13 -1
  22. package/docs/client-development/api/rest-api.md +52 -0
  23. package/docs/usage/configuration.md +7 -6
  24. package/docs/usage/plugins/auth/authz-linid1.md +36 -1
  25. package/docs/usage/plugins/ldap/bulk-import.md +15 -0
  26. package/docs/usage/plugins/ldap/enterprise-rules.md +41 -11
  27. package/docs/usage/plugins/ldap/password-policy.md +13 -1
  28. package/docs/usage/plugins/ldap/trash.md +9 -0
  29. package/docs/usage/upgrading.md +37 -0
  30. package/package.json +1 -1
  31. package/static/schemas/example/groups.json +2 -1
  32. package/static/schemas/example/organizations.json +2 -0
  33. package/static/schemas/standard/devices.json +4 -2
  34. package/static/schemas/twake/groups.json +2 -1
  35. package/static/schemas/twake/organizations.json +2 -0
  36. package/static/schemas/twake/teamMailboxes.json +2 -1
  37. package/static/schemas/twake/users.json +1 -0
@@ -1 +1 @@
1
- import{isDeepStrictEqual as e}from"node:util";import i from"../../abstract/plugin.js";import{asyncHandler as t,normalizeDn as s,escapeLdapFilter as a,validateDnValue as o,escapeDnValue as r}from"../../lib/utils.js";import{jsonBody as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{NotFoundError as l,ConflictError as d,BadRequestError as p}from"../../lib/errors.js";const u=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],w={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},h=["pwdCheckModuleArg","pwdUseCheckModule"],y=[...Object.keys(w),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.prototype.hasOwnProperty.call(w,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const i={};for(const t of y){const s=values(e[t]);if(0!==s.length)switch(w[t]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(i[t]=e);break}case"boolean":i[t]="TRUE"===s[0];break;default:i[t]=1===s.length?s[0]:s}}return i},g=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/;class PasswordPolicy extends i{constructor(){super(...arguments),this.name="ldapPasswordPolicy",this.roles=["api","configurable"],this.policyCache=new Map,this.discoveredDefault=null,this.warnedAmbiguousDefault=!1,this.warnedMissingBase=!1,this.POLICY_CACHE_TTL=6e4}api(e){const i=`${this.config.api_prefix}/v1`;e.get(`${i}/password-policy`,t(async(e,i)=>{const t=await this.readPolicyConfig();if(t.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},t.dn,e)}catch(e){if(32!==c(e))throw e;return this.forgetPolicies(),void i.json({})}i.json(t)})),e.get(`${i}/users/:id/password-status`,t(async(e,i)=>{const t=this.param(e,"id"),s=await this.getPasswordStatus(t,e);i.json(s)})),e.post(`${i}/users/:id/unlock`,t(async(e,i)=>{const t=this.param(e,"id");await this.unlockAccount(t,e),i.json({success:!0,message:"Account unlocked"})})),e.get(`${i}/password-policy/expiring-soon`,t(async(e,i)=>{const t=parseInt(e.query.days)||this.config.ppolicy_warn_days||14,s=await this.findExpiringPasswords(t,e);i.json({warningDays:t,users:s})})),e.get(`${i}/password-policy/locked-accounts`,t(async(e,i)=>{const t=await this.findLockedAccounts(e);i.json({accounts:t})})),e.get(`${i}/password-policies`,t(async(e,i)=>{i.json({policies:await this.listPolicies(e)})})),e.post(`${i}/password-policies`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const s=await this.createPolicy(t,e);i.status(201).json(s)})),e.get(`${i}/password-policies/:cn`,t(async(e,i)=>{const t=await this.findPolicy(this.param(e,"cn"),e);i.json(await this.toPolicyRecord(t))})),e.patch(`${i}/password-policies/:cn`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const s=await this.updatePolicy(this.param(e,"cn"),t,e);i.json(s)})),e.delete(`${i}/password-policies/:cn`,t(async(e,i)=>{await this.deletePolicy(this.param(e,"cn"),e),i.json({success:!0})})),e.put(`${i}/users/:id/password-policy`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const{policy:s}=t;if(null!==s&&("string"!=typeof s||!s))throw new p("policy must be a policy name or null");i.json(await this.assignPolicy(this.param(e,"id"),s,e))})),this.config.ppolicy_validate_complexity&&e.post(`${i}/password/validate`,t(async(e,i)=>{const t=n(e,i);if(!t)return;const{password:s}=t;if(!s)throw new p("password required");const a=this.validateComplexity(s);i.json(a)}))}async getPasswordStatus(e,i){const t=this.resolveUserDn(e),s=await this.readUser(t,e,["*",...u],i),a=await this.policyFor(s);return this.calculateStatus(t,s,a)}async readUser(e,i,t,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:t,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new l(`User not found: ${i}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new l(`User not found: ${i}`);throw e}}calculateStatus(e,i,t){const s=Date.now(),a=this.parseGeneralizedTime(i.pwdChangedTime);let o=null,r=!1,n=null;t.pwdMaxAge&&a&&(o=new Date(a.getTime()+1e3*t.pwdMaxAge),r=s>o.getTime(),n=Math.ceil((o.getTime()-s)/864e5));let c=!1;const l=this.config.ppolicy_warn_days||14;null!==n&&n>0&&(c=n<=l);const d=i.pwdAccountLockedTime;let p=!!d;if(p&&!("000001010000Z"===d)&&t.pwdLockoutDuration){const e=this.parseGeneralizedTime(d);if(e){s>new Date(e.getTime()+1e3*t.pwdLockoutDuration).getTime()&&(p=!1)}}const u=i.pwdFailureTime,w=Array.isArray(u)?u.length:0,h=i.pwdGraceUseTime,y=Array.isArray(h)?h.length:0,g="TRUE"===i.pwdReset;return{dn:e,passwordSet:!!i.userPassword,lastChanged:a?.toISOString()||null,expiresAt:o?.toISOString()||null,daysUntilExpiration:n,isExpired:r,isExpiringSoon:c,mustChange:g,isLocked:p,lockedAt:d&&this.parseGeneralizedTime(d)?.toISOString()||null,failureCount:w,graceLoginsUsed:y,policy:t.dn??null}}async unlockAccount(e,i){const t=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,i);if(!s.searchEntries||0===s.searchEntries.length)throw new l(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),o={};a.pwdAccountLockedTime&&(o.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(o.pwdFailureTime=[]),Object.keys(o).length>0?(await this.server.ldap.modify(t,{delete:o},i),this.logger.info(`Account unlocked: ${t}`)):this.logger.info(`Account was not locked: ${t}`)}async findExpiringPasswords(e,i){const t=this.config.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},t,i),a=Date.now(),o=24*e*60*60*1e3,r=[];for(const e of s.searchEntries){const i=userEntry(e),t=this.parseGeneralizedTime(i.pwdChangedTime);if(!t)continue;const s=await this.policyFor(i);if(!s.pwdMaxAge)continue;const n=new Date(t.getTime()+1e3*s.pwdMaxAge),c=n.getTime()-a;c>0&&c<o&&r.push({dn:i.dn,uid:i.uid,displayName:i.displayName||i.cn,mail:i.mail,expiresAt:n.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return r.sort((e,i)=>e.daysUntilExpiration-i.daysUntilExpiration)}async findLockedAccounts(e){const i=this.config.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,e)).searchEntries.map(e=>{const i=userEntry(e);return{dn:i.dn,uid:i.uid,displayName:i.displayName||i.cn,mail:i.mail,lockedAt:this.parseGeneralizedTime(i.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(i.pwdFailureTime)?i.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const i=await this.readPolicyAt(e.pwdPolicySubentry);if(i)return i}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.config.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const i=(await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===i.length?e=i[0]:i.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${i.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const i=s(e),t=this.policyCache.get(i);if(t&&Date.now()-t.time<this.POLICY_CACHE_TTL)return t.policy;let a,o=null;try{const i=await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:y,paged:!1},e);i.searchEntries.length>0&&(o=((e,i)=>{const t=typedPolicy(e);return{dn:i,pwdMaxAge:t.pwdMaxAge,pwdMinAge:t.pwdMinAge,pwdInHistory:t.pwdInHistory,pwdCheckQuality:t.pwdCheckQuality,pwdMinLength:t.pwdMinLength,pwdMaxFailure:t.pwdMaxFailure,pwdLockout:!0===t.pwdLockout,pwdLockoutDuration:t.pwdLockoutDuration,pwdGraceAuthNLimit:t.pwdGraceAuthNLimit,pwdExpireWarning:t.pwdExpireWarning,pwdMustChange:!0===t.pwdMustChange,pwdAllowUserChange:!1!==t.pwdAllowUserChange}})(i.searchEntries[0],e))}catch(e){a=String(e)}const r=this.config.ppolicy_default_dn;return!o&&r&&s(r)===i?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(i,{policy:o,time:Date.now()}),o}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.config.ppolicy_base||this.config.ldap_base}async searchPolicies(e,i){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...y],paged:!1},this.policyBase(),i)).searchEntries}catch(e){if(32!==c(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,i){const t=await this.searchPolicies(`(cn=${a(e)})`,i);if(0===t.length)throw new l(`No password policy named ${e}`);if(t.length>1)throw new d(`Several password policies are named ${e}`);return t[0]}async listPolicies(e){const i=[];for(const t of await this.searchPolicies("",e))i.push(await this.toPolicyRecord(t));return i.sort((e,i)=>e.cn.localeCompare(i.cn))}async toPolicyRecord(e){const i=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!i&&s(i)===s(e.dn)},...typedPolicy(e)}}policyValue(e,i){switch(w[e]){case"integer":if("number"!=typeof i||!Number.isInteger(i)||i<0||"pwdCheckQuality"===e&&i>2)throw new p("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be a non-negative integer`);return String(i);case"boolean":if("boolean"!=typeof i)throw new p(`${e} must be a boolean`);return i?"TRUE":"FALSE";default:if("string"!=typeof i||""===i)throw new p(`${e} must be a non-empty string`);return i}}async createPolicy(e,i){const{cn:t,...s}=e;if("string"!=typeof t)throw new p("cn required");try{o(t,"cn")}catch(e){throw new p(e.message)}const a=await this.findPolicy(t,i).catch(e=>{if(e instanceof l)return null;throw e});if(a)throw new d(`A password policy is named ${t}: ${a.dn}`);const n=["top","person","pwdPolicy"],u={objectClass:n,cn:t,sn:t,pwdAttribute:"userPassword"};for(const[e,i]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new p(`${e} cannot be written`);null!==i&&(u[e]=this.policyValue(e,i))}h.some(e=>e in u)&&n.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${r(t)},${this.policyBase()}`,u,i)}catch(e){if(32!==c(e))throw e;throw new l(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,i))}async updatePolicy(i,t,a){const o=await this.findPolicy(i,a),r=await this.toPolicyRecord(o),n={},c=[];for(const[i,a]of Object.entries(t)){if(!isWritablePolicyAttribute(i)){if("dn"===i?"string"==typeof a&&s(a)===s(r.dn):Object.prototype.hasOwnProperty.call(r,i)&&e(r[i],a))continue;throw new p(`${i} cannot be written`)}null!==a?n[i]=this.policyValue(i,a):void 0!==r[i]&&c.push(i)}const l={};if(Object.keys(n).length>0&&(l.replace=n),c.length>0&&(l.delete=c),!l.replace&&!l.delete)return r;const d=values(o.objectClass).map(e=>e.toLowerCase());return h.some(e=>e in n)&&!d.includes("pwdpolicychecker")&&(l.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(o.dn,l,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,a))}async deletePolicy(e,i){const t=await this.findPolicy(e,i),o=(await this.readPolicyConfig()).dn;if(o&&s(o)===s(t.dn))throw new d(`${t.dn} is the default password policy`);if((await this.server.ldap.search({filter:`(pwdPolicySubentry=${a(t.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base)).searchEntries.length>0)throw new d(`${t.dn} is still applied to at least one entry`);await this.server.ldap.delete(t.dn,i),this.forgetPolicies()}async assignPolicy(e,i,t){const s=this.resolveUserDn(e);if(null===i){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},t)}catch(e){if(16!==c(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(i,t);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},t),{dn:s,policy:a.dn}}param(e,i){const t=e.params[i];return Array.isArray(t)?t[0]:t}parseGeneralizedTime(e){if(!e)return null;const i=e.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);if(!i)return null;const[,t,s,a,o,r,n]=i;return new Date(Date.UTC(+t,+s-1,+a,+o,+r,+n))}validateComplexity(e){const i=[],t=this.config,s=t.ppolicy_min_length||12;return e.length<s&&i.push(`Minimum ${s} characters required`),t.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&i.push("At least one uppercase letter required"),t.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&i.push("At least one lowercase letter required"),t.ppolicy_require_digit&&!/\d/.test(e)&&i.push("At least one digit required"),t.ppolicy_require_special&&!g.test(e)&&i.push("At least one special character required"),{valid:0===i.length,errors:i}}resolveUserDn(e){if(e.includes("="))return e;const i=this.config.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${r(e)},${i}`}getConfigApiData(){const e=this.config.api_prefix||"/api",i=!!this.config.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:i?`${e}/v1/password/validate`:void 0},config:{warnDays:this.config.ppolicy_warn_days||14,validateComplexity:i,...i&&{complexityRules:{minLength:this.config.ppolicy_min_length||12,requireUppercase:this.config.ppolicy_require_uppercase??!0,requireLowercase:this.config.ppolicy_require_lowercase??!0,requireDigit:this.config.ppolicy_require_digit??!0,requireSpecial:this.config.ppolicy_require_special??!0}}}}}}export{PasswordPolicy as default};
1
+ import{isDeepStrictEqual as e}from"node:util";import t from"../../abstract/plugin.js";import{asyncHandler as i,normalizeDn as s,escapeLdapFilter as a,validateDnValue as r,escapeDnValue as o}from"../../lib/utils.js";import{jsonBody as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{NotFoundError as l,ConflictError as d,BadRequestError as p}from"../../lib/errors.js";const u=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],h={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},w=["pwdCheckModuleArg","pwdUseCheckModule"],y=[...Object.keys(h),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.prototype.hasOwnProperty.call(h,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const t={};for(const i of y){const s=values(e[i]);if(0!==s.length)switch(h[i]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(t[i]=e);break}case"boolean":t[i]="TRUE"===s[0];break;default:t[i]=1===s.length?s[0]:s}}return t},g=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/;class PasswordPolicy extends t{constructor(){super(...arguments),this.name="ldapPasswordPolicy",this.roles=["api","configurable"],this.policyCache=new Map,this.discoveredDefault=null,this.warnedAmbiguousDefault=!1,this.warnedMissingBase=!1,this.warnedUnreadableSchema=!1,this.POLICY_CACHE_TTL=6e4}api(e){const t=`${this.config.api_prefix}/v1`;e.get(`${t}/password-policy`,i(async(e,t)=>{const i=await this.readPolicyConfig();if(i.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},i.dn,e)}catch(e){if(32!==c(e))throw e;return this.forgetPolicies(),void t.json({})}t.json(i)})),e.get(`${t}/users/:id/password-status`,i(async(e,t)=>{const i=this.param(e,"id"),s=await this.getPasswordStatus(i,e);t.json(s)})),e.post(`${t}/users/:id/unlock`,i(async(e,t)=>{const i=this.param(e,"id");await this.unlockAccount(i,e),t.json({success:!0,message:"Account unlocked"})})),e.get(`${t}/password-policy/expiring-soon`,i(async(e,t)=>{const i=parseInt(e.query.days)||this.config.ppolicy_warn_days||14,s=await this.findExpiringPasswords(i,e);t.json({warningDays:i,users:s})})),e.get(`${t}/password-policy/locked-accounts`,i(async(e,t)=>{const i=await this.findLockedAccounts(e);t.json({accounts:i})})),e.get(`${t}/password-policies`,i(async(e,t)=>{t.json({policies:await this.listPolicies(e),attributes:await this.definedAttributes()})})),e.post(`${t}/password-policies`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const s=await this.createPolicy(i,e);t.status(201).json(s)})),e.get(`${t}/password-policies/:cn`,i(async(e,t)=>{const i=await this.findPolicy(this.param(e,"cn"),e);t.json(await this.toPolicyRecord(i))})),e.patch(`${t}/password-policies/:cn`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const s=await this.updatePolicy(this.param(e,"cn"),i,e);t.json(s)})),e.delete(`${t}/password-policies/:cn`,i(async(e,t)=>{await this.deletePolicy(this.param(e,"cn"),e),t.json({success:!0})})),e.put(`${t}/users/:id/password-policy`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const{policy:s}=i;if(null!==s&&("string"!=typeof s||!s))throw new p("policy must be a policy name or null");t.json(await this.assignPolicy(this.param(e,"id"),s,e))})),this.config.ppolicy_validate_complexity&&e.post(`${t}/password/validate`,i(async(e,t)=>{const i=n(e,t);if(!i)return;const{password:s}=i;if(!s)throw new p("password required");const a=this.validateComplexity(s);t.json(a)}))}async getPasswordStatus(e,t){const i=this.resolveUserDn(e),s=await this.readUser(i,e,["*",...u],t),a=await this.policyFor(s);return this.calculateStatus(i,s,a)}async readUser(e,t,i,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:i,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new l(`User not found: ${t}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new l(`User not found: ${t}`);throw e}}calculateStatus(e,t,i){const s=Date.now(),a=this.parseGeneralizedTime(t.pwdChangedTime);let r=null,o=!1,n=null;i.pwdMaxAge&&a&&(r=new Date(a.getTime()+1e3*i.pwdMaxAge),o=s>r.getTime(),n=Math.ceil((r.getTime()-s)/864e5));let c=!1;const l=this.config.ppolicy_warn_days||14;null!==n&&n>0&&(c=n<=l);const d=t.pwdAccountLockedTime;let p=!!d;if(p&&!("000001010000Z"===d)&&i.pwdLockoutDuration){const e=this.parseGeneralizedTime(d);if(e){s>new Date(e.getTime()+1e3*i.pwdLockoutDuration).getTime()&&(p=!1)}}const u=t.pwdFailureTime,h=Array.isArray(u)?u.length:0,w=t.pwdGraceUseTime,y=Array.isArray(w)?w.length:0,g="TRUE"===t.pwdReset;return{dn:e,passwordSet:!!t.userPassword,lastChanged:a?.toISOString()||null,expiresAt:r?.toISOString()||null,daysUntilExpiration:n,isExpired:o,isExpiringSoon:c,mustChange:g,isLocked:p,lockedAt:d&&this.parseGeneralizedTime(d)?.toISOString()||null,failureCount:h,graceLoginsUsed:y,policy:i.dn??null}}async unlockAccount(e,t){const i=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,t);if(!s.searchEntries||0===s.searchEntries.length)throw new l(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),r={};a.pwdAccountLockedTime&&(r.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(r.pwdFailureTime=[]),Object.keys(r).length>0?(await this.server.ldap.modify(i,{delete:r},t),this.logger.info(`Account unlocked: ${i}`)):this.logger.info(`Account was not locked: ${i}`)}async findExpiringPasswords(e,t){const i=this.config.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},i,t),a=Date.now(),r=24*e*60*60*1e3,o=[];for(const e of s.searchEntries){const t=userEntry(e),i=this.parseGeneralizedTime(t.pwdChangedTime);if(!i)continue;const s=await this.policyFor(t);if(!s.pwdMaxAge)continue;const n=new Date(i.getTime()+1e3*s.pwdMaxAge),c=n.getTime()-a;c>0&&c<r&&o.push({dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,expiresAt:n.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return o.sort((e,t)=>e.daysUntilExpiration-t.daysUntilExpiration)}async findLockedAccounts(e){const t=this.config.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,e)).searchEntries.map(e=>{const t=userEntry(e);return{dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,lockedAt:this.parseGeneralizedTime(t.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(t.pwdFailureTime)?t.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const t=await this.readPolicyAt(e.pwdPolicySubentry);if(t)return t}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.config.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const t=(await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===t.length?e=t[0]:t.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${t.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const t=s(e),i=this.policyCache.get(t);if(i&&Date.now()-i.time<this.POLICY_CACHE_TTL)return i.policy;let a,r=null;try{const t=await this.server.ldap.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:y,paged:!1},e);t.searchEntries.length>0&&(r=((e,t)=>{const i=typedPolicy(e);return{dn:t,pwdMaxAge:i.pwdMaxAge,pwdMinAge:i.pwdMinAge,pwdInHistory:i.pwdInHistory,pwdCheckQuality:i.pwdCheckQuality,pwdMinLength:i.pwdMinLength,pwdMaxFailure:i.pwdMaxFailure,pwdLockout:!0===i.pwdLockout,pwdLockoutDuration:i.pwdLockoutDuration,pwdGraceAuthNLimit:i.pwdGraceAuthNLimit,pwdExpireWarning:i.pwdExpireWarning,pwdMustChange:!0===i.pwdMustChange,pwdAllowUserChange:!1!==i.pwdAllowUserChange}})(t.searchEntries[0],e))}catch(e){a=String(e)}const o=this.config.ppolicy_default_dn;return!r&&o&&s(o)===t?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(t,{policy:r,time:Date.now()}),r}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.config.ppolicy_base||this.config.ldap_base}async searchPolicies(e,t){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...y],paged:!1},this.policyBase(),t)).searchEntries}catch(e){if(32!==c(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,t){const i=await this.searchPolicies(`(cn=${a(e)})`,t);if(0===i.length)throw new l(`No password policy named ${e}`);if(i.length>1)throw new d(`Several password policies are named ${e}`);return i[0]}async listPolicies(e){const t=[];for(const i of await this.searchPolicies("",e))t.push(await this.toPolicyRecord(i));return t.sort((e,t)=>e.cn.localeCompare(t.cn))}async definedAttributes(){const e=Object.keys(h);try{const t=await this.server.ldap.schemaIndex();return e.filter(e=>t.getAttributeType(e))}catch(t){return this.warnedUnreadableSchema||(this.warnedUnreadableSchema=!0,this.logger.warn(`Unable to read the schema, every policy attribute is listed: ${String(t)}`)),e}}async toPolicyRecord(e){const t=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!t&&s(t)===s(e.dn)},...typedPolicy(e)}}policyValue(e,t){switch(h[e]){case"integer":if("number"!=typeof t||!Number.isInteger(t)||t<0||"pwdCheckQuality"===e&&t>2)throw new p("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be a non-negative integer`);return String(t);case"boolean":if("boolean"!=typeof t)throw new p(`${e} must be a boolean`);return t?"TRUE":"FALSE";default:if("string"!=typeof t||""===t)throw new p(`${e} must be a non-empty string`);return t}}async createPolicy(e,t){const{cn:i,...s}=e;if("string"!=typeof i)throw new p("cn required");try{r(i,"cn")}catch(e){throw new p(e.message)}const a=await this.findPolicy(i,t).catch(e=>{if(e instanceof l)return null;throw e});if(a)throw new d(`A password policy is named ${i}: ${a.dn}`);const n=["top","person","pwdPolicy"],u={objectClass:n,cn:i,sn:i,pwdAttribute:"userPassword"};for(const[e,t]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new p(`${e} cannot be written`);null!==t&&(u[e]=this.policyValue(e,t))}w.some(e=>e in u)&&n.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${o(i)},${this.policyBase()}`,u,t)}catch(e){if(32!==c(e))throw e;throw new l(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,t))}async updatePolicy(t,i,a){const r=await this.findPolicy(t,a),o=await this.toPolicyRecord(r),n={},c=[];for(const[t,a]of Object.entries(i)){if(!isWritablePolicyAttribute(t)){if("dn"===t?"string"==typeof a&&s(a)===s(o.dn):Object.prototype.hasOwnProperty.call(o,t)&&e(o[t],a))continue;throw new p(`${t} cannot be written`)}null!==a?n[t]=this.policyValue(t,a):void 0!==o[t]&&c.push(t)}const l={};if(Object.keys(n).length>0&&(l.replace=n),c.length>0&&(l.delete=c),!l.replace&&!l.delete)return o;const d=values(r.objectClass).map(e=>e.toLowerCase());return w.some(e=>e in n)&&!d.includes("pwdpolicychecker")&&(l.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(r.dn,l,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,a))}async deletePolicy(e,t){const i=await this.findPolicy(e,t),r=(await this.readPolicyConfig()).dn;if(r&&s(r)===s(i.dn))throw new d(`${i.dn} is the default password policy`);if((await this.server.ldap.search({filter:`(pwdPolicySubentry=${a(i.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base)).searchEntries.length>0)throw new d(`${i.dn} is still applied to at least one entry`);await this.server.ldap.delete(i.dn,t),this.forgetPolicies()}async assignPolicy(e,t,i){const s=this.resolveUserDn(e);if(null===t){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},i)}catch(e){if(16!==c(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(t,i);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},i),{dn:s,policy:a.dn}}param(e,t){const i=e.params[t];return Array.isArray(i)?i[0]:i}parseGeneralizedTime(e){if(!e)return null;const t=e.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);if(!t)return null;const[,i,s,a,r,o,n]=t;return new Date(Date.UTC(+i,+s-1,+a,+r,+o,+n))}validateComplexity(e){const t=[],i=this.config,s=i.ppolicy_min_length||12;return e.length<s&&t.push(`Minimum ${s} characters required`),i.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&t.push("At least one uppercase letter required"),i.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&t.push("At least one lowercase letter required"),i.ppolicy_require_digit&&!/\d/.test(e)&&t.push("At least one digit required"),i.ppolicy_require_special&&!g.test(e)&&t.push("At least one special character required"),{valid:0===t.length,errors:t}}resolveUserDn(e){if(e.includes("="))return e;const t=this.config.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${o(e)},${t}`}getConfigApiData(){const e=this.config.api_prefix||"/api",t=!!this.config.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:t?`${e}/v1/password/validate`:void 0},config:{warnDays:this.config.ppolicy_warn_days||14,validateComplexity:t,...t&&{complexityRules:{minLength:this.config.ppolicy_min_length||12,requireUppercase:this.config.ppolicy_require_uppercase??!0,requireLowercase:this.config.ppolicy_require_lowercase??!0,requireDigit:this.config.ppolicy_require_digit??!0,requireSpecial:this.config.ppolicy_require_special??!0}}}}}}export{PasswordPolicy as default};
@@ -125,6 +125,12 @@ export interface Config {
125
125
  * organization is in transit too, with or without this option.
126
126
  */
127
127
  authz_transit_branch?: string;
128
+ /**
129
+ * The attribute an entry put in transit keeps the organization it leaves
130
+ * in, removed when it is claimed: who claims it can tell where it comes
131
+ * from. Empty records nothing. It must exist in the directory's schema.
132
+ */
133
+ authz_transit_origin_attribute?: string;
128
134
  authz_unresolved_user?: string;
129
135
  authz_identity?: string;
130
136
  /**
@@ -217,10 +217,12 @@ export interface SchemaAttribute {
217
217
  */
218
218
  states?: Record<string, AttributeValue>;
219
219
  /**
220
- * For a `pointer`: refuse to delete the entry it points at while any entry
221
- * still references it. `ignore` (the default) leaves the reference dangling.
220
+ * For a `pointer`, or a list of them: what deleting the entry it points at
221
+ * does to the reference. `restrict` refuses the deletion while any entry
222
+ * still references it; `remove` takes the deleted DN out of the attribute;
223
+ * `ignore` (the default) leaves the reference dangling.
222
224
  */
223
- referentialIntegrity?: 'restrict' | 'ignore';
225
+ referentialIntegrity?: 'restrict' | 'remove' | 'ignore';
224
226
  /**
225
227
  * For a `members`-role attribute: refuse to delete the entry while it still
226
228
  * has members. The placeholder member some directories need to keep a
@@ -63,6 +63,24 @@ export default abstract class AuthzBase extends DmPlugin {
63
63
  * only a caller holding the branch reads. None unless a plugin names some.
64
64
  */
65
65
  protected globalBranches(): string[];
66
+ /**
67
+ * Why `user` may not search under `base`, or `undefined` when they may:
68
+ * the judgement of `ldapsearchrequest`, for `authzScope` to ask it of a
69
+ * branch without searching it.
70
+ *
71
+ * Filtering, an administrator reads the directory whole — the
72
+ * organization tree, the groups, the nomenclatures — and what is held to
73
+ * a branch is the accounts, recognised one entry at a time by
74
+ * `ldapsearchfilter`; only a global branch asks for the branch itself.
75
+ * Otherwise the branch decides. Where a branch is a tenant rather than a
76
+ * department, letting a listing cross it is letting a customer read
77
+ * another.
78
+ *
79
+ * @param user the caller's DN
80
+ * @param base the base of the search
81
+ * @returns the end of the refusal's message, after the caller's name
82
+ */
83
+ searchRefusal(user: string, base: string): Promise<string | undefined>;
66
84
  /**
67
85
  * What taking an entry out of a branch, by a move or a rename, asks of
68
86
  * the caller there: read, by default, the destination being judged on
@@ -100,6 +118,27 @@ export default abstract class AuthzBase extends DmPlugin {
100
118
  * @param key the transit branch as {@link transitKey} gives it
101
119
  */
102
120
  protected inTransit(link: string | undefined, key?: string | undefined): boolean;
121
+ /**
122
+ * The attribute an entry in transit keeps the organization it was handed
123
+ * over from in: `--authz-transit-origin-attribute`, with a transit branch
124
+ * to hand it over through. `undefined` records nothing.
125
+ */
126
+ protected transitOriginAttribute(): string | undefined;
127
+ /**
128
+ * A modify that changes an entry's organization link, with the origin of
129
+ * a hand-over recorded: put in transit, the entry keeps the organization
130
+ * it leaves in {@link transitOriginAttribute}; claimed out of it, it loses
131
+ * it. The administrator who claims it can then tell where it comes from.
132
+ *
133
+ * The changes are left alone when they already name the attribute, and
134
+ * when the stored entry cannot be read: a failed read must not cost the
135
+ * move itself, which is judged elsewhere.
136
+ *
137
+ * @param dn the entry modified
138
+ * @param changes the modify as it stands
139
+ * @returns the modify, with the origin set or removed
140
+ */
141
+ protected recordTransitOrigin(dn: string, changes: ModifyRequest): Promise<ModifyRequest>;
103
142
  /**
104
143
  * The transit branch in the form DNs are compared in. A listing asks once
105
144
  * and hands it to {@link inTransit} for every entry, rather than parsing
@@ -7,7 +7,27 @@
7
7
  */
8
8
  export declare class HttpError extends Error {
9
9
  statusCode: number;
10
- constructor(message: string, statusCode?: number);
10
+ refusal?: Refusal | undefined;
11
+ constructor(message: string, statusCode?: number, refusal?: Refusal | undefined);
12
+ }
13
+ /**
14
+ * What a client is told of a refusal besides its sentence: enough to say it
15
+ * in its own language and next to the field at fault, without reading the
16
+ * sentence back. Added to the `error` field of the answer, never in place
17
+ * of it.
18
+ */
19
+ export interface Refusal {
20
+ /**
21
+ * `unique` (a value already used), `mailDomain` (an address outside the
22
+ * authorised domains) or `forbidden` (an authorization plugin refused)
23
+ */
24
+ code: 'unique' | 'mailDomain' | 'forbidden';
25
+ /** The attribute refused */
26
+ attribute?: string;
27
+ /** The value refused */
28
+ value?: string;
29
+ /** The authorised mail domains, for `mailDomain` */
30
+ domains?: string[];
11
31
  }
12
32
  /**
13
33
  * Client errors (4xx)
@@ -23,6 +43,19 @@ export declare class UnauthorizedError extends HttpError {
23
43
  * threw names the branch, which the caller may not know of.
24
44
  */
25
45
  export declare const AUTHZ_REFUSED = "Token does not have permission on this branch";
46
+ /**
47
+ * The JSON answer of a client error: its sentence, and the refusal it
48
+ * carries. An authorization plugin's refusal carries none — its message names
49
+ * the branch, which is why the client is told `AUTHZ_REFUSED` — so the code
50
+ * is given here, from the sentence the caller settled on.
51
+ *
52
+ * @param err the error thrown
53
+ * @param message the sentence the client is told
54
+ * @returns the body to answer
55
+ */
56
+ export declare const errorBody: (err: unknown, message: string) => {
57
+ error: string;
58
+ } & Partial<Refusal>;
26
59
  export declare class ForbiddenError extends HttpError {
27
60
  constructor(message?: string);
28
61
  }
@@ -45,7 +78,7 @@ export declare class NotFoundError extends HttpError {
45
78
  constructor(message?: string);
46
79
  }
47
80
  export declare class ConflictError extends HttpError {
48
- constructor(message?: string);
81
+ constructor(message?: string, refusal?: Refusal);
49
82
  }
50
83
  export declare class UriTooLongError extends HttpError {
51
84
  constructor(message?: string);
@@ -313,6 +313,18 @@ export declare function organizationLink(value: AttributeValue | undefined): str
313
313
  * ```
314
314
  */
315
315
  export declare function isDnInBranch(dn: string, base: string): boolean;
316
+ /**
317
+ * Tell whether a rename moved an entry into the trash branch.
318
+ *
319
+ * `core/ldap/trash` turns a deletion into such a move, so the DN it frees is
320
+ * as free as a deleted one, without any `ldapdeletedone`.
321
+ *
322
+ * @param trashBase the `--trash-base`, if any
323
+ * @param oldDn DN before the rename
324
+ * @param newDn DN after it
325
+ * @returns true when the entry entered the trash branch
326
+ */
327
+ export declare function movedToTrash(trashBase: string | undefined, oldDn: string, newDn: string): boolean;
316
328
  /**
317
329
  * Wrapper for async Express route handlers to catch errors and pass them to error middleware
318
330
  * This ensures that errors in async routes are properly handled and don't crash the server
@@ -83,6 +83,25 @@ export default class AuthzScope extends DmPlugin {
83
83
  * @param res Express response
84
84
  */
85
85
  private scope;
86
+ /** The password policy plugin, when loaded. */
87
+ private policyPlugin;
88
+ /**
89
+ * The `passwordPolicies` field: nothing when no plugin serves the policies,
90
+ * so that "not served" is not read as "not yours".
91
+ *
92
+ * @param may whether the caller may list them
93
+ */
94
+ private passwordPolicies;
95
+ /**
96
+ * Whether the describing plugin lets the caller list the password
97
+ * policies: the judgement its search hook makes of the listing, asked
98
+ * without the listing. A plugin that cannot say does not offer them, as
99
+ * `linksAnyDomain` does not.
100
+ *
101
+ * @param authz the describing plugin
102
+ * @param user the caller's DN
103
+ */
104
+ private readsPolicies;
86
105
  /**
87
106
  * Read the display name and path of a branch, so a client can show the
88
107
  * scope in the words the directory uses rather than as a raw DN.
@@ -54,6 +54,16 @@ import type { DM } from '../../bin';
54
54
  * error:
55
55
  * type: string
56
56
  * description: Error message.
57
+ * code:
58
+ * type: string
59
+ * description: |
60
+ * `unique` or `mailDomain`, with `attribute`, `value` and
61
+ * `domains`, as in the `Error` answer of a single write.
62
+ * attribute: { type: string }
63
+ * value: { type: string }
64
+ * domains:
65
+ * type: array
66
+ * items: { type: string }
57
67
  * example:
58
68
  * - line: 5
59
69
  * identifier: bob
@@ -5,7 +5,7 @@
5
5
  * Business rules an enterprise directory needs on top of declarative schema
6
6
  * validation: uniqueness across a shared namespace, mail addresses confined to
7
7
  * the domains an organization owns, computed organization paths, normalised
8
- * quotas, and referential-integrity guards on deletion.
8
+ * quotas, and referential integrity on deletion.
9
9
  *
10
10
  * Nothing here knows a concrete attribute name, a domain, or a nomenclature
11
11
  * value. Every rule is driven by the loaded entity schemas — their semantic
@@ -23,8 +23,9 @@
23
23
  *
24
24
  * ## What "a reference" is, and the one case this cannot see
25
25
  *
26
- * Two rules — the delete guard and the rename cascade — have to find every
27
- * entry naming another one by its DN. They look in two places, both declared
26
+ * Three rules — the delete guard, the removal of a deleted DN and the rename
27
+ * cascade — have to find every entry naming another one by its DN. They look
28
+ * in two places, both declared
28
29
  * by the schemas: every `pointer` (single, or under `items`) whose `branch`
29
30
  * admits the target, and every attribute carrying the `members` or `owners`
30
31
  * role. The second is not redundant: a group's member list cannot be a
@@ -33,8 +34,8 @@
33
34
  *
34
35
  * A deployment whose schema stores a DN in an attribute that is neither of
35
36
  * those — a plain `string` carrying no role — is therefore not covered: its
36
- * value is left naming the old DN after a rename, and does not hold a
37
- * deletion back. Declare such an attribute as a `pointer`, or give it the
37
+ * value is left naming the old DN after a rename, does not hold a deletion
38
+ * back, and is not removed when that DN is deleted. Declare such an attribute as a `pointer`, or give it the
38
39
  * role it plays. No marker is invented here to cover the gap: a marker
39
40
  * meaning "this happens to contain a DN" would be a third way of saying what
40
41
  * `pointer` and the roles already say.
@@ -225,7 +226,8 @@ export default class LdapEnterpriseRules extends DmPlugin {
225
226
  * Refuse a mail address outside the domains its organization may use.
226
227
  *
227
228
  * @param entity entity the entry belongs to
228
- * @param attr schema definition of the mail attribute
229
+ * @param name the mail attribute
230
+ * @param attr its schema definition
229
231
  * @param values addresses submitted
230
232
  * @param submitted attributes as submitted, read for the organization link
231
233
  * @param current entry as stored, when the link is not being changed
@@ -295,10 +297,26 @@ export default class LdapEnterpriseRules extends DmPlugin {
295
297
  * @throws ConflictError naming the first referencing entry
296
298
  */
297
299
  private guardReferences;
300
+ /**
301
+ * Take a deleted DN out of every `referentialIntegrity: remove` pointer
302
+ * still naming it.
303
+ *
304
+ * Written with the server's own rights, not the caller's: the deletion was
305
+ * authorized, and the entries naming it are often outside what the caller
306
+ * may write — the organization a deleted account administered. A link left
307
+ * behind is a right waiting for the next entry given the same DN.
308
+ *
309
+ * A failure is logged, not thrown: the entry is already gone.
310
+ *
311
+ * @param dn entry just deleted
312
+ * @param context change context of the deletion, to attribute these writes
313
+ */
314
+ private removeReferences;
298
315
  /**
299
316
  * Every attribute of every loaded entity that could hold this DN.
300
317
  *
301
- * This is the traversal the delete guard and the rename cascade share: it
318
+ * This is the traversal the delete guard, the removal and the rename
319
+ * cascade share: it
302
320
  * knows no attribute name, only what the schemas declare — a `pointer`
303
321
  * (single or under `items`) whose `branch` admits the DN's parent, or an
304
322
  * attribute carrying the `members` or `owners` role. The module header says
@@ -80,12 +80,35 @@ export type postModify = ModifyRequest;
80
80
  * description: Updated description
81
81
  * Error:
82
82
  * type: object
83
+ * required: [error]
83
84
  * properties:
84
- * error: { type: string }
85
- * code: { type: integer }
85
+ * error:
86
+ * type: string
87
+ * description: What was refused, in English.
88
+ * code:
89
+ * type: string
90
+ * description: |
91
+ * For a client to explain the refusal in its own words, when there
92
+ * is one: `unique` (a value already used), `mailDomain` (an address
93
+ * outside the authorised mail domains), `forbidden` (the caller may
94
+ * not act on this branch). Absent for any other refusal, which only
95
+ * `error` describes. The Twake groups API answers its own codes
96
+ * (`INVALID_INPUT`, `REFUSED`…).
97
+ * attribute:
98
+ * type: string
99
+ * description: The attribute refused, for `unique` and `mailDomain`.
100
+ * value:
101
+ * type: string
102
+ * description: The value refused, for `unique` and `mailDomain`.
103
+ * domains:
104
+ * type: array
105
+ * items: { type: string }
106
+ * description: The authorised mail domains, for `mailDomain`.
86
107
  * example:
87
- * error: Group not found
88
- * code: 404
108
+ * error: 'entity: "value" is already used (attribute must be unique)'
109
+ * code: unique
110
+ * attribute: attribute
111
+ * value: value
89
112
  */
90
113
  export default class LdapGroups extends DmPlugin {
91
114
  name: string;
@@ -103,7 +126,8 @@ export default class LdapGroups extends DmPlugin {
103
126
  /**
104
127
  * Remove deleted entries from groups once the delete has landed: a delete
105
128
  * refused, failed or turned into something else by another plugin leaves
106
- * the memberships alone.
129
+ * the memberships alone — except a move into the trash, which frees the DN
130
+ * all the same.
107
131
  */
108
132
  hooks: Hooks;
109
133
  /**
@@ -239,6 +239,7 @@ export default class PasswordPolicy extends DmPlugin {
239
239
  private discoveredDefault;
240
240
  private warnedAmbiguousDefault;
241
241
  private warnedMissingBase;
242
+ private warnedUnreadableSchema;
242
243
  private readonly POLICY_CACHE_TTL;
243
244
  api(app: Express): void;
244
245
  /**
@@ -288,7 +289,11 @@ export default class PasswordPolicy extends DmPlugin {
288
289
  */
289
290
  private readPolicyAt;
290
291
  private forgetPolicies;
291
- private policyBase;
292
+ /**
293
+ * Where the policies are listed from: `--ppolicy-base`, else
294
+ * `--ldap-base`. `authzScope` asks whether a caller may search it.
295
+ */
296
+ policyBase(): string;
292
297
  /**
293
298
  * The policy entries under the policy base matching `filter`, none when
294
299
  * the base itself is missing
@@ -299,6 +304,13 @@ export default class PasswordPolicy extends DmPlugin {
299
304
  */
300
305
  private findPolicy;
301
306
  private listPolicies;
307
+ /**
308
+ * The writable policy attributes the directory's schema defines: OpenLDAP
309
+ * 2.4 lacks pwdMaxLength, pwdMaxIdle, pwdMinDelay and pwdMaxDelay, and
310
+ * only 2.6 has the checker's. All of them when the schema cannot be read:
311
+ * the directory still refuses what it lacks.
312
+ */
313
+ private definedAttributes;
302
314
  private toPolicyRecord;
303
315
  /**
304
316
  * The directory form of a value a client sent for policy attribute `name`
@@ -1142,6 +1142,20 @@ All errors return a JSON object with an error message:
1142
1142
  }
1143
1143
  ```
1144
1144
 
1145
+ The message is English, for a log or a developer. The refusals a client is
1146
+ expected to explain to its users carry a `code` too, and what was refused,
1147
+ for the client to say it in its own words and next to the field at fault.
1148
+ `error` is always there; read the other fields only when `code` is:
1149
+
1150
+ | `code` | Status | Refusal | Other fields |
1151
+ | ------------ | ------ | ------------------------------------------------------------ | ------------------------------- |
1152
+ | `unique` | 409 | A value another entry already holds | `attribute`, `value` |
1153
+ | `mailDomain` | 409 | An address outside the mail domains its organization may use | `attribute`, `value`, `domains` |
1154
+ | `forbidden` | 403 | The caller may not act on this branch | none: the branch is not named |
1155
+
1156
+ Any other refusal has no `code`, and only its message says what it is. The
1157
+ bulk import reports the same fields on each refused row.
1158
+
1145
1159
  ### Common Errors
1146
1160
 
1147
1161
  #### 400 Bad Request
@@ -1210,6 +1224,18 @@ not a DN (LDAP result code 34):
1210
1224
  }
1211
1225
  ```
1212
1226
 
1227
+ #### 403 Forbidden
1228
+
1229
+ **Refused by authorization** — the caller may not read, write or delete in
1230
+ this branch. The branch is not named, the caller may not know of it:
1231
+
1232
+ ```json
1233
+ {
1234
+ "error": "Token does not have permission on this branch",
1235
+ "code": "forbidden"
1236
+ }
1237
+ ```
1238
+
1213
1239
  #### 404 Not Found
1214
1240
 
1215
1241
  **User Not Found:**
@@ -1259,6 +1285,32 @@ result codes 32 and 16). Removing a group member that is not one names both:
1259
1285
  A rename or a move onto a DN already taken answers the same way, naming that
1260
1286
  DN (LDAP result code 68).
1261
1287
 
1288
+ **Value already used** — a value the schema says is unique
1289
+ ([`unique`](../../usage/plugins/ldap/enterprise-rules.md)), held by another
1290
+ entry, which is not named:
1291
+
1292
+ ```json
1293
+ {
1294
+ "error": "user: \"alice@example.com\" is already used (mail must be unique)",
1295
+ "code": "unique",
1296
+ "attribute": "mail",
1297
+ "value": "alice@example.com"
1298
+ }
1299
+ ```
1300
+
1301
+ **Mail domain not authorised** — an address outside the domains the entry's
1302
+ organization, or one of its ancestors, links to:
1303
+
1304
+ ```json
1305
+ {
1306
+ "error": "The mail domain of \"x.user@other.org\" is not one of the authorised domains (example.com)",
1307
+ "code": "mailDomain",
1308
+ "attribute": "mail",
1309
+ "value": "x.user@other.org",
1310
+ "domains": ["example.com"]
1311
+ }
1312
+ ```
1313
+
1262
1314
  **Entry that cannot hold a change** — a modify adding an attribute the
1263
1315
  classes of the entry do not allow, or a value it already holds (LDAP result
1264
1316
  codes 65 and 20 on a modify):
@@ -417,12 +417,13 @@ whichever of the two names it selects. A value other than `req.user` or
417
417
 
418
418
  #### `core/auth/authzPerBranch`
419
419
 
420
- | CLI | Env | Default | Description |
421
- | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
422
- | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
423
- | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
424
- | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
425
- | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
420
+ | CLI | Env | Default | Description |
421
+ | ---------------------------------- | ----------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
422
+ | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
423
+ | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
424
+ | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
425
+ | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
426
+ | `--authz-transit-origin-attribute` | `DM_AUTHZ_TRANSIT_ORIGIN_ATTRIBUTE` | | Attribute an entry put in transit keeps the organization it leaves in, removed when it is claimed. Empty records nothing; it must exist in the directory's schema. See [authzLinid1](plugins/auth/authz-linid1.md#where-an-entry-comes-from). |
426
427
 
427
428
  #### `core/auth/authzScope`
428
429
 
@@ -96,6 +96,40 @@ to the transit branch follows the same rules through
96
96
  `POST /api/v1/ldap/groups/:cn/move`; that endpoint does not move a group
97
97
  attached to no organization.
98
98
 
99
+ #### Where an entry comes from
100
+
101
+ Once in transit, an entry no longer says which organization it left, and the
102
+ administrator who would claim it cannot tell whether it is theirs. Name an
103
+ attribute to keep it in; `twakeTransitOrigin` below is an example, which
104
+ the directory's schema must define (see the rules after it):
105
+
106
+ ```bash
107
+ --authz-transit-origin-attribute twakeTransitOrigin
108
+ ```
109
+
110
+ Putting an entry in transit, by `move` or by any modify of its
111
+ `twakeDepartmentLink`, then sets that attribute to the DN of the organization
112
+ it leaves; claiming it removes the attribute. A move from transit to transit
113
+ keeps it, and an entry that was attached to no organization gets none. A
114
+ modify that sets or removes the attribute itself is left as it is. The name
115
+ is compared as LDAP does, whatever its case. Without a transit branch, the
116
+ option does nothing.
117
+
118
+ The attribute is the directory's to allow, and its rules apply to the whole
119
+ move: what it refuses, it refuses the move with.
120
+
121
+ - **A dedicated attribute.** The origin is written with a `replace`: every
122
+ value the attribute held is overwritten. A business attribute such as
123
+ `departmentNumber`, which may hold several real values, would lose them.
124
+ The Twake schema has no attribute for this: add one to the directory's
125
+ schema.
126
+ - **Optional.** Claiming the entry removes the attribute, so it must be
127
+ among the `MAY` of its object classes, never a `MUST`. It must be allowed
128
+ on every entry that can be handed over, accounts and groups alike.
129
+ - **Writable.** The directory's ACLs must let ldap-rest's bind write it.
130
+
131
+ Without the option, nothing is recorded.
132
+
99
133
  ### Mail domains
100
134
 
101
135
  The mail domains an organization's entries may use are those it links in
@@ -145,7 +179,8 @@ a search started above the branch leaves its entries out. That is how only
145
179
  the administrators of the whole directory manage the
146
180
  [password policies](../ldap/password-policy.md#managing-policies). A global
147
181
  branch is no branch of theirs in `GET /authz/scope`, so it adds nothing to
148
- the organizations they are offered.
182
+ the organizations they are offered; `passwordPolicies` there says whether
183
+ they may list the policies.
149
184
 
150
185
  `--authz-global-write-branches` names branches whose writes and deletes are
151
186
  held the same way, while reading them stays as for the rest of the reference
@@ -495,11 +495,26 @@ newuser,New User,User,new2@example.com
495
495
  "line": 10,
496
496
  "identifier": "user009",
497
497
  "error": "Invalid DN format"
498
+ },
499
+ {
500
+ "line": 11,
501
+ "identifier": "user010",
502
+ "error": "user: \"jane@example.com\" is already used (mail must be unique)",
503
+ "code": "unique",
504
+ "attribute": "mail",
505
+ "value": "jane@example.com"
498
506
  }
499
507
  ]
500
508
  }
501
509
  ```
502
510
 
511
+ A row refused by a rule a client is expected to explain — a value already
512
+ used, a mail domain outside the authorised ones, with
513
+ [enterprise rules](enterprise-rules.md) loaded — carries the same `code`,
514
+ `attribute`, `value` and `domains` as the answer of a single write: see
515
+ [the error format](../../../client-development/api/rest-api.md#error-response-format).
516
+ Other rows only have `error`.
517
+
503
518
  ## Best Practices
504
519
 
505
520
  ### Handling Partial Failures