ldap-rest 0.13.4 → 0.15.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 (49) hide show
  1. package/dist/bin/index.js +1 -1
  2. package/dist/config/args.js +1 -1
  3. package/dist/lib/errors.js +1 -1
  4. package/dist/lib/expressFormatedResponses.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/lib/operation.js +1 -0
  7. package/dist/lib/parseConfig.js +1 -1
  8. package/dist/lib/rawBody.js +1 -1
  9. package/dist/lib/utils.js +1 -1
  10. package/dist/plugins/auth/hmac.js +1 -1
  11. package/dist/plugins/auth/totp.js +1 -1
  12. package/dist/plugins/ldap/onChange.js +1 -1
  13. package/dist/plugins/scim/errors.js +1 -1
  14. package/dist/plugins/scim/scim.js +1 -1
  15. package/dist/plugins/twake/groups.js +1 -1
  16. package/dist/plugins/twake/instances.js +1 -1
  17. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  18. package/dist/plugins/twake/spaces.js +1 -0
  19. package/dist/plugins/twake/tombstone.js +1 -1
  20. package/dist/src/bin/index.d.ts +16 -0
  21. package/dist/src/config/args.d.ts +8 -1
  22. package/dist/src/hooks.d.ts +17 -0
  23. package/dist/src/lib/errors.d.ts +5 -0
  24. package/dist/src/lib/ldapActions.d.ts +12 -1
  25. package/dist/src/lib/operation.d.ts +29 -0
  26. package/dist/src/lib/parseConfig.d.ts +4 -1
  27. package/dist/src/lib/rawBody.d.ts +27 -8
  28. package/dist/src/lib/utils.d.ts +6 -0
  29. package/dist/src/plugins/auth/hmac.d.ts +1 -1
  30. package/dist/src/plugins/ldap/onChange.d.ts +5 -2
  31. package/dist/src/plugins/scim/scim.d.ts +11 -1
  32. package/dist/src/plugins/twake/groups.d.ts +72 -12
  33. package/dist/src/plugins/twake/instances.d.ts +0 -8
  34. package/dist/src/plugins/twake/spaces.d.ts +186 -0
  35. package/dist/src/plugins/twake/tombstone.d.ts +5 -0
  36. package/docs/plugin-development/README.md +21 -0
  37. package/docs/plugin-development/hooks.md +25 -0
  38. package/docs/usage/configuration.md +83 -4
  39. package/docs/usage/plugins/auth/hmac.md +6 -1
  40. package/docs/usage/plugins/integrations/README.md +6 -0
  41. package/docs/usage/plugins/integrations/groups.md +12 -5
  42. package/docs/usage/plugins/integrations/instances.md +0 -10
  43. package/docs/usage/plugins/integrations/lifecycle-events.md +3 -1
  44. package/docs/usage/plugins/integrations/spaces.md +221 -0
  45. package/docs/usage/plugins/ldap/raw.md +1 -1
  46. package/docs/usage/upgrading.md +131 -0
  47. package/package.json +6 -1
  48. package/static/browser/shared/utils/hmac.js +5 -0
  49. package/static/browser/shared/utils/hmac.js.map +1 -1
@@ -1,12 +1,55 @@
1
- import type { Express } from 'express';
1
+ import type { Express, Request, Response } from 'express';
2
2
  import type { DM } from '../../bin';
3
3
  import type { Schema } from '../../config/schema';
4
4
  import type { Hooks } from '../../hooks';
5
- import type { AttributeValue } from '../../lib/ldapActions';
5
+ import type { AttributesList, AttributeValue } from '../../lib/ldapActions';
6
6
  import LdapGroups from '../ldap/groups';
7
7
  import { type LifecycleAttributes } from './lifecycleAttributes';
8
+ /**
9
+ * An attribute's first value, whatever case its name is configured in: the
10
+ * directory answers it under its schema's spelling.
11
+ */
12
+ export declare const read: (entry: AttributesList, attribute: string) => string | undefined;
13
+ export declare const ORG = "{org}";
14
+ /** A refusal the routes answer as `{ error, code }`. */
15
+ export declare class RouteError extends Error {
16
+ readonly status: number;
17
+ readonly code: string;
18
+ constructor(status: number, message: string, code: string);
19
+ }
20
+ export declare const notFound: (what: string) => RouteError;
21
+ export declare const invalid: (message: string, code?: string) => RouteError;
22
+ export declare const NAME_RULE = "name must be a non-blank string of at most 256 characters with no control characters";
23
+ export declare function isDisplayName(name: unknown): name is string;
24
+ /** A request without a JSON object body leaves `req.body` undefined. */
25
+ export declare function bodyOf(req: Request): Record<string, unknown>;
26
+ export interface Page {
27
+ page: number;
28
+ limit: number;
29
+ offset: number;
30
+ search?: string;
31
+ sortBy?: string;
32
+ desc: boolean;
33
+ }
8
34
  /** The values of an attribute, whatever shape the directory gave them. */
9
35
  export declare function values(value: AttributeValue | undefined): string[];
36
+ /** A DN spelled one way, to compare two spellings of it. */
37
+ export declare function dnKey(dn: string): string;
38
+ export declare function parentOf(dn: string): string;
39
+ /** The RDN value of a DN, unescaped, or the DN itself if it has none. */
40
+ export declare function rdnValue(dn: string): string;
41
+ /**
42
+ * The entries one level under a DN pattern holding `{org}`, the organization
43
+ * captured as one RDN value: `(?:\\.|[^,])+` keeps an escaped comma inside it.
44
+ */
45
+ export declare function branchPattern(base: string): RegExp;
46
+ /**
47
+ * The organization a rename moves an entry into, under a branch pattern, if
48
+ * it changes the entry's parent: what it holds comes along unchecked.
49
+ */
50
+ export declare function movedInto(pattern: RegExp, dn: string, newDn: string): string | undefined;
51
+ /** The organization of an entry one level under a branch pattern. */
52
+ export declare function organizationIn(pattern: RegExp, dn: string): string | undefined;
10
53
  export default class TwakeGroups extends LdapGroups {
11
54
  name: string;
12
55
  protected readonly groupBase: string;
@@ -17,7 +60,7 @@ export default class TwakeGroups extends LdapGroups {
17
60
  private readonly displayName;
18
61
  private readonly color;
19
62
  private readonly createdAt;
20
- private readonly maxPage;
63
+ readonly maxPage: number;
21
64
  private readonly memberFields;
22
65
  constructor(server: DM);
23
66
  /**
@@ -47,27 +90,36 @@ export default class TwakeGroups extends LdapGroups {
47
90
  * within its own organization only. The placeholder belongs to none.
48
91
  */
49
92
  private checkMembers;
93
+ /** A member that is not a user of the organization, if any. */
94
+ private foreign;
50
95
  /**
51
96
  * A tombstone keeps its memberships until it is erased, and is hidden from
52
97
  * them, so every API reads the same member list.
53
98
  */
54
99
  private hideTombstones;
55
- private tombstonesOf;
100
+ /**
101
+ * Read without a request: hiding a tombstone is integrity, not data, and
102
+ * must not depend on what the caller may see.
103
+ */
104
+ tombstonesOf(org: string): Promise<string[]>;
56
105
  /**
57
106
  * Only the organization routes: the flat `/v1/ldap/groups` of
58
107
  * core/ldap/groups would read one base that holds no group here.
59
108
  */
60
109
  api(app: Express): void;
61
- private groupDn;
62
- private get userAttribute();
110
+ /** A route of the organization `:id`; see `RouteError` for refusals. */
111
+ organizationRoute(context: string, handler: (req: Request, res: Response, org: string) => Promise<void>, plugin?: string): (req: Request, res: Response) => Promise<void>;
112
+ groupDn(org: string, id: string): string;
113
+ get userAttribute(): string;
114
+ userDn(org: string, username: string): string;
63
115
  /** Every route answers 404 for a missing organization, 410 for a deleted one. */
64
116
  private checkOrganization;
65
117
  /** Page, search and sort of a list route, or a refusal of them. */
66
- private page;
67
- private sorted;
118
+ page(req: Request, sortable: string[]): Page;
119
+ sorted<T extends Record<string, unknown>>(items: T[], sortBy: string | undefined, desc: boolean): T[];
68
120
  /** A group as the routes answer it: members named by their RDN value. */
69
- private groupOf;
70
- private groups;
121
+ groupOf(org: string, entry: AttributesList): Record<string, unknown>;
122
+ groups(org: string, filter: string, attributes?: string[], req?: Request): Promise<AttributesList[]>;
71
123
  private readGroup;
72
124
  /** The group of that display name in the organization, if any. */
73
125
  private named;
@@ -77,12 +129,20 @@ export default class TwakeGroups extends LdapGroups {
77
129
  private createRoute;
78
130
  private updateRoute;
79
131
  private membersRoute;
132
+ /**
133
+ * A page of members' public profiles, each with what `extra` adds for its
134
+ * username.
135
+ */
136
+ memberPage(p: Page, org: string, names: string[], extra?: (username: string) => Record<string, unknown>, req?: Request): Promise<{
137
+ members: Record<string, unknown>[];
138
+ pagination: Record<string, number | boolean>;
139
+ }>;
80
140
  /**
81
141
  * Users of the organization by name, read in pages of the size limit. A
82
142
  * tombstone is no user: it cannot be added, as it would join hidden.
83
143
  */
84
- private users;
85
- private profiles;
144
+ users(org: string, usernames: string[], attributes: string[], req?: Request): Promise<AttributesList[]>;
145
+ profiles(org: string, usernames: string[], req?: Request): Promise<Map<string, Record<string, unknown>>>;
86
146
  private addMembersRoute;
87
147
  private dropPlaceholder;
88
148
  private removeMemberRoute;
@@ -76,14 +76,6 @@ export default class TwakeInstances extends DmPlugin {
76
76
  private provide;
77
77
  protected ready(dn: string, entry: AttributesList, account: Account, fqdn: string, req?: Request): Promise<void>;
78
78
  private organizationReady;
79
- /** The organization whose account `dn` is, if it is one */
80
- private organizationOf;
81
- /**
82
- * An organization account added once its organization is announced. One
83
- * added before is filled by organizationReady
84
- */
85
- private giveOrganizationAddress;
86
- private fillOrganizationAccount;
87
79
  /** Send the user.created held while the organization at `dn` was pending */
88
80
  private releaseMembers;
89
81
  /** Announce a held member, with the address rule ensureInstance applies */
@@ -0,0 +1,186 @@
1
+ import type { Express } from 'express';
2
+ import DmPlugin, { type Role } from '../../abstract/plugin';
3
+ import type { DM } from '../../bin';
4
+ import type { Hooks } from '../../hooks';
5
+ /** From the weakest to the strongest. */
6
+ export declare const SPACE_ROLES: readonly ["viewer", "editor", "admin"];
7
+ export type SpaceRole = (typeof SPACE_ROLES)[number];
8
+ export type Space = {
9
+ id: string;
10
+ name: string;
11
+ organizationId: string;
12
+ members: {
13
+ username: string;
14
+ role: SpaceRole;
15
+ }[];
16
+ groups: {
17
+ id: string;
18
+ role: SpaceRole;
19
+ }[];
20
+ };
21
+ export default class TwakeSpaces extends DmPlugin {
22
+ name: string;
23
+ roles: Role[];
24
+ dependencies: Record<string, string>;
25
+ private readonly spaceBase;
26
+ private readonly spacePattern;
27
+ private readonly userPattern;
28
+ private readonly lifecycle;
29
+ private readonly displayName;
30
+ readonly roleAttributes: Record<SpaceRole, string>;
31
+ private readonly userRole;
32
+ private readonly exchange;
33
+ /**
34
+ * Changes are followed one at a time, each from the directory as it is
35
+ * then, so the order they are followed in does not matter.
36
+ */
37
+ private following;
38
+ private publishing;
39
+ /**
40
+ * The spaces of a group being deleted, by DN key, read before refint takes
41
+ * it out of them, until its deletion is followed. `op` is the delete
42
+ * still running, `owed` the unlinks space writes left to it meanwhile.
43
+ */
44
+ private readonly unlinking;
45
+ /**
46
+ * The groups a deletion announced unlinked, by space DN key, until a write
47
+ * of the space takes their DN out of it, or finds it gone already.
48
+ */
49
+ private readonly announced;
50
+ /**
51
+ * The spaces a user being deleted is an admin of, by operation and DN,
52
+ * read before refint takes the user out of them.
53
+ */
54
+ private readonly handing;
55
+ constructor(server: DM);
56
+ /** Whether user roles are kept or events published. */
57
+ private get follows();
58
+ private get groups();
59
+ private get tombstone();
60
+ hooks: Hooks;
61
+ /**
62
+ * core/rabbitmq connects lazily and hands back no client when it cannot:
63
+ * every event would then be lost, so the server does not start.
64
+ *
65
+ * core/ldap/trash moves a deleted group or space away instead of deleting
66
+ * it: nothing follows the move, so users would keep the roles it gave,
67
+ * and no event would say it went.
68
+ */
69
+ assertComposition(): Promise<void>;
70
+ private queue;
71
+ /** The organization a space DN belongs to, if it is a space at all. */
72
+ organizationOf(dn: string): string | undefined;
73
+ spaceBaseOf(org: string): string;
74
+ spaceDn(org: string, id: string): string;
75
+ /** An entry as it is, read for no one, or nothing for one missing. */
76
+ private entry;
77
+ /** A space holds users and groups of its organization, whichever API writes it. */
78
+ private checkHolders;
79
+ /** A holder that is neither a user nor a group of the organization. */
80
+ private foreign;
81
+ api(app: Express): void;
82
+ /** A space as the routes answer it, strongest role first. */
83
+ private spaceOf;
84
+ /**
85
+ * The users and groups an entry holds, strongest role first, each once
86
+ * with its strongest role. A tombstone is hidden.
87
+ */
88
+ private entryOf;
89
+ /** The spaces of the organization matching a filter, or the one of an id. */
90
+ private spaces;
91
+ /**
92
+ * Whoever asks: a tombstone outside the caller's branches would otherwise
93
+ * count as a member, and as an admin.
94
+ */
95
+ private hidden;
96
+ /** A filter for the spaces holding any of these DNs, whatever the role. */
97
+ private holding;
98
+ private space;
99
+ private write;
100
+ /**
101
+ * A change decided on a space read before another request wrote it fails
102
+ * with LDAP 16 or 20: it is decided again on the space as it is now.
103
+ */
104
+ private again;
105
+ /** The live users of the organization by lowercased name, as DNs, or a 404. */
106
+ private liveUsers;
107
+ private get groupName();
108
+ /** Groups of the organization by id, read in pages of the size limit. */
109
+ private orgGroups;
110
+ /**
111
+ * The users a space write or a change of a group's members may have
112
+ * moved, and the events of the write. A deleted group is followed through
113
+ * its members as they were.
114
+ */
115
+ private follow;
116
+ /**
117
+ * The spaces a user is an admin of. Read again when a tombstone is erased:
118
+ * its hand-over may have failed, or predate this plugin, and handing over
119
+ * a space that keeps another admin does nothing.
120
+ */
121
+ private administered;
122
+ /**
123
+ * A space a deleted user was the last admin of goes to its editors, else
124
+ * its viewers; one left with no member is deleted.
125
+ */
126
+ private handOver;
127
+ private handOverOnce;
128
+ /** A group about to be deleted, if it is one linked to spaces. */
129
+ private deletedGroup;
130
+ /**
131
+ * The spaces a deleted group left. One still holding its DN, without
132
+ * refint, remembers the announcement for the write that takes it out.
133
+ */
134
+ private unlinked;
135
+ /**
136
+ * Rewrite each user's roles from the directory as it is now: a change
137
+ * only tells which users to look at, so two changes followed in either
138
+ * order, or late, leave the same roles.
139
+ *
140
+ * The roles each user had come from the values written before, or, with
141
+ * no user role attribute, from the directory with the change undone: two
142
+ * changes followed late may then announce a role twice, or not at all.
143
+ */
144
+ private settle;
145
+ /**
146
+ * A filter for the spaces a user is in, directly or through a group, and
147
+ * their role in one of them. With a group's members undone, as they were.
148
+ */
149
+ private userIn;
150
+ /**
151
+ * The events of a followed change: the space write itself, then each
152
+ * moved user's roles, except in a space created or deleted by it.
153
+ *
154
+ * A role may have moved in a space the change did not touch, by another
155
+ * change not followed yet. Without the role attribute, that change
156
+ * announces it. With it, this follow wrote the value, so that change
157
+ * will find nothing to announce: the role goes out here, without an
158
+ * actor, as this change's is not the one that moved it.
159
+ */
160
+ private announce;
161
+ /** Users as space events name them, by DN key, live users only. */
162
+ private profiles;
163
+ private publishEvent;
164
+ private get rabbitmq();
165
+ private publish;
166
+ /** A user's roles, in the directory as it is or with a change undone. */
167
+ private rolesOf;
168
+ /**
169
+ * Write a user's `<space id>:<role>` values, and answer the roles they
170
+ * held, or nothing for a user gone.
171
+ */
172
+ private writeRoles;
173
+ /** The groups of the organization by lowercased id, as DNs, or a 404. */
174
+ private groupDns;
175
+ /** Each of a space's members or groups holds one role. */
176
+ private add;
177
+ private addOnce;
178
+ /**
179
+ * Move a member or group to another role, or out of the space without
180
+ * one. A space keeps at least one admin among its users.
181
+ */
182
+ private change;
183
+ private changeOnce;
184
+ private listRoute;
185
+ private createRoute;
186
+ }
@@ -53,6 +53,11 @@ export default class TwakeTombstone extends DmPlugin {
53
53
  force?: boolean;
54
54
  req?: Request;
55
55
  }): Promise<void>;
56
+ /**
57
+ * Whether a delete of this DN, asked for now, writes a tombstone instead of
58
+ * reaching the directory: not when it erases one.
59
+ */
60
+ keeps(dn: string): boolean;
56
61
  /**
57
62
  * core/ldap/trash moves a deleted entry away before this plugin, last in
58
63
  * the delete chain, sees it: no tombstone would be written, and an erase
@@ -690,6 +690,27 @@ api(app: Express): void {
690
690
  }
691
691
  ```
692
692
 
693
+ #### Parsing request bodies
694
+
695
+ The server parses `application/json` (up to 100 kB) and
696
+ `application/x-www-form-urlencoded` bodies before authentication runs, so
697
+ that `core/auth/hmac` can check them. A plugin that needs another media type
698
+ or limit under its prefix registers its own parser, which runs ahead of
699
+ those:
700
+
701
+ ```typescript
702
+ import { jsonBodyParser } from '../../lib/rawBody';
703
+
704
+ api(app: Express): void {
705
+ this.server.registerBodyParser('/myplugin', jsonBodyParser({ limit: '1mb' }));
706
+ }
707
+ ```
708
+
709
+ `jsonBodyParser` keeps the bytes it reads, which `core/auth/hmac` hashes. Any
710
+ other parser must pass `keepRawBody` as `verify`, or `core/auth/hmac` refuses
711
+ its bodies with `401`. A parser mounted with `app.use()` in `api()` runs
712
+ after authentication, too late for that check.
713
+
693
714
  #### Error Handling Best Practices
694
715
 
695
716
  The `asyncHandler` wrapper automatically catches errors and passes them to the global error middleware. This ensures:
@@ -19,6 +19,9 @@ Typescript definitions into [hooks.ts](./src/hooks.ts)
19
19
  - **ldapmodifyrequest**: called before any ldapmodify
20
20
  - **ldapdeleterequest**: called before any ldapdelete
21
21
  - **ldaprenamerequest**: called before any ldap rename/modifyDN operation
22
+ - **ldapmodifyend**, **ldapdeleteend**, **ldaprenameend**: launched once a
23
+ modify, delete or rename is over, whatever became of it, see
24
+ [below](#ldapmodifyend-ldapdeleteend-ldaprenameend)
22
25
 
23
26
  ### ldapaddafter
24
27
 
@@ -41,6 +44,28 @@ the account the entry describes.
41
44
  no set order with it: an add event may or may not include what a
42
45
  subscriber here wrote.
43
46
 
47
+ ### ldapmodifyend, ldapdeleteend, ldaprenameend
48
+
49
+ For a plugin that keeps something from a request hook to the matching "done"
50
+ one, as core/ldap/onChange keeps the entry it read. A write that is refused
51
+ by a request hook, that one of them takes out of the request (core/ldap/trash
52
+ and core/twake/tombstone do with a delete), or that the directory refuses,
53
+ never reaches its "done" hook; its "end" hook fires all the same.
54
+
55
+ - Each gets the number of its operation. A modify's request and "done" hooks
56
+ are given it in their arguments; for every modify, delete and rename,
57
+ `currentOperation()` (`lib/operation`) returns it inside the request and
58
+ "done" hooks. Key what you keep by it rather than by DN: two writes of one
59
+ DN may be under way, and only the operation tells them apart.
60
+ Outside those hooks it is whatever encloses the call (an add made from a
61
+ delete's request hook sees the delete's number): in an end hook, use the
62
+ number it is given.
63
+ - It is launched once the "done" subscribers of the operation have returned,
64
+ and is not awaited by the write.
65
+ - A move (`ldapActions.move`) launches no request hook and no end hook, and
66
+ its `ldaprenamedone` runs outside any operation, even when a request hook
67
+ made it.
68
+
44
69
  ## [onChange](../usage/plugins/ldap/on-change.md) hooks
45
70
 
46
71
  - **onLdapEntryChange**: called after any write, with the entry before and
@@ -59,18 +59,37 @@ All CLI options can be set via environment variables with the `DM_` prefix.
59
59
 
60
60
  Some options accept multiple values (e.g., `--plugin`, `--auth-token`, `--ldap-url`). These can be configured in several ways:
61
61
 
62
- **Via CLI - repeat the option:**
62
+ **Via CLI - repeat the option, or give it comma-separated values:**
63
63
 
64
64
  ```bash
65
- ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric --plugin core/ldap/groups
65
+ ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric,core/ldap/groups
66
66
  ```
67
67
 
68
+ Only options holding identifiers split on commas: `--plugin`, `--mail-domain`,
69
+ `--ldap-url`, the object class options (`--user-class`, `--group-class`,
70
+ `--ldap-organization-class`, `--external-branch-class`,
71
+ `--scim-user-object-class`, `--scim-group-object-class`,
72
+ `--twake-space-class`), the attribute
73
+ options (`--ldap-raw-hidden-attribute`, `--applicative-account-attribute`,
74
+ `--ldap-operational-attribute`, `--twake-tombstone-clear-attributes`),
75
+ `--twake-tombstone-reasons`, `--authz-for`, `--authz-dynamic-bypass` and
76
+ `--trusted-proxy`. Any other
77
+ option takes each value whole, so a DN or a secret keeps its commas.
78
+
68
79
  **Via CLI - use plural form with comma-separated values:**
69
80
 
70
81
  ```bash
71
82
  ldap-rest --plugins core/auth/token,core/ldap/flatGeneric,core/ldap/groups
72
83
  ```
73
84
 
85
+ The plural form splits like the environment variable, see below. Give a
86
+ DN with the repeated singular option, or separate DNs with `;` in the plural
87
+ form:
88
+
89
+ ```bash
90
+ ldap-rest --ldap-raw-base ou=users,dc=example,dc=com --ldap-raw-base ou=groups,dc=example,dc=com
91
+ ```
92
+
74
93
  **Via environment variable - use `;` or `,` as separator:**
75
94
 
76
95
  ```bash
@@ -81,7 +100,49 @@ export DM_PLUGINS="core/auth/token;core/ldap/flatGeneric;core/ldap/groups"
81
100
  export DM_PLUGINS="core/auth/token,core/ldap/flatGeneric,core/ldap/groups"
82
101
  ```
83
102
 
84
- > **Note:** If the value contains a semicolon, it will be used as the separator. Otherwise, commas are used. Whitespace around separators is ignored.
103
+ The split depends on what the option holds. The plural form splits the same
104
+ way:
105
+
106
+ - Options holding identifiers (the list above): a value containing `;` is
107
+ split on `;` and spaces, any other on `,` and spaces. A value starting with
108
+ `;` is split on `;` too.
109
+ - Options holding DNs or DN expressions (`--ldap-raw-base`,
110
+ `--james-mailing-list-branch`, `--twake-tombstone-dn`,
111
+ `--twake-tombstone-group-bases`, `--twake-instance-dn`): split on `;` only,
112
+ never on `,` nor spaces, as a DN holds both.
113
+ `DM_LDAP_RAW_BASE="ou=My Unit,dc=example,dc=com"` is one DN; separate
114
+ several DNs with `;`.
115
+ - `--auth-hmac` (`id:secret:name`) and `--auth-totp` (`secret:name[:digits]`):
116
+ split on `;` if the value contains one, else on `,`, and on newlines (one
117
+ entry per line), never on spaces, so a name keeps its spaces. A list
118
+ separated by spaces is read as one entry: for HMAC the services after the
119
+ first cannot authenticate, and for TOTP the entry is refused. Any entry that
120
+ cannot work stops the server: an HMAC one with an empty id, secret or name or
121
+ fewer than 3 fields, a TOTP one with an empty secret or name, too many
122
+ fields, a secret that is not Base32 or digits that are not an integer from 6
123
+ to 10.
124
+ - Any other option (tokens, paths, rules...): split on `;` if
125
+ the value contains one, else on `,`, and on spaces. `DM_AUTH_TOKENS="t1 t2"`
126
+ and `DM_AUTH_TOKENS="t1,t2"` are two tokens, and padded base64 secrets
127
+ (`YWJj=,ZGVm=`) split on the comma.
128
+
129
+ Empty items are dropped, and each item is trimmed. A variable that is empty,
130
+ only holds whitespace or only holds separators (`;`, `${A:-};${B:-}`), such as
131
+ `${VAR:-}` in a compose file, counts as unset: the default applies, so an
132
+ environment variable cannot give an empty list to an option whose default is
133
+ not empty. The exception is `DM_LDAP_URL`, whose default `ldap://localhost` is
134
+ a guess: an empty value stops the server. The same
135
+ goes for an empty number or JSON value such as `DM_PORT=`. A number option
136
+ takes an integer only: `DM_PORT=abc`, `DM_PORT=1e3`, `--port 80abc` and
137
+ `--port --log-level debug` stop the server, and so does an empty number on the
138
+ command line, as an empty array value does.
139
+ The plural form of an option takes `;` before `,` too: `--auth-tokens 'a;b,c'`
140
+ is `a` and `b,c`.
141
+
142
+ Values given on the command line replace the default, and are added to those
143
+ of the environment variable. An empty value on the command line, such as an
144
+ unset variable expanding to nothing, stops the server: leave the option out
145
+ to keep the default.
85
146
 
86
147
  ## General Options
87
148
 
@@ -500,7 +561,6 @@ See [lifecycle events](plugins/integrations/lifecycle-events.md).
500
561
  | `--twake-instance-organization-domain-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE` | `twakeDomain` | Attribute holding an organization's domain |
501
562
  | `--twake-instance-organization-name-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE` | `description` | Attribute holding an organization's name |
502
563
  | `--twake-instance-organization-fqdn-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE` | | Address attribute of an organization (empty: not written) |
503
- | `--twake-instance-organization-account` | `DM_TWAKE_INSTANCE_ORGANIZATION_ACCOUNT` | | Organization account DN, `{id}` its id (empty: none) |
504
564
 
505
565
  See [instances](plugins/integrations/instances.md).
506
566
 
@@ -542,6 +602,25 @@ Extends `core/ldap/groups`, whose options it reads too.
542
602
 
543
603
  See [groups](plugins/integrations/groups.md).
544
604
 
605
+ #### `core/twake/spaces`
606
+
607
+ Requires `core/twake/groups`, whose options it reads for users, groups and the
608
+ organization entry, and `core/ldap/onChange`; with `--rabbitmq-url`, it
609
+ requires `core/rabbitmq`.
610
+
611
+ | CLI | Env | Default | Description |
612
+ | -------------------------------------- | --------------------------------------- | ------------------ | -------------------------------------------------- |
613
+ | `--twake-space-base` | `DM_TWAKE_SPACE_BASE` | | Space branch of an organization, with `{org}` |
614
+ | `--twake-space-class` | `DM_TWAKE_SPACE_CLASS` | `top,twakeSpace` | Object classes of a space |
615
+ | `--twake-space-display-name-attribute` | `DM_TWAKE_SPACE_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Space name attribute |
616
+ | `--twake-space-admin-attribute` | `DM_TWAKE_SPACE_ADMIN_ATTRIBUTE` | `twakeSpaceAdmin` | DNs holding the admin role |
617
+ | `--twake-space-editor-attribute` | `DM_TWAKE_SPACE_EDITOR_ATTRIBUTE` | `twakeSpaceEditor` | DNs holding the editor role |
618
+ | `--twake-space-viewer-attribute` | `DM_TWAKE_SPACE_VIEWER_ATTRIBUTE` | `twakeSpaceViewer` | DNs holding the viewer role |
619
+ | `--twake-space-user-role-attribute` | `DM_TWAKE_SPACE_USER_ROLE_ATTRIBUTE` | | User attribute holding `<space id>:<role>`, if set |
620
+ | `--twake-space-exchange` | `DM_TWAKE_SPACE_EXCHANGE` | `space` | Topic exchange of the space events |
621
+
622
+ See [spaces](plugins/integrations/spaces.md).
623
+
545
624
  ### Utility Plugins
546
625
 
547
626
  #### `core/static`
@@ -60,8 +60,13 @@ DM_AUTH_HMAC_WINDOW=120000 # Default: 120000ms (2 minutes)
60
60
 
61
61
  Only a body the server parses before checking the signature can be signed:
62
62
  `application/json`, `application/x-www-form-urlencoded` and, under the
63
- SCIM prefix, `application/scim+json`. A POST, PATCH or PUT carrying any other body,
63
+ prefix of a loaded `core/scim`, `application/scim+json`. A POST, PATCH or PUT carrying any other body,
64
64
  such as a `multipart/form-data` bulk import upload, is refused with `401`.
65
+ So is a GET, DELETE or HEAD carrying a body, since they are signed without
66
+ one.
67
+
68
+ The `Content-Type` is not part of the signature. A signed body is read as
69
+ UTF-8 only: one sent under another `charset` is refused with `401`.
65
70
 
66
71
  2. **Client sends Authorization header:**
67
72
 
@@ -19,6 +19,7 @@ Plugins for connecting LDAP-Rest to external systems.
19
19
  | [instances](instances.md) | Workplace instance of each account |
20
20
  | [tombstone](tombstone.md) | Deleted accounts kept as tombstones |
21
21
  | [groups](groups.md) | Organization groups |
22
+ | [spaces](spaces.md) | Organization spaces, with member roles |
22
23
 
23
24
  ## Prerequisites
24
25
 
@@ -47,4 +48,9 @@ core/twake/calendar
47
48
  core/twake/lifecycleEvents
48
49
  ├─ requires: core/ldap/onChange
49
50
  └─ requires: core/rabbitmq
51
+
52
+ core/twake/spaces
53
+ ├─ requires: core/twake/groups
54
+ ├─ requires: core/ldap/onChange
55
+ └─ requires: core/rabbitmq (with RabbitMQ)
50
56
  ```
@@ -12,8 +12,14 @@ branch per organization. It serves the groups of an organization under
12
12
  display name attribute, an optional color and its creation date.
13
13
  - A group's members belong to its organization: adding a user of another
14
14
  organization, or of no organization, is refused with a 400, whichever API
15
- writes the group (REST, SCIM, another plugin). The placeholder
16
- (`--group-dummy-user`) is the one exception.
15
+ writes the group (REST, SCIM, another plugin), and so is moving a group
16
+ with its members, tombstones included, straight into another
17
+ organization's group branch: that refusal names none of them. The
18
+ placeholder (`--group-dummy-user`) is the one exception. Not checked:
19
+ moving a group out of the organizations' branches (to `ou=archive,…`, for
20
+ instance) or deeper than one level under another organization, which
21
+ takes its members along, and moving a user to another organization, whose
22
+ groups in the one it left keep it as a member.
17
23
  - An empty group holds the placeholder, because `groupOfNames` needs a member.
18
24
  The placeholder is never listed.
19
25
  - A tombstone (see [tombstone](tombstone.md)) keeps its memberships until it
@@ -33,7 +39,8 @@ branch per organization. It serves the groups of an organization under
33
39
 
34
40
  Every route answers 404 `ORGANIZATION_NOT_FOUND` when the organization entry
35
41
  is missing, and 410 `ORGANIZATION_DELETED` when it is deleted. Errors are
36
- `{ "error": "...", "code": "..." }`.
42
+ `{ "error": "...", "code": "..." }`. A request an authorization plugin refuses
43
+ answers 403 `REFUSED`.
37
44
 
38
45
  A group reads:
39
46
 
@@ -55,8 +62,8 @@ A group reads:
55
62
  display name reads its `cn`.
56
63
 
57
64
  - `GET /groups?page&limit&search&sortBy&sortOrder`: `search` (2 characters
58
- or more) matches the display name and the description; `sortBy` is
59
- `displayName`, `description` or `createdAt`. Answers
65
+ or more; an empty one lists everything) matches the display name and the
66
+ description; `sortBy` is `displayName`, `description` or `createdAt`. Answers
60
67
  `{ organizationId, groups, pagination: { page, limit, total, totalPages } }`.
61
68
  - `POST /groups` with `{ name, description?, color? }`: `name` is at most 256
62
69
  characters, not blank, without control characters, and unique in the
@@ -75,16 +75,6 @@ from `--twake-instance-organization-name-attribute`, which
75
75
  `ensureOrganization` fills from `name` when the entry has none. The mark, not
76
76
  the address, says the organization is done.
77
77
 
78
- `--twake-instance-organization-account` names an account of the
79
- organization by a DN template, `{id}` standing for the organization id, for
80
- example `uid={id},ou=users,ou={id},ou=organizations,dc=example,dc=com`. It
81
- is usually a technical account, kept from an instance of its own by
82
- `--twake-instance-skip-attribute`. When the organization is announced, and on
83
- any replay, the account gets the organization's address in
84
- `--twake-instance-fqdn-attribute`, when it exists and has none. An account
85
- added once the organization is marked gets it at creation, read from the
86
- organization entry or else from the provider.
87
-
88
78
  With the Cloudery, a member's `user.created` waits for its organization's: a
89
79
  member under an organization entry that is not marked yet gets its address
90
80
  but no event and no mark. A member outside its organization entry is not
@@ -195,7 +195,9 @@ Payload and `when` values are sources:
195
195
  - anything else: the value itself
196
196
 
197
197
  `$attr` and `$previous.attr` carry the first value of a multi-valued
198
- attribute. A source with no value leaves its field out.
198
+ attribute. A source with no value leaves its field out. Every attribute a
199
+ source names is requested by name when the entry is read, so an operational
200
+ one such as `entryUUID` works anywhere in a payload or a `when`.
199
201
 
200
202
  A payload field can also list members, as an object with one key:
201
203