ldap-rest 0.3.1 → 0.3.2
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/config/args.js +1 -1
- package/dist/lib/expressFormatedResponses.js +1 -1
- package/dist/plugins/auth/authzPerRoute.js +1 -0
- package/dist/plugins/priority.json.js +1 -1
- package/dist/plugins/twake/cozyProvision.js +1 -0
- package/dist/src/config/args.d.ts +10 -0
- package/dist/src/plugins/auth/authzPerRoute.d.ts +22 -0
- package/dist/src/plugins/twake/cozyProvision.d.ts +54 -0
- package/docs/usage/configuration.md +7 -0
- package/docs/usage/plugins/auth/README.md +1 -0
- package/docs/usage/plugins/auth/authz-per-route.md +144 -0
- package/package.json +10 -1
package/dist/config/args.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{fileURLToPath as
|
|
1
|
+
import{fileURLToPath as _}from"url";import{join as a,dirname as e}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls"],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",300,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",a(e(_(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--drive-quota-attribute","DM_DRIVE_QUOTA_ATTRIBUTE","twakeDriveQuota"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",a(e(_(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes"],["--static-path","DM_STATIC_PATH",a(e(_(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--twake-drive-webadmin-url","DM_TWAKE_DRIVE_WEBADMIN_URL",""],["--twake-drive-webadmin-token","DM_TWAKE_DRIVE_WEBADMIN_TOKEN",""],["--twake-drive-concurrency","DM_TWAKE_DRIVE_CONCURRENCY",10,"number"],["--twake-drive-domain-attribute","DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE","twakeCozyDomain"],["--twake-drive-default-domain-template","DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE",""],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--cozy-admin-url","DM_COZY_ADMIN_URL",""],["--cozy-admin-user","DM_COZY_ADMIN_USER","admin"],["--cozy-admin-passphrase","DM_COZY_ADMIN_PASSPHRASE",""],["--cozy-org-id","DM_COZY_ORG_ID",""],["--cozy-org-domain","DM_COZY_ORG_DOMAIN",""],["--cozy-default-locale","DM_COZY_DEFAULT_LOCALE","fr"],["--cozy-auth-exchange","DM_COZY_AUTH_EXCHANGE","auth"],["--cozy-b2b-exchange","DM_COZY_B2B_EXCHANGE","b2b"],["--rabbitmq-url","DM_RABBITMQ_URL",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-route","DM_AUTHZ_PER_ROUTES",[],"array","--authz-per-routes"],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-dynamic-base","DM_AUTHZ_DYNAMIC_BASE",""],["--authz-dynamic-cache-ttl","DM_AUTHZ_DYNAMIC_CACHE_TTL",60,"number"],["--authz-dynamic-token-attribute","DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE","userPassword"],["--authz-dynamic-config-attribute","DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE","description"],["--authz-dynamic-tenant-attribute","DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE","cn"],["--authz-dynamic-reload-endpoint","DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",!1,"boolean"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"],["--ppolicy-default-dn","DM_PPOLICY_DEFAULT_DN",""],["--ppolicy-warn-days","DM_PPOLICY_WARN_DAYS",14,"number"],["--ppolicy-validate-complexity","DM_PPOLICY_VALIDATE_COMPLEXITY",!1,"boolean"],["--ppolicy-min-length","DM_PPOLICY_MIN_LENGTH",12,"number"],["--ppolicy-require-uppercase","DM_PPOLICY_REQUIRE_UPPERCASE",!0,"boolean"],["--ppolicy-require-lowercase","DM_PPOLICY_REQUIRE_LOWERCASE",!0,"boolean"],["--ppolicy-require-digit","DM_PPOLICY_REQUIRE_DIGIT",!0,"boolean"],["--ppolicy-require-special","DM_PPOLICY_REQUIRE_SPECIAL",!0,"boolean"],["--ldap-users-base","DM_LDAP_USERS_BASE",""],["--scim-prefix","DM_SCIM_PREFIX","/scim/v2"],["--scim-user-base","DM_SCIM_USER_BASE",""],["--scim-group-base","DM_SCIM_GROUP_BASE",""],["--scim-user-base-template","DM_SCIM_USER_BASE_TEMPLATE",""],["--scim-group-base-template","DM_SCIM_GROUP_BASE_TEMPLATE",""],["--scim-base-map","DM_SCIM_BASE_MAP",""],["--scim-user-object-class","DM_SCIM_USER_OBJECT_CLASSES",["top","inetOrgPerson","organizationalPerson","person"],"array","--scim-user-object-classes"],["--scim-user-rdn-attribute","DM_SCIM_USER_RDN_ATTRIBUTE","uid"],["--scim-group-object-class","DM_SCIM_GROUP_OBJECT_CLASSES",["top","groupOfNames"],"array","--scim-group-object-classes"],["--scim-group-rdn-attribute","DM_SCIM_GROUP_RDN_ATTRIBUTE","cn"],["--scim-id-attribute","DM_SCIM_ID_ATTRIBUTE","rdn"],["--scim-user-mapping","DM_SCIM_USER_MAPPING",""],["--scim-group-mapping","DM_SCIM_GROUP_MAPPING",""],["--scim-max-results","DM_SCIM_MAX_RESULTS",200,"number"],["--scim-bulk-max-operations","DM_SCIM_BULK_MAX_OPERATIONS",100,"number"],["--scim-bulk-max-payload-size","DM_SCIM_BULK_MAX_PAYLOAD_SIZE",1048576,"number"],["--scim-etag","DM_SCIM_ETAG",!1,"boolean"],["--scim-base-url","DM_SCIM_BASE_URL",""]];export{t as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
let r;const setLogger=t=>{r=t},getLogger=()=>r,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const n=await t(...e);return ok(r,n)}catch(t){return serverError(r,t)}},_rejectResponse=(t,e,n)=>{r?.warn(`Client error ${t}: ${n}`),e.status(t).json({error:n})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(t,e)=>{const n=e instanceof Error?e.message:String(e);let
|
|
1
|
+
let r;const setLogger=t=>{r=t},getLogger=()=>r,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const n=await t(...e);return ok(r,n)}catch(t){return serverError(r,t)}},_rejectResponse=(t,e,n)=>{r?.warn(`Client error ${t}: ${n}`),e.status(t).json({error:n})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),forbidden=(r,t="Forbidden")=>_rejectResponse(403,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(t,e)=>{const n=e instanceof Error?e.message:String(e);let o=e instanceof Error&&"statusCode"in e?e.statusCode:500;if(500===o&&/\[authz-forbidden\]/.test(n)&&(o=403),o>=400&&o<500){const e=403===o&&/\[authz-forbidden\]/.test(n)?"Token does not have permission on this branch":n;return r.warn(`Client error ${o}: ${e}`),void t.status(o).json({error:e})}e instanceof Error?r.error(e.message,e):"string"==typeof e?r.error(e):r.error("Server error",e),t.status(o).json({error:"check logs"})},wantJson=(r,t)=>!1!==r.accepts("json")||(badRequest(t),!1),jsonBody=(r,t,...e)=>{try{if(!wantJson(r,t))return!1;const n=r.body;if(e.length>0)for(let r=0;r<e.length;r++)if(null==n[e[r]])return badRequest(t,"Bad content"),!1;return n}catch(r){return badRequest(t,"Bad content"),!1}};export{badRequest,created,forbidden,getLogger,jsonBody,notFound,ok,serverError,setLogger,tryMethod,tryMethodData,unauthorized,wantJson};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import e from"../../abstract/plugin.js";import{forbidden as t}from"../../lib/expressFormatedResponses.js";const r=/^[\w/.\-+*]*$/;function globToRegex(e){if(!r.test(e))throw new Error(`Invalid glob pattern: "${e}" — only [a-zA-Z0-9_/.-+*] are allowed`);const t=e.replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/\\\*\\\*/g,".*").replace(/\\\*/g,"[^/]*");return new RegExp(`^${t}$`)}class AuthzPerRoute extends e{constructor(...e){super(...e),this.name="authzPerRoute",this.roles=["authz"],this.rules=new Map;const t=this.config.authz_per_route??[];for(const e of t)this.parseEntry(e);const r=[...this.rules.entries()].map(([e,t])=>`${e}: ${t.some(e=>"wildcard"===e.kind)?"full access":`${t.length} rule${1!==t.length?"s":""}`}`).join(", ");this.logger.info(`authzPerRoute: ${this.rules.size} user${1!==this.rules.size?"s":""} configured${this.rules.size>0?` (${r})`:""}`)}parseEntry(e){const t=e.split(":").map(e=>e.trim());if(t.length<2)return void this.logger.warn(`authzPerRoute: ignoring invalid rule entry: ${e}`);const r=t[0];if(r)if(2!==t.length||"*"!==t[1]){if(t.length>=3){const i=t[1].toUpperCase(),o=new Set(["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS","*"]);if(!i||!o.has(i))return void this.logger.warn(`authzPerRoute: ignoring rule with invalid method "${t[1]}" in entry: ${e}`);const n=t.slice(2).join(":").trim();if(!n)return void this.logger.warn(`authzPerRoute: ignoring rule with empty path pattern in entry: ${e}`);let s;try{s=globToRegex(n)}catch{return void this.logger.warn(`authzPerRoute: ignoring entry with invalid glob "${n}" in rule: ${e}`)}return void this.addRule(r,{kind:"method-path",method:i,pathRe:s})}this.logger.warn(`authzPerRoute: ignoring invalid rule entry: ${e}`)}else this.addRule(r,{kind:"wildcard"});else this.logger.warn(`authzPerRoute: ignoring rule with empty user: ${e}`)}addRule(e,t){const r=this.rules.get(e);r?r.push(t):this.rules.set(e,[t])}matches(e,t,r){for(const i of e){if("wildcard"===i.kind)return!0;if(("*"===i.method||i.method===t.toUpperCase())&&i.pathRe.test(r))return!0}return!1}api(e){e.use((e,r,i)=>{const o=e.user;if(!o)return i();const n=this.rules.get(o);return n?this.matches(n,e.method,e.path)?i():(this.logger.warn(`authzPerRoute: user '${o}' denied for ${e.method} ${e.path}`),t(r)):(this.logger.warn(`authzPerRoute: user '${o}' denied for ${e.method} ${e.path} (no rules configured)`),t(r))})}}export{AuthzPerRoute as default,globToRegex};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var e=["core/auth/trustedProxy","core/weblogs","core/auth/crowdsec","core/auth/rateLimit","core/auth/token","core/auth/llng","core/auth/openidconnect"];export{e as default};
|
|
1
|
+
var e=["core/auth/trustedProxy","core/weblogs","core/auth/crowdsec","core/auth/rateLimit","core/auth/token","core/auth/llng","core/auth/openidconnect","core/auth/authzPerRoute"];export{e as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{Buffer as t}from"buffer";import e from"node-fetch";import i from"../../abstract/plugin.js";class CozyProvision extends i{constructor(t){super(t),this.name="cozyProvision",this.roles=["consistency"],this.publisher=null,this.publisherInit=null,this.hooks={scimusercreatedone:async t=>{await this.createCozyInstance(t)&&await this.publishUserCreated(t)},scimuserdeletedone:async t=>{await this.publishUserDeleted(t)}},this.cozyAdminUrl=(this.config.cozy_admin_url||"").replace(/\/$/,""),this.cozyAdminUser=this.config.cozy_admin_user||"admin",this.cozyAdminPassphrase=this.config.cozy_admin_passphrase||"",this.cozyOrgId=this.config.cozy_org_id||"",this.cozyOrgDomain=this.config.cozy_org_domain||"",this.cozyDefaultLocale=this.config.cozy_default_locale||"fr",this.rabbitmqUrl=this.config.rabbitmq_url||"",this.authExchange=this.config.cozy_auth_exchange||"auth",this.b2bExchange=this.config.cozy_b2b_exchange||"b2b",this.cozyAdminUrl||this.logger.warn(`${this.name}: cozy_admin_url is empty — Cozy instance creation will be skipped`),this.cozyOrgDomain||this.logger.warn(`${this.name}: cozy_org_domain is empty — workplaceFqdn cannot be composed`),this.rabbitmqUrl||this.logger.warn(`${this.name}: rabbitmq_url is empty — AMQP publishes will be skipped`),this.registerShutdown()}async createCozyInstance(i){if(!this.cozyAdminUrl)return!1;const s=this.extractId(i);if(!s)return this.logger.warn({plugin:this.name,event:"createCozyInstance",message:"user has no userName/id — skipping"}),!1;if(!this.cozyOrgDomain)return this.logger.error({plugin:this.name,event:"createCozyInstance",id:s,message:"cozy_org_domain not configured — cannot compose Domain"}),!1;const r=`${s}.${this.cozyOrgDomain}`,n=new URLSearchParams;n.set("Domain",r),n.set("Locale",this.extractLocale(i));const a=this.extractPrimaryEmail(i);a&&n.set("Email",a),this.cozyOrgId&&n.set("OrgID",this.cozyOrgId),this.cozyOrgDomain&&n.set("OrgDomain",this.cozyOrgDomain);const o=`${this.cozyAdminUrl}/instances?${n.toString()}`,c=t.from(`${this.cozyAdminUser}:${this.cozyAdminPassphrase}`).toString("base64"),h={plugin:this.name,event:"createCozyInstance",id:s,domain:r,email:a||void 0};try{const t=await e(o,{method:"POST",headers:{Authorization:`Basic ${c}`,Accept:"application/json"}});return t.ok?(this.logger.info({...h,result:"success",http_status:t.status}),!0):409===t.status?(this.logger.info({...h,result:"already_exists",http_status:t.status}),!0):(this.logger.error({...h,result:"error",http_status:t.status,http_status_text:t.statusText}),!1)}catch(t){return this.logger.error({...h,result:"error",error:`${t}`}),!1}}async publishUserCreated(t){const e=await this.getPublisher();if(!e)return;const i=this.extractId(t);if(!i)return;const s=this.extractPrimaryEmail(t),r={sub:i,organizationDomain:this.cozyOrgDomain,workplaceFqdn:`${i}.${this.cozyOrgDomain}`};s&&(r.email=s);try{await e.publish(this.authExchange,"user.created",r),this.logger.info({plugin:this.name,event:"publishUserCreated",result:"success",exchange:this.authExchange,routingKey:"user.created",sub:i})}catch(t){this.logger.error({plugin:this.name,event:"publishUserCreated",sub:i,error:`${t}`})}}async publishUserDeleted(t){const e=await this.getPublisher();if(!e)return;if(!this.cozyOrgDomain)return void this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,message:"cozy_org_domain not configured — cannot compose workplaceFqdn"});const i={workplaceFqdn:`${t}.${this.cozyOrgDomain}`,domain:this.cozyOrgDomain};try{await e.publish(this.b2bExchange,"domain.user.deleted",i),this.logger.info({plugin:this.name,event:"publishUserDeleted",result:"success",exchange:this.b2bExchange,routingKey:"domain.user.deleted",workplaceFqdn:i.workplaceFqdn})}catch(e){this.logger.error({plugin:this.name,event:"publishUserDeleted",id:t,error:`${e}`})}}async getPublisher(){return this.publisher?this.publisher:this.rabbitmqUrl?(this.publisherInit||(this.publisherInit=(async()=>{try{const t=new((await import("@linagora/rabbitmq-client")).RabbitMQClient)({url:this.rabbitmqUrl});return await t.init(),this.publisher=t,this.logger.info(`${this.name}: connected to RabbitMQ at ${function(t){try{const e=new URL(t);return e.username="",e.password="",e.toString()}catch{return"amqp://[broker]"}}(this.rabbitmqUrl)}`),t}catch(t){return this.logger.warn({plugin:this.name,event:"rabbitmq_init",message:"@linagora/rabbitmq-client unavailable or broker unreachable — AMQP publishes disabled",error:`${t}`}),null}})()),this.publisherInit):null}extractId(t){return"string"==typeof t.userName&&t.userName.length>0?t.userName:"string"==typeof t.id&&t.id.length>0?t.id:null}extractPrimaryEmail(t){const e=t.emails;if(!e||0===e.length)return null;const i=(e.find(t=>t.primary)||e[0]).value;return"string"==typeof i&&i.length>0?i:null}extractLocale(t){return"string"==typeof t.locale&&t.locale.length>0?t.locale:"string"==typeof t.preferredLanguage&&t.preferredLanguage.length>0?t.preferredLanguage:this.cozyDefaultLocale}registerShutdown(){!function(t){if(s.push(t),r)return;r=!0;const fanOut=()=>{for(const t of s.splice(0))try{t()}catch{}};process.once("SIGTERM",fanOut),process.once("SIGINT",fanOut)}(()=>{const t=this.publisher;this.publisher=null,this.publisherInit=null,t&&t.close().catch(t=>{this.logger.warn({plugin:this.name,event:"shutdown",error:`${t}`})})})}}const s=[];let r=!1;export{CozyProvision as default};
|
|
@@ -76,6 +76,7 @@ export interface Config {
|
|
|
76
76
|
oidc_client_id?: string;
|
|
77
77
|
oidc_client_secret?: string;
|
|
78
78
|
base_url?: string;
|
|
79
|
+
authz_per_route?: string[];
|
|
79
80
|
authz_per_branch_config?: AuthConfig;
|
|
80
81
|
authz_per_branch_cache_ttl?: number;
|
|
81
82
|
authz_dynamic_base?: string;
|
|
@@ -119,6 +120,15 @@ export interface Config {
|
|
|
119
120
|
calendar_resource_objectclass?: string;
|
|
120
121
|
calendar_resource_creator?: string;
|
|
121
122
|
calendar_resource_domain?: string;
|
|
123
|
+
cozy_admin_url?: string;
|
|
124
|
+
cozy_admin_user?: string;
|
|
125
|
+
cozy_admin_passphrase?: string;
|
|
126
|
+
cozy_org_id?: string;
|
|
127
|
+
cozy_org_domain?: string;
|
|
128
|
+
cozy_default_locale?: string;
|
|
129
|
+
cozy_auth_exchange?: string;
|
|
130
|
+
cozy_b2b_exchange?: string;
|
|
131
|
+
rabbitmq_url?: string;
|
|
122
132
|
applicative_account_base?: string;
|
|
123
133
|
max_app_accounts?: number;
|
|
124
134
|
ldap_operational_attributes?: string[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/auth/authzPerRoute
|
|
3
|
+
* Route-level authorization plugin.
|
|
4
|
+
*
|
|
5
|
+
* Restricts access by HTTP method + path based on the authenticated user name
|
|
6
|
+
* (req.user, set by an auth plugin loaded before this one).
|
|
7
|
+
*
|
|
8
|
+
* @author Xavier Guimard <xguimard@linagora.com>
|
|
9
|
+
*/
|
|
10
|
+
import type { Express } from 'express';
|
|
11
|
+
import DmPlugin, { type Role } from '../../abstract/plugin';
|
|
12
|
+
export declare function globToRegex(glob: string): RegExp;
|
|
13
|
+
export default class AuthzPerRoute extends DmPlugin {
|
|
14
|
+
name: string;
|
|
15
|
+
roles: Role[];
|
|
16
|
+
private rules;
|
|
17
|
+
constructor(...args: ConstructorParameters<typeof DmPlugin>);
|
|
18
|
+
private parseEntry;
|
|
19
|
+
private addRule;
|
|
20
|
+
private matches;
|
|
21
|
+
api(app: Express): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import DmPlugin, { type Role } from '../../abstract/plugin';
|
|
2
|
+
import type { DM } from '../../bin';
|
|
3
|
+
import { Hooks } from '../../hooks';
|
|
4
|
+
interface RabbitPublisher {
|
|
5
|
+
init(): Promise<void>;
|
|
6
|
+
publish(exchange: string, routingKey: string, message: Record<string, unknown>): Promise<void>;
|
|
7
|
+
close(clearSubscriptions?: boolean): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export default class CozyProvision extends DmPlugin {
|
|
10
|
+
name: string;
|
|
11
|
+
roles: Role[];
|
|
12
|
+
private readonly cozyAdminUrl;
|
|
13
|
+
private readonly cozyAdminPassphrase;
|
|
14
|
+
private readonly cozyAdminUser;
|
|
15
|
+
private readonly cozyOrgId;
|
|
16
|
+
private readonly cozyOrgDomain;
|
|
17
|
+
private readonly cozyDefaultLocale;
|
|
18
|
+
private readonly rabbitmqUrl;
|
|
19
|
+
private readonly authExchange;
|
|
20
|
+
private readonly b2bExchange;
|
|
21
|
+
private publisher;
|
|
22
|
+
private publisherInit;
|
|
23
|
+
constructor(server: DM);
|
|
24
|
+
hooks: Hooks;
|
|
25
|
+
/**
|
|
26
|
+
* POST /instances on the cozy-stack admin API. Returns true on success
|
|
27
|
+
* (including 409 idempotent "already exists").
|
|
28
|
+
*/
|
|
29
|
+
private createCozyInstance;
|
|
30
|
+
/**
|
|
31
|
+
* Publish user.created on the `auth` topic exchange. Payload mirrors what
|
|
32
|
+
* cozy-stack expects: a sub identifier, an optional email, and the
|
|
33
|
+
* organizationDomain so consumers can match the instance.
|
|
34
|
+
*/
|
|
35
|
+
private publishUserCreated;
|
|
36
|
+
/**
|
|
37
|
+
* Publish domain.user.deleted on the `b2b` topic exchange so peer cozy
|
|
38
|
+
* instances can drop the deleted user's contact card.
|
|
39
|
+
*/
|
|
40
|
+
private publishUserDeleted;
|
|
41
|
+
/**
|
|
42
|
+
* Lazy-init the AMQP publisher. The `@linagora/rabbitmq-client` dependency
|
|
43
|
+
* is optional — if the package or the broker is unreachable, return null
|
|
44
|
+
* so callers can no-op cleanly.
|
|
45
|
+
*
|
|
46
|
+
* Override this in tests to inject a stub publisher.
|
|
47
|
+
*/
|
|
48
|
+
protected getPublisher(): Promise<RabbitPublisher | null>;
|
|
49
|
+
private extractId;
|
|
50
|
+
private extractPrimaryEmail;
|
|
51
|
+
private extractLocale;
|
|
52
|
+
private registerShutdown;
|
|
53
|
+
}
|
|
54
|
+
export {};
|
|
@@ -27,6 +27,7 @@ All LDAP-Rest configuration options.
|
|
|
27
27
|
- [core/auth/openidconnect](#coreauthopenidconnect)
|
|
28
28
|
- [Authorization Plugins](#authorization-plugins)
|
|
29
29
|
- [core/auth/authzPerBranch](#coreauthauthzperbranch)
|
|
30
|
+
- [core/auth/authzPerRoute](#coreauthauthzperroute)
|
|
30
31
|
- [core/auth/authzLinid1](#coreauthauthzlinid1)
|
|
31
32
|
- [Security Plugins](#security-plugins)
|
|
32
33
|
- [core/auth/rateLimit](#coreauthratelimit)
|
|
@@ -236,6 +237,12 @@ Maintains consistency of department links when organizations are renamed/moved.
|
|
|
236
237
|
| `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
|
|
237
238
|
| `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
|
|
238
239
|
|
|
240
|
+
#### `core/auth/authzPerRoute`
|
|
241
|
+
|
|
242
|
+
| CLI | Env | Default | Description |
|
|
243
|
+
| ------------------- | --------------------- | ------- | ---------------------------------------------------------------------- |
|
|
244
|
+
| `--authz-per-route` | `DM_AUTHZ_PER_ROUTES` | `[]` | Per-user route ACL rules (see [docs](plugins/auth/authz-per-route.md)) |
|
|
245
|
+
|
|
239
246
|
#### `core/auth/authzLinid1`
|
|
240
247
|
|
|
241
248
|
| CLI | Env | Default | Description |
|
|
@@ -17,6 +17,7 @@ LDAP-Rest provides multiple authentication plugins to secure API access. These p
|
|
|
17
17
|
| Method | Plugin | Description |
|
|
18
18
|
| ----------------------------------------------- | -------------------------- | --------------------------- |
|
|
19
19
|
| [Authorization Per Branch](authz-per-branch.md) | `core/auth/authzPerBranch` | Branch-level access control |
|
|
20
|
+
| [Authorization Per Route](authz-per-route.md) | `core/auth/authzPerRoute` | HTTP route-level ACL |
|
|
20
21
|
| [Authorization LinID 1.x](authz-linid1.md) | `core/auth/authzLinid1` | LinID 1.x integration |
|
|
21
22
|
|
|
22
23
|
## Security Plugins
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Route-Level Authorization (`authzPerRoute`)
|
|
2
|
+
|
|
3
|
+
Restricts access to specific HTTP routes based on the authenticated user's name
|
|
4
|
+
(`req.user`), which is set by an auth plugin such as `core/auth/token`.
|
|
5
|
+
|
|
6
|
+
This plugin complements `authzPerBranch` (LDAP-branch ACL) and `authzDynamic`
|
|
7
|
+
(LDAP-backed tokens): whereas those operate at the LDAP-branch level,
|
|
8
|
+
`authzPerRoute` works at the HTTP route level and needs no LDAP connection.
|
|
9
|
+
|
|
10
|
+
## Plugin loading order
|
|
11
|
+
|
|
12
|
+
`authzPerRoute` MUST run **after** the authentication plugin (so `req.user` is set)
|
|
13
|
+
AND **before** any API route plugin (so its Express middleware is registered before
|
|
14
|
+
the routes it protects).
|
|
15
|
+
|
|
16
|
+
When loaded via the CLI plugin loader, plugins not listed in
|
|
17
|
+
`src/plugins/priority.json` are loaded in parallel — this can race with API route
|
|
18
|
+
registration and result in an authorization bypass. To guarantee correct ordering,
|
|
19
|
+
`core/auth/authzPerRoute` is included in the priority list and loads sequentially
|
|
20
|
+
after the other auth plugins. If you build a custom loader, ensure equivalent
|
|
21
|
+
sequencing.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
--plugin core/auth/token \
|
|
25
|
+
--plugin core/auth/authzPerRoute
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
### CLI
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
--authz-per-route "full-access:*" \
|
|
34
|
+
--authz-per-route "updt-only:POST:/api/v1/ldap/updt" \
|
|
35
|
+
--authz-per-route "updt-only:GET:/api/v1/ldap/updt/**"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Environment Variable
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
DM_AUTHZ_PER_ROUTES="full-access:*,updt-only:POST:/api/v1/ldap/updt,updt-only:GET:/api/v1/ldap/updt/**"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Multiple values are comma-separated. Repeating `--authz-per-route` on the CLI
|
|
45
|
+
is equivalent.
|
|
46
|
+
|
|
47
|
+
## Rule Syntax
|
|
48
|
+
|
|
49
|
+
Each entry has one of two forms:
|
|
50
|
+
|
|
51
|
+
| Form | Meaning |
|
|
52
|
+
| ---------------------------- | ----------------------------------------------------------- |
|
|
53
|
+
| `<user>:*` | Full wildcard — allow any method on any path |
|
|
54
|
+
| `<user>:<METHOD>:<pathGlob>` | Allow only the given HTTP method on paths matching the glob |
|
|
55
|
+
|
|
56
|
+
- `<user>` — the value of `req.user` as set by the auth plugin.
|
|
57
|
+
- `<METHOD>` — an HTTP verb (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`,
|
|
58
|
+
`OPTIONS`) or `*` (any verb).
|
|
59
|
+
- `<pathGlob>` — a glob pattern (not a regex). Patterns are implicitly
|
|
60
|
+
anchored start-to-end. Rules:
|
|
61
|
+
- `*` matches any sequence of characters **except** `/` (one path segment).
|
|
62
|
+
- `**` matches any sequence of characters **including** `/` (multiple segments).
|
|
63
|
+
- All other characters are matched literally — regex special characters (`.`, `+`,
|
|
64
|
+
`?`, etc.) have no special meaning.
|
|
65
|
+
- **Allowed characters:** alphanumerics (`a-z`, `A-Z`, `0-9`), `/`, `_`, `-`,
|
|
66
|
+
`.`, `+`, and `*`. Globs containing any other character (e.g. `;`, space,
|
|
67
|
+
`?`) are rejected at startup with a warning and the rule is skipped.
|
|
68
|
+
|
|
69
|
+
A user may have multiple entries; access is granted if **any** rule matches
|
|
70
|
+
(logical OR).
|
|
71
|
+
|
|
72
|
+
### Examples
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# admin token: full access
|
|
76
|
+
full-access:*
|
|
77
|
+
|
|
78
|
+
# read-only token: GET only on exactly /api/hello
|
|
79
|
+
reader:GET:/api/hello
|
|
80
|
+
|
|
81
|
+
# update token: POST on exact path, GET on that path and all sub-paths
|
|
82
|
+
updt:POST:/api/v1/ldap/updt
|
|
83
|
+
updt:GET:/api/v1/ldap/updt**
|
|
84
|
+
|
|
85
|
+
# any method on a path and all sub-paths
|
|
86
|
+
any-method:*:/api/v1/ldap/users**
|
|
87
|
+
|
|
88
|
+
# match one level deep only (not /api/v1/ldap/users/uid=bob/sub)
|
|
89
|
+
one-level:GET:/api/v1/ldap/users/*
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Glob pattern quick reference
|
|
93
|
+
|
|
94
|
+
| Pattern | Matches | Does NOT match |
|
|
95
|
+
| ---------------- | ----------------------------------------------------- | ----------------------------------- |
|
|
96
|
+
| `/api/hello` | `/api/hello` | `/api/hello/sub` |
|
|
97
|
+
| `/api/hello**` | `/api/hello`, `/api/hello/sub`, `/api/hello/sub/deep` | `/api/hell` |
|
|
98
|
+
| `/api/hello/**` | `/api/hello/sub`, `/api/hello/sub/deep` | `/api/hello` |
|
|
99
|
+
| `/api/hello/*` | `/api/hello/sub` | `/api/hello`, `/api/hello/sub/deep` |
|
|
100
|
+
| `/api/hello.bak` | `/api/hello.bak` | `/api/helloXbak` (`.` is literal) |
|
|
101
|
+
|
|
102
|
+
## Behavior
|
|
103
|
+
|
|
104
|
+
| Situation | Result |
|
|
105
|
+
| ----------------------------------------------------- | ------------------------------------------------------ |
|
|
106
|
+
| `req.user` is unset (no auth plugin ran) | Pass through (`next()`) — let upstream auth return 401 |
|
|
107
|
+
| User authenticated but has no rules configured | 403 Forbidden |
|
|
108
|
+
| User has at least one matching rule | 200 / pass through |
|
|
109
|
+
| User has rules but none match the current method+path | 403 Forbidden |
|
|
110
|
+
|
|
111
|
+
## Full Example
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
DM_AUTH_TOKENS="tok-admin:admin,tok-ro:reader"
|
|
115
|
+
DM_AUTHZ_PER_ROUTES="admin:*,reader:GET:/api/v1/ldap/users**,reader:GET:/api/v1/ldap/groups**"
|
|
116
|
+
|
|
117
|
+
npx ldap-rest \
|
|
118
|
+
--plugin core/auth/token \
|
|
119
|
+
--plugin core/auth/authzPerRoute \
|
|
120
|
+
--plugin core/ldap/users \
|
|
121
|
+
--plugin core/ldap/groups \
|
|
122
|
+
--ldap-url ldap://localhost:389 \
|
|
123
|
+
...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# admin: any request allowed
|
|
128
|
+
curl -H "Authorization: Bearer tok-admin" http://api/v1/ldap/users
|
|
129
|
+
curl -H "Authorization: Bearer tok-admin" -X DELETE http://api/v1/ldap/users/uid=bob,ou=users,dc=example,dc=org
|
|
130
|
+
|
|
131
|
+
# reader: GET allowed
|
|
132
|
+
curl -H "Authorization: Bearer tok-ro" http://api/v1/ldap/users # 200
|
|
133
|
+
|
|
134
|
+
# reader: write denied
|
|
135
|
+
curl -H "Authorization: Bearer tok-ro" -X POST http://api/v1/ldap/users # 403
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Difference vs. Other Authz Plugins
|
|
139
|
+
|
|
140
|
+
| Plugin | Scope | Requires LDAP |
|
|
141
|
+
| ---------------- | ------------------------------ | ------------------ |
|
|
142
|
+
| `authzPerRoute` | HTTP method + path | No |
|
|
143
|
+
| `authzPerBranch` | LDAP branch read/write/delete | No (static config) |
|
|
144
|
+
| `authzDynamic` | LDAP branch ACL stored in LDAP | Yes |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ldap-rest",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Light directory manager",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Identity-Manager"
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"types": "./dist/src/plugins/auth/authzPerBranch.d.ts",
|
|
53
53
|
"import": "./dist/plugins/auth/authzPerBranch.js"
|
|
54
54
|
},
|
|
55
|
+
"./plugin-auth-authzperroute": {
|
|
56
|
+
"types": "./dist/src/plugins/auth/authzPerRoute.d.ts",
|
|
57
|
+
"import": "./dist/plugins/auth/authzPerRoute.js"
|
|
58
|
+
},
|
|
55
59
|
"./plugin-auth-crowdsec": {
|
|
56
60
|
"types": "./dist/src/plugins/auth/crowdsec.d.ts",
|
|
57
61
|
"import": "./dist/plugins/auth/crowdsec.js"
|
|
@@ -192,6 +196,10 @@
|
|
|
192
196
|
"types": "./dist/src/plugins/twake/calendarResources.d.ts",
|
|
193
197
|
"import": "./dist/plugins/twake/calendarResources.js"
|
|
194
198
|
},
|
|
199
|
+
"./plugin-twake-cozyprovision": {
|
|
200
|
+
"types": "./dist/src/plugins/twake/cozyProvision.d.ts",
|
|
201
|
+
"import": "./dist/plugins/twake/cozyProvision.js"
|
|
202
|
+
},
|
|
195
203
|
"./plugin-twake-drive": {
|
|
196
204
|
"types": "./dist/src/plugins/twake/drive.d.ts",
|
|
197
205
|
"import": "./dist/plugins/twake/drive.js"
|
|
@@ -631,6 +639,7 @@
|
|
|
631
639
|
"typescript": "^5.9.2"
|
|
632
640
|
},
|
|
633
641
|
"optionalDependencies": {
|
|
642
|
+
"@linagora/rabbitmq-client": "^0.1.2",
|
|
634
643
|
"express-openid-connect": "^2.19.2",
|
|
635
644
|
"lemonldap-ng-handler": "^0.7.3",
|
|
636
645
|
"re2": "^1.22.1"
|