ldap-rest 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/lib/authz/base.js +1 -1
  2. package/dist/lib/ldapFilter.js +1 -0
  3. package/dist/plugins/auth/auth.options.js +1 -1
  4. package/dist/plugins/auth/authzLinid1.js +1 -1
  5. package/dist/plugins/auth/authzScope.js +1 -1
  6. package/dist/plugins/auth/crowdsec.js +1 -1
  7. package/dist/plugins/auth/rateLimit.js +1 -1
  8. package/dist/plugins/auth/totp.js +1 -1
  9. package/dist/plugins/auth/trustedProxy.js +1 -1
  10. package/dist/plugins/ldap/bulkImport.js +1 -1
  11. package/dist/plugins/ldap/raw.js +1 -1
  12. package/dist/plugins/weblogs.js +1 -1
  13. package/dist/src/bin/index.d.ts +2 -2
  14. package/dist/src/browser/shared/utils/totp.d.ts +5 -1
  15. package/dist/src/lib/authz/base.d.ts +23 -9
  16. package/dist/src/lib/ldapFilter.d.ts +19 -0
  17. package/dist/src/plugins/auth/auth.options.d.ts +1 -0
  18. package/dist/src/plugins/auth/authzLinid1.d.ts +103 -9
  19. package/dist/src/plugins/auth/authzScope.d.ts +4 -0
  20. package/dist/src/plugins/auth/crowdsec.d.ts +7 -0
  21. package/dist/src/plugins/auth/rateLimit.d.ts +7 -0
  22. package/dist/src/plugins/auth/totp.d.ts +11 -0
  23. package/dist/src/plugins/auth/trustedProxy.d.ts +23 -0
  24. package/dist/src/plugins/ldap/bulkImport.d.ts +7 -0
  25. package/docs/client-development/schemas/README.md +8 -7
  26. package/docs/usage/configuration.md +8 -7
  27. package/docs/usage/plugins/auth/README.md +12 -12
  28. package/docs/usage/plugins/auth/authz-linid1.md +69 -0
  29. package/docs/usage/plugins/auth/authz-scope.md +14 -0
  30. package/docs/usage/plugins/auth/rate-limit.md +11 -6
  31. package/docs/usage/plugins/auth/totp.md +37 -17
  32. package/docs/usage/plugins/auth/trusted-proxy.md +9 -1
  33. package/docs/usage/upgrading.md +26 -1
  34. package/examples/web/totp-client.html +16 -9
  35. package/package.json +1 -1
  36. package/static/browser/ldap-unit-editor/LdapUnitEditor.js +2 -1
  37. package/static/browser/ldap-unit-editor/LdapUnitEditor.js.map +1 -1
  38. package/static/browser/ldap-unit-editor.esm.js +23 -1
  39. package/static/browser/ldap-unit-editor.esm.js.map +1 -1
  40. package/static/browser/ldap-unit-editor.js +23 -1
  41. package/static/browser/ldap-unit-editor.js.map +1 -1
  42. package/static/browser/ldap-unit-editor.min.js +1 -1
  43. package/static/browser/ldap-unit-editor.min.js.map +1 -1
  44. package/static/browser/shared/utils/totp.js +2 -1
  45. package/static/browser/shared/utils/totp.js.map +1 -1
  46. package/static/browser/shared.esm.js +2 -1
  47. package/static/browser/shared.esm.js.map +1 -1
  48. package/static/browser/shared.js +2 -1
  49. package/static/browser/shared.js.map +1 -1
  50. package/static/browser/shared.min.js +1 -1
  51. package/static/browser/shared.min.js.map +1 -1
  52. package/static/schemas/twake/groups.json +15 -0
@@ -1,11 +1,3 @@
1
- /**
2
- * @module plugins/auth/authzLinid1
3
- * @author Xavier Guimard <xguimard@linagora.com>
4
- *
5
- * Authorization plugin based on twakeLocalAdminLink LDAP attribute
6
- * Grants permissions to users referenced in organization's twakeLocalAdminLink
7
- * @group Plugins
8
- */
9
1
  import type { DM } from '../../bin/index.js';
10
2
  import type { Hooks } from '../../hooks.js';
11
3
  import type { BranchPermissions } from '../../config/args.js';
@@ -21,6 +13,7 @@ export default class AuthzLinid1 extends AuthzBase {
21
13
  authz_global_branch: import("../../config/options.js").ArrayOption;
22
14
  authz_global_write_branch: import("../../config/options.js").ArrayOption;
23
15
  authz_global_organization: import("../../config/options.js").StringOption;
16
+ authz_group_admin_attribute: import("../../config/options.js").StringOption;
24
17
  };
25
18
  name: string;
26
19
  permissionsCache: Map<string, CachedPermissions>;
@@ -30,12 +23,97 @@ export default class AuthzLinid1 extends AuthzBase {
30
23
  * into the cache afterwards.
31
24
  */
32
25
  private permissionsGeneration;
33
- hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapmodifydone' | 'ldapdeletedone' | 'ldaprenamedone'>;
26
+ /** The delegation attribute, when the feature is on. */
27
+ private readonly groupAdminAttr;
28
+ private readonly adminAttr;
29
+ private reducedFilterNames?;
30
+ /**
31
+ * Marks a search whose projection we widened for the delegation attribute.
32
+ * As in the base class, a symbol survives the copies another plugin makes.
33
+ */
34
+ private static readonly widenedForDelegation;
35
+ hooks: AuthzBase['hooks'] & Pick<Hooks, 'ldapmodifydone' | 'ldapdeletedone' | 'ldaprenamedone' | 'ldapsearchrequest' | 'ldapsearchfilter' | 'ldapmodifyrequest'>;
34
36
  /** What the classes above declared, read once this one is constructed */
35
37
  private readonly declaredByBase;
36
38
  constructor(server: DM);
37
39
  /** Drop every cached permission lookup. */
38
40
  forgetPermissions(): void;
41
+ /**
42
+ * Add the delegation attribute to a search projection the caller did not
43
+ * ask it in: the filter below cannot tell a delegated group from any other
44
+ * entry without it. It is removed again once the result is judged.
45
+ */
46
+ private widenForDelegation;
47
+ /**
48
+ * Keep a delegated group the attachment filter would hide, and show only
49
+ * the members of it this caller manages. A group the caller administers
50
+ * through its organization keeps the ordinary answer, members included:
51
+ * the delegation is an addition to that path, not a restriction on it.
52
+ */
53
+ private filterDelegatedGroups;
54
+ /** Remove the delegation attribute a search only carried for this judgement. */
55
+ private stripWidened;
56
+ /**
57
+ * Judge a change to a group whose membership is delegated: a delegated
58
+ * administrator may add or remove members they manage, and change nothing
59
+ * else on the group.
60
+ *
61
+ * An ordinary administrator of the group goes through the base checks
62
+ * untouched, as does a caller the delegation does not reach.
63
+ */
64
+ private judgeDelegatedGroup;
65
+ /** The fields a group schema gives its members and owners. */
66
+ private groupAttributes;
67
+ /**
68
+ * The filter names that test the member and owner values we reduce: their
69
+ * own names and OIDs, and every supertype they answer under. A filter on
70
+ * `distinguishedName`, which both inherit, tests both.
71
+ */
72
+ private memberFilterNames;
73
+ private resolveMemberFilterNames;
74
+ /** Whether `dn` is in a branch only its organization's administrators read. */
75
+ private isGlobalBranch;
76
+ /**
77
+ * Keep only the members and owners this caller manages, in place.
78
+ *
79
+ * @param entry group entry about to be answered
80
+ * @param delegations the organizations the group delegates to
81
+ * @param manages tells whether a member's organization is one of them
82
+ */
83
+ private keepManagedMembers;
84
+ /** The organizations a group delegates membership management to. */
85
+ private delegationsOf;
86
+ /** Whether a member's organization lies in one of the delegated branches. */
87
+ private withinDelegation;
88
+ /** The organization a member is attached to, and nothing else. */
89
+ private storedOrganization;
90
+ /** The delegation of a group, and its stored entry. */
91
+ private storedGroup;
92
+ /** A DN as a key two spellings of it share. */
93
+ private dnKey;
94
+ /** `isDnInBranch`, with a malformed DN judged as outside. */
95
+ private within;
96
+ /**
97
+ * Refuse a delegated change that is not an add or delete of a member the
98
+ * caller manages.
99
+ */
100
+ private assertDelegatedChanges;
101
+ /** Whether the caller manages a member: it is attached to one of theirs. */
102
+ private managesMember;
103
+ /** The group plugin's placeholder, which takes the last member's place. */
104
+ private isDummyMember;
105
+ /**
106
+ * The groups a caller manages the members of through a delegation, for
107
+ * `GET /authz/scope`. Read on each call: a group's delegation is a handful
108
+ * of entries, and the scope is not on a request's hot path.
109
+ *
110
+ * @param user the caller's DN
111
+ * @returns the delegated groups, empty when the option is off
112
+ */
113
+ delegatedGroups(user: string): Promise<{
114
+ dn: string;
115
+ manageMembers: boolean;
116
+ }[]>;
39
117
  /**
40
118
  * The organizations naming `dn` an administrator. Taking one of those
41
119
  * accounts over grants its branches, so the base refuses a caller who
@@ -77,8 +155,24 @@ export default class AuthzLinid1 extends AuthzBase {
77
155
  protected sourcePermission(branch: string): keyof BranchPermissions;
78
156
  /**
79
157
  * Resolve user - for authzLinid1, convert uid to userDn via LDAP
158
+ *
159
+ * An account the schema marks with a state other than `enabled` is
160
+ * refused: the identity provider may still let it sign in, and the
161
+ * lifecycle state is the directory's way to stop it.
80
162
  */
81
163
  resolveUser(uid: string): Promise<string | null>;
164
+ /**
165
+ * The attribute carrying an account's lifecycle state, and the value that
166
+ * means enabled, from the flat entity schema holding `dn`.
167
+ */
168
+ private accountStatus;
169
+ /**
170
+ * Whether the account carries the enabled state, or carries no status at
171
+ * all: an entry the schema does not manage keeps the access it had.
172
+ */
173
+ private enabled;
174
+ /** The account status attributes the loaded user schemas declare. */
175
+ private statusAttributes;
82
176
  /**
83
177
  * Get user DN from uid
84
178
  *
@@ -15,6 +15,10 @@ export declare const ScopeResponse: import("@sinclair/typebox").TObject<{
15
15
  path: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
16
16
  }>>;
17
17
  transit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>>;
18
+ delegatedGroups: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
19
+ dn: import("@sinclair/typebox").TString;
20
+ manageMembers: import("@sinclair/typebox").TBoolean;
21
+ }>>>;
18
22
  linksAnyDomain: import("@sinclair/typebox").TBoolean;
19
23
  passwordPolicies: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
20
24
  entities: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
@@ -27,8 +27,15 @@ export default class CrowdSec extends DmPlugin {
27
27
  private apiKey;
28
28
  private cacheTtl;
29
29
  private cache;
30
+ private lastSweep;
31
+ /** Distinct addresses remembered at once, so a rotating header cannot grow the map. */
32
+ private static readonly MAX_CLIENTS;
30
33
  constructor(...args: ConstructorParameters<typeof DmPlugin>);
31
34
  api(app: Express): void;
32
35
  private checkIpBanned;
36
+ /** Drop expired decisions, at most once a TTL, and keep the map bounded. */
37
+ private prune;
38
+ /** Drop the oldest decision: `Map` keeps insertion order. */
39
+ private evictOldest;
33
40
  private getClientIp;
34
41
  }
@@ -24,7 +24,14 @@ export default class RateLimit extends DmPlugin {
24
24
  private store;
25
25
  private windowMs;
26
26
  private maxRequests;
27
+ private lastSweep;
28
+ /** Distinct addresses remembered at once, so a rotating header cannot grow the map. */
29
+ private static readonly MAX_CLIENTS;
27
30
  constructor(...args: ConstructorParameters<typeof DmPlugin>);
28
31
  api(app: Express): void;
32
+ /** Drop expired counters, at most once a window, and keep the map bounded. */
33
+ private prune;
34
+ /** Drop the oldest counter: `Map` keeps insertion order. */
35
+ private evictOldest;
29
36
  private getClientIp;
30
37
  }
@@ -14,12 +14,23 @@ export default class AuthTotp extends AuthBase {
14
14
  private totpUsers;
15
15
  private window;
16
16
  private step;
17
+ /** Last counter accepted per user, so an observed code is not reusable. */
18
+ private lastCounters;
17
19
  constructor(...args: ConstructorParameters<typeof AuthBase>);
18
20
  authMethod(req: DmRequest, res: Response, next: () => void): void;
21
+ /**
22
+ * Whether a counter is new for this user: a code is accepted once, so an
23
+ * observed one is not a bearer credential for the rest of its window.
24
+ */
25
+ private acceptCounter;
19
26
  /**
20
27
  * Verify a TOTP token against a secret
28
+ *
29
+ * @returns the counter it matched, or null
21
30
  */
22
31
  private verifyTotp;
32
+ /** Compare two codes without letting a difference shorten the comparison. */
33
+ private sameCode;
23
34
  /**
24
35
  * Generate a TOTP token using HMAC-SHA1
25
36
  */
@@ -5,6 +5,8 @@ declare global {
5
5
  interface Request {
6
6
  trustedProxy?: boolean;
7
7
  proxyAuthUser?: string;
8
+ /** The client address, read through the trusted proxy chain. */
9
+ clientIp?: string;
8
10
  }
9
11
  }
10
12
  }
@@ -19,6 +21,27 @@ export default class TrustedProxy extends DmPlugin {
19
21
  private authHeader;
20
22
  constructor(...args: ConstructorParameters<typeof DmPlugin>);
21
23
  api(app: Express): void;
24
+ /**
25
+ * The client address in a proxy chain: walk `X-Forwarded-For` from the
26
+ * right, skip the addresses this server trusts as proxies, and take the
27
+ * first one it does not. A port is stripped from the entry (`ip:port`,
28
+ * `[v6]:port`) since some proxies write one.
29
+ *
30
+ * An entry that is not an address stops the walk and the peer is used: the
31
+ * entries to its left are the ones a client wrote, so continuing would let
32
+ * it choose the address. A chain made only of trusted addresses falls back
33
+ * to the peer too.
34
+ *
35
+ * @param xff the header as received from a trusted peer
36
+ * @param remoteAddr the TCP peer
37
+ * @returns the address to key rate limits and logs on
38
+ */
39
+ private clientIpFromXff;
40
+ /**
41
+ * An `X-Forwarded-For` entry as an address: a plain one, a bracketed IPv6
42
+ * address, or either carrying a port. Anything else is undefined.
43
+ */
44
+ private forwardedAddress;
22
45
  /**
23
46
  * Check if an IP address belongs to a trusted proxy network
24
47
  */
@@ -17,6 +17,13 @@ export default class LdapBulkImport extends DmPlugin {
17
17
  private readonly settings;
18
18
  private resources;
19
19
  private upload;
20
+ /**
21
+ * The branches the import writes into. An authorization plugin judges an
22
+ * added entry on its link rather than on a flat store it lands in.
23
+ *
24
+ * @returns the distinct resource bases
25
+ */
26
+ bases(): string[];
20
27
  constructor(parent: DM);
21
28
  api(app: Express): void;
22
29
  /**
@@ -602,13 +602,14 @@ Semantic roles assign special meaning to attributes for UI and business logic ha
602
602
 
603
603
  ### Available Roles
604
604
 
605
- | Role | Description | Example Attribute | Usage |
606
- | -------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------- |
607
- | `identifier` | Primary identifier (RDN attribute) | `uid`, `cn`, `sAMAccountName` | Used as the entry's unique identifier |
608
- | `displayName` | Human-readable display name | `cn`, `displayName` | Shown in lists and UI elements |
609
- | `primaryEmail` | Main email address | `mail`, `userPrincipalName` | Primary contact email |
610
- | `emailAliases` | Additional email addresses | `mailAlternateAddress`, `proxyAddresses` | Alternative email addresses |
611
- | `emailQuota` | Email storage quota | `mailQuota`, `mailQuotaSize` | Storage limit for mailbox |
605
+ | Role | Description | Example Attribute | Usage |
606
+ | ------------------------- | ----------------------------------------------------------------- | ---------------------------------------- | ------------------------------------- |
607
+ | `identifier` | Primary identifier (RDN attribute) | `uid`, `cn`, `sAMAccountName` | Used as the entry's unique identifier |
608
+ | `displayName` | Human-readable display name | `cn`, `displayName` | Shown in lists and UI elements |
609
+ | `primaryEmail` | Main email address | `mail`, `userPrincipalName` | Primary contact email |
610
+ | `emailAliases` | Additional email addresses | `mailAlternateAddress`, `proxyAddresses` | Alternative email addresses |
611
+ | `emailQuota` | Email storage quota | `mailQuota`, `mailQuotaSize` | Storage limit for mailbox |
612
+ | `groupAdminOrganizations` | Organizations whose administrators manage the group's own members | `twakeGroupAdminOrgLink` | Delegated group membership |
612
613
 
613
614
  ### Role Usage Examples
614
615
 
@@ -456,13 +456,14 @@ whichever of the two names it selects. A value other than `req.user` or
456
456
 
457
457
  #### `core/auth/authzLinid1`
458
458
 
459
- | CLI | Env | Default | Description |
460
- | --------------------------------------------------------------- | -------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
461
- | `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute |
462
- | `--authz-linid1-cache-ttl` | `DM_AUTHZ_LINID1_CACHE_TTL` | `300` | Seconds a user's resolved permissions are cached; a write touching the admin attribute clears the cache at once |
463
- | `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches) |
464
- | `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches) |
465
- | `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches |
459
+ | CLI | Env | Default | Description |
460
+ | --------------------------------------------------------------- | -------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
461
+ | `--authz-local-admin-attribute` | `DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE` | `twakeLocalAdminLink` | Local admin attribute |
462
+ | `--authz-linid1-cache-ttl` | `DM_AUTHZ_LINID1_CACHE_TTL` | `300` | Seconds a user's resolved permissions are cached; a write touching the admin attribute clears the cache at once |
463
+ | `--authz-global-branch` / `--authz-global-branches` | `DM_AUTHZ_GLOBAL_BRANCHES` | | Branches outside the tree only the administrators of `--authz-global-organization` read and write, [details](plugins/auth/authz-linid1.md#global-branches) |
464
+ | `--authz-global-write-branch` / `--authz-global-write-branches` | `DM_AUTHZ_GLOBAL_WRITE_BRANCHES` | | Branches outside the tree every administrator reads and only the administrators of `--authz-global-organization` write and delete in, [details](plugins/auth/authz-linid1.md#global-branches) |
465
+ | `--authz-global-organization` | `DM_AUTHZ_GLOBAL_ORGANIZATION` | `--ldap-top-organization` | The organization holding the global branches and global write branches |
466
+ | `--authz-group-admin-attribute` | `DM_AUTHZ_GROUP_ADMIN_ATTRIBUTE` | | Attribute on a group naming the organizations whose administrators manage their own members; empty means the delegation is off, [details](plugins/auth/authz-linid1.md#delegated-group-membership) |
466
467
 
467
468
  ### Security Plugins
468
469
 
@@ -181,18 +181,18 @@ base class, but nothing obliges a third implementation to.
181
181
 
182
182
  ## Choosing an Authentication Method
183
183
 
184
- | Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect |
185
- | ---------------------- | ------------- | ----------------- | ---------------------- | --------------------- | -------------------------------- |
186
- | **Setup Complexity** | Simple | Simple | Medium | Medium | Medium |
187
- | **User Management** | None | Manual | Manual (service-based) | External (LLNG) | External (Provider) |
188
- | **SSO Support** | No | No | No | Yes | Yes |
189
- | **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) |
190
- | **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions |
191
- | **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based |
192
- | **Request Integrity** | No | No | Yes (body + path hash) | No | No |
193
- | **Replay Protection** | No | Yes (time-based) | Yes (timestamp) | Session-based | Session-based |
194
- | **Best For** | APIs, Scripts | APIs, Enhanced | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO |
195
- | **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect |
184
+ | Feature | Token | TOTP | HMAC | LemonLDAP::NG | OpenID Connect |
185
+ | ---------------------- | ------------- | ------------------------- | ---------------------- | --------------------- | -------------------------------- |
186
+ | **Setup Complexity** | Simple | Simple | Medium | Medium | Medium |
187
+ | **User Management** | None | Manual | Manual (service-based) | External (LLNG) | External (Provider) |
188
+ | **SSO Support** | No | No | No | Yes | Yes |
189
+ | **MFA Support** | No | Yes (TOTP itself) | N/A | Yes (via LLNG) | Yes (via Provider) |
190
+ | **Session Management** | Stateless | Stateless | Stateless | LLNG Sessions | OIDC Sessions |
191
+ | **Code Expiration** | Never | 30-60 seconds | Per-request | Session-based | Session-based |
192
+ | **Request Integrity** | No | No | Yes (body + path hash) | No | No |
193
+ | **Replay Protection** | No | Yes (single-use code) | Yes (timestamp) | Session-based | Session-based |
194
+ | **Best For** | APIs, Scripts | MFA, one request per step | Backend Services | Enterprises with LLNG | Cloud/SaaS, Enterprises with SSO |
195
+ | **Dependencies** | None | None | None | lemonldap-ng-handler | express-openid-connect |
196
196
 
197
197
  ## Serving several populations from one server
198
198
 
@@ -11,6 +11,7 @@ The `authzLinid1` plugin provides dynamic, LDAP-driven access control by reading
11
11
  - **Full CRUD rights** - Local admins get read, write, and delete permissions
12
12
  - **Sub-branch inheritance** - Permissions apply recursively to sub-branches
13
13
  - **Cross-branch access** - Users/groups can be managed if their `twakeDepartmentLink` points to an authorized organization
14
+ - **Delegated group membership** - A group can let other organizations' administrators manage their own members ([details](#delegated-group-membership))
14
15
  - **Permission caching** - Configurable TTL reduces LDAP queries (default: 5 minutes)
15
16
 
16
17
  ## How It Works
@@ -238,6 +239,56 @@ other: the global branch would hide the write branch it holds from the
238
239
  administrators meant to read it. In either case the server refuses to
239
240
  start.
240
241
 
242
+ ### Delegated group membership
243
+
244
+ A group may delegate membership management to the administrators of other
245
+ organizations, so that a group spanning several organizations can be
246
+ maintained by each of them without opening the others' members. Off by
247
+ default: name the attribute to turn it on.
248
+
249
+ ```bash
250
+ --authz-group-admin-attribute twakeGroupAdminOrgLink
251
+ ```
252
+
253
+ The attribute is multi-valued and holds organization DNs. The Twake group
254
+ schema declares it as `twakeGroupAdminOrgLink` (`groupAdminOrganizations`
255
+ role, `referentialIntegrity: remove`), and `twakeGroup` and
256
+ `twakeStaticGroup` must allow it (`MAY`).
257
+
258
+ Let `D` be the organizations a group delegates to, `L` the organization a
259
+ member is attached to (its link attribute), and `C` the caller's written
260
+ branches. Then:
261
+
262
+ - `C` reads the group when a branch of `C` lies in one of the `D`.
263
+ - `C` reads a member or owner of the group only when it is attached to an
264
+ `L` inside some `D` and `C` writes `L`. A member attached to no
265
+ organization (in transit) is neither read nor managed through the
266
+ delegation.
267
+ - `C` adds or removes a `member` under the same rule. Nothing else on the
268
+ group changes: not its description, delegation, name, position or
269
+ existence. The delegation grants no creation, rename, move or delete of a
270
+ group.
271
+ - The group's own administrators keep the whole group and every right they
272
+ had. A group attached to no organization is already readable by every
273
+ administrator; the delegation only adds the member write there. A member
274
+ that is a group follows the same link rule.
275
+
276
+ Removing the last real member keeps the group plugin's rule: the placeholder
277
+ takes its place, or the request is refused with `409`.
278
+
279
+ A search whose filter tests `member`, `owner`, or a supertype they inherit
280
+ (`distinguishedName`) does not return a delegated group: the directory judged
281
+ the full group, and answering from the filtered membership would tell the
282
+ caller whether a user of another organization belongs to it. The group stays
283
+ in the ordinary listing and can be read directly.
284
+
285
+ `GET /api/v1/authz/scope` lists the delegated groups under
286
+ `delegatedGroups`, so a client does not duplicate the rule
287
+ ([scope](authz-scope.md#delegated-groups)).
288
+
289
+ The delegation is read from the group entry, so a change takes effect at
290
+ once; the plugin does not keep a second copy of it.
291
+
241
292
  ## Configuration
242
293
 
243
294
  No configuration file needed - permissions are read from LDAP. Simply load the plugin:
@@ -293,6 +344,17 @@ directly in the directory waits out the TTL.
293
344
  `GET /api/v1/authz/scope` resolves the identity outside that cache, so
294
345
  during such a window it can describe a scope the hooks no longer grant.
295
346
 
347
+ ### Account status
348
+
349
+ When the user schema declares an `accountStatus` attribute, the resolved
350
+ caller must carry its `states.enabled` value; any other value is refused with
351
+ `403`, like an identity that does not resolve. A disabled, no-access or
352
+ to-delete administrator therefore loses the API even when the identity
353
+ provider still lets them sign in, and cannot re-enable themselves through the
354
+ console. A write through this server that changes the status drops the
355
+ resolved identity at once; a change made directly in the directory waits for
356
+ `--authz-linid1-cache-ttl`.
357
+
296
358
  ## Permission Resolution
297
359
 
298
360
  For each LDAP operation, the plugin:
@@ -520,6 +582,13 @@ User makes request → Check cache → (if expired) Search LDAP once → Cache f
520
582
 
521
583
  - `twakeDepartmentLink` (single-valued) - DN of parent organization
522
584
 
585
+ **Groups, when `--authz-group-admin-attribute` is set:**
586
+
587
+ - `twakeGroupAdminOrgLink` (multi-valued) - DNs of the organizations whose
588
+ administrators manage their own members
589
+ ([delegated group membership](#delegated-group-membership)). The directory's
590
+ `twakeGroup` and `twakeStaticGroup` object classes must allow it.
591
+
523
592
  ### Example Schema
524
593
 
525
594
  ```ldif
@@ -93,6 +93,12 @@ GET /api/v1/authz/scope
93
93
  "delete": true
94
94
  }
95
95
  ],
96
+ "delegatedGroups": [
97
+ {
98
+ "dn": "cn=all-staff,ou=groups,dc=example,dc=com",
99
+ "manageMembers": true
100
+ }
101
+ ],
96
102
  "entities": [
97
103
  { "name": "users", "base": "ou=users,dc=example,dc=com", "create": true },
98
104
  { "name": "groups", "base": "ou=groups,dc=example,dc=com", "create": true },
@@ -105,6 +111,14 @@ GET /api/v1/authz/scope
105
111
  }
106
112
  ```
107
113
 
114
+ ### Delegated groups
115
+
116
+ `delegatedGroups` lists the groups the caller reads, and whose members of
117
+ their own organizations they may add or remove, through
118
+ [`--authz-group-admin-attribute`](authz-linid1.md#delegated-group-membership).
119
+ It is absent when that option is off or the caller has none. A client that
120
+ does not know the field keeps working.
121
+
108
122
  `name` and `path` come from the branch entry itself, so an interface can show
109
123
  the scope in the directory's own words rather than as a raw DN.
110
124
 
@@ -52,16 +52,21 @@ For correct IP detection behind reverse proxies:
52
52
  --rate-limit-max 100
53
53
  ```
54
54
 
55
- The rate limiter will use the real client IP from `X-Forwarded-For` when the request comes from a trusted proxy.
56
-
57
- This pairing is not a refinement, it is what makes the limit hold. The limiter keys on `X-Forwarded-For`, a header any client can set to anything; `trustedProxy` is what strips it on requests that do not come from a declared proxy. Loaded alone, the limiter counts forged keys: rotating the header on every attempt gives an attacker a fresh counter each time, and the brute-force protection becomes decorative while still looking configured. The plugin therefore warns at startup when `trustedProxy` is absent:
55
+ The limiter reads the client address `trustedProxy` publishes on the request
56
+ (`req.clientIp`). That plugin walks `X-Forwarded-For` from the right, skips
57
+ the addresses it trusts as proxies, and takes the first one it does not, so a
58
+ value a client writes at the left of the chain never becomes its key. Without
59
+ `trustedProxy`, the limiter uses the TCP peer and a reverse proxy makes every
60
+ client share one counter; the plugin says so at startup:
58
61
 
59
62
  ```
60
- Auth rate limiting keys on X-Forwarded-For but core/auth/trustedProxy is not
61
- loaded: a client can forge that header and evade the limit.
63
+ Auth rate limiting ignores X-Forwarded-For because core/auth/trustedProxy is
64
+ not loaded and uses the TCP peer: requests through a proxy share one counter.
62
65
  ```
63
66
 
64
- Either load `trustedProxy`, or make sure the reverse proxy in front **overwrites** `X-Forwarded-For` rather than appending to it.
67
+ Load `trustedProxy` when a reverse proxy sits in front. It is no longer
68
+ necessary for the proxy to overwrite the header: appending
69
+ (`$proxy_add_x_forwarded_for`) is read correctly.
65
70
 
66
71
  ## Security Considerations
67
72
 
@@ -58,7 +58,7 @@ Include the current TOTP code in the `Authorization` header:
58
58
 
59
59
  ```bash
60
60
  # Get current TOTP code from your authenticator app
61
- curl -H "Authorization: Bearer 123456" \
61
+ curl -H "Authorization: Bearer admin:123456" \
62
62
  http://localhost:8081/api/v1/ldap/users
63
63
  ```
64
64
 
@@ -80,6 +80,7 @@ import {
80
80
 
81
81
  const client = new TotpAuthClient({
82
82
  secret: 'JBSWY3DPEHPK3PXP',
83
+ user: 'admin',
83
84
  digits: 6,
84
85
  step: 30,
85
86
  });
@@ -97,12 +98,18 @@ console.log(`Current TOTP code: ${code}`);
97
98
 
98
99
  ## How It Works
99
100
 
100
- 1. Extracts TOTP code from `Authorization: Bearer <code>` header
101
- 2. Generates expected TOTP codes for current time window
102
- 3. Validates code against all configured users' secrets
103
- 4. Supports multiple time windows (±window × step seconds) for clock drift tolerance
104
- 5. Sets `req.user` to the user's name on successful validation
105
- 6. Returns 401 Unauthorized if code is missing, invalid, or expired
101
+ 1. Extracts the user name and TOTP code from
102
+ `Authorization: Bearer <name>:<code>`
103
+ 2. Looks up that user and generates the expected codes for the current time
104
+ window
105
+ 3. Validates the code against that user's secret, in constant time
106
+ 4. Accepts a counter once per user: a replay, or the same code twice inside
107
+ one time step, is refused
108
+ 5. Supports multiple time windows (±window × step seconds) for clock drift
109
+ tolerance
110
+ 6. Sets `req.user` to the user's name on successful validation
111
+ 7. Returns 401 Unauthorized if the name is missing or unknown, or the code is
112
+ missing, invalid, replayed or expired
106
113
 
107
114
  ## Multi-User Example
108
115
 
@@ -126,7 +133,7 @@ INFO: TOTP authentication successful for user: api-service
126
133
  - **Enhanced Security**: Dynamic codes that expire every 30 seconds
127
134
  - **No Shared Secrets**: Each user/service has unique TOTP secret
128
135
  - **Compatible**: Works with Google Authenticator, Authy, 1Password, etc.
129
- - **API Access**: Suitable for automated scripts with TOTP generation
136
+ - **API Access**: One code per request; a client making several calls must wait for the next step or use a session token
130
137
  - **Multi-Factor**: Can be combined with other authentication methods
131
138
 
132
139
  ## Security Considerations
@@ -136,7 +143,11 @@ INFO: TOTP authentication successful for user: api-service
136
143
  - **Clock Synchronization**: Ensure server and client clocks are synchronized (NTP recommended)
137
144
  - **Window Size**: Larger windows are more tolerant but slightly less secure
138
145
  - **Secret Rotation**: Rotate TOTP secrets periodically for better security
139
- - **Rate Limiting**: Consider adding rate limiting to prevent brute force attacks
146
+ - **Rate Limiting**: Load `core/auth/rateLimit`; a six-digit code is still guessable without it
147
+ - **Single use**: A code is accepted once per user, so a second request with
148
+ the same code in the same step is refused. The record is per process: with
149
+ several replicas, a code can be used once against each until they share
150
+ state
140
151
 
141
152
  ## Browser Client Features
142
153
 
@@ -147,14 +158,14 @@ import {
147
158
  generateTotp,
148
159
  getRemainingSeconds,
149
160
  isValidBase32,
150
- TotpAuthClient
161
+ TotpAuthClient,
151
162
  } from 'ldap-rest/browser-shared-utils-totp';
152
163
 
153
164
  // Generate TOTP code
154
165
  const code = await generateTotp({
155
166
  secret: 'JBSWY3DPEHPK3PXP',
156
167
  digits: 6,
157
- step: 30
168
+ step: 30,
158
169
  });
159
170
 
160
171
  // Check validity time
@@ -169,16 +180,25 @@ if (isValidBase32('JBSWY3DPEHPK3PXP')) {
169
180
  // HTTP client with automatic TOTP authentication
170
181
  const client = new TotpAuthClient({
171
182
  secret: 'JBSWY3DPEHPK3PXP',
183
+ user: 'admin',
172
184
  digits: 6,
173
- step: 30
185
+ step: 30,
174
186
  });
175
187
 
176
- // All HTTP methods automatically include TOTP in Authorization header
177
- await client.post('/api/v1/ldap/users', { uid: 'user1', ... });
178
- await client.put('/api/v1/ldap/users/user1', { mail: 'new@example.com' });
179
- await client.delete('/api/v1/ldap/users/user1');
188
+ // Every request carries the current code; a code is accepted once, so this
189
+ // works for one call per time step. Wait for the next code before the next.
190
+ const created = await client.post('/api/v1/ldap/users', {
191
+ uid: 'user1',
192
+ mail: 'user1@example.com',
193
+ });
180
194
  ```
181
195
 
196
+ **One request per code step.** TOTP has no session: each request is
197
+ authenticated on its own. A code is accepted once per user, so a second
198
+ request in the same 30-second step is refused. A client making several calls
199
+ must wait for the next code, or use another authentication plugin for the
200
+ session after the first TOTP call.
201
+
182
202
  **Module Export:** `ldap-rest/browser-shared-utils-totp`
183
203
 
184
204
  **Live Demo:** Run the server and open [http://localhost:8081/static/examples/web/totp-client.html](http://localhost:8081/static/examples/web/totp-client.html)
@@ -193,7 +213,7 @@ await client.delete('/api/v1/ldap/users/user1');
193
213
  2. Check system clock synchronization (use NTP)
194
214
  3. Increase `--auth-totp-window` if experiencing timing issues
195
215
  4. Ensure code is used immediately (codes expire every 30s)
196
- 5. Verify `Authorization: Bearer {code}` format
216
+ 5. Verify the `Authorization: Bearer <name>:<code>` format and that the name is one configured in `--auth-totp`
197
217
 
198
218
  **Problem:** Codes don't match authenticator app
199
219