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.
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/errors.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/ldap/bulkImport.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/passwordPolicy.js +1 -1
- package/dist/src/config/args.d.ts +6 -0
- package/dist/src/config/schema.d.ts +5 -3
- package/dist/src/lib/authz/base.d.ts +39 -0
- package/dist/src/lib/errors.d.ts +35 -2
- package/dist/src/lib/utils.d.ts +12 -0
- package/dist/src/plugins/auth/authzScope.d.ts +19 -0
- package/dist/src/plugins/ldap/bulkImport.d.ts +10 -0
- package/dist/src/plugins/ldap/enterpriseRules.d.ts +25 -7
- package/dist/src/plugins/ldap/groups.d.ts +29 -5
- package/dist/src/plugins/ldap/passwordPolicy.d.ts +13 -1
- package/docs/client-development/api/rest-api.md +52 -0
- package/docs/usage/configuration.md +7 -6
- package/docs/usage/plugins/auth/authz-linid1.md +36 -1
- package/docs/usage/plugins/ldap/bulk-import.md +15 -0
- package/docs/usage/plugins/ldap/enterprise-rules.md +41 -11
- package/docs/usage/plugins/ldap/password-policy.md +13 -1
- package/docs/usage/plugins/ldap/trash.md +9 -0
- package/docs/usage/upgrading.md +37 -0
- package/package.json +1 -1
- package/static/schemas/example/groups.json +2 -1
- package/static/schemas/example/organizations.json +2 -0
- package/static/schemas/standard/devices.json +4 -2
- package/static/schemas/twake/groups.json +2 -1
- package/static/schemas/twake/organizations.json +2 -0
- package/static/schemas/twake/teamMailboxes.json +2 -1
- 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
|
|
221
|
-
*
|
|
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
|
package/dist/src/lib/errors.d.ts
CHANGED
|
@@ -7,7 +7,27 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export declare class HttpError extends Error {
|
|
9
9
|
statusCode: number;
|
|
10
|
-
|
|
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);
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
27
|
-
* entry naming another one by its DN. They look
|
|
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,
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
85
|
-
*
|
|
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:
|
|
88
|
-
* code:
|
|
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
|
-
|
|
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
|
|
421
|
-
|
|
|
422
|
-
| `--authz-per-branch-config`
|
|
423
|
-
| `--authz-per-branch-cache-ttl`
|
|
424
|
-
| `--authz-filter-attached-entries`
|
|
425
|
-
| `--authz-transit-branch`
|
|
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
|