ldap-rest 0.12.0 → 0.13.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 (46) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/config/args.js +1 -1
  3. package/dist/lib/errors.js +1 -1
  4. package/dist/lib/instanceProviders.js +1 -1
  5. package/dist/lib/ldapActions.js +1 -1
  6. package/dist/plugins/auth/openidconnect.js +1 -1
  7. package/dist/plugins/ldap/groups.js +1 -1
  8. package/dist/plugins/ldap/trash.js +1 -1
  9. package/dist/plugins/scim/filter.js +1 -1
  10. package/dist/plugins/scim/groups.js +1 -1
  11. package/dist/plugins/scim/mapping.js +1 -1
  12. package/dist/plugins/scim/patch.js +1 -1
  13. package/dist/plugins/scim/users.js +1 -1
  14. package/dist/plugins/twake/groups.js +1 -0
  15. package/dist/plugins/twake/instances.js +1 -1
  16. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  17. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  18. package/dist/plugins/twake/tombstone.js +1 -1
  19. package/dist/src/abstract/ldapFlat.d.ts +34 -6
  20. package/dist/src/config/args.d.ts +12 -0
  21. package/dist/src/lib/errors.d.ts +3 -0
  22. package/dist/src/lib/instanceProviders.d.ts +2 -0
  23. package/dist/src/plugins/auth/openidconnect.d.ts +15 -0
  24. package/dist/src/plugins/ldap/groups.d.ts +5 -0
  25. package/dist/src/plugins/scim/filter.d.ts +2 -1
  26. package/dist/src/plugins/scim/groups.d.ts +1 -0
  27. package/dist/src/plugins/scim/mapping.d.ts +18 -7
  28. package/dist/src/plugins/scim/patch.d.ts +2 -2
  29. package/dist/src/plugins/twake/groups.d.ts +89 -0
  30. package/dist/src/plugins/twake/instances.d.ts +14 -0
  31. package/dist/src/plugins/twake/lifecycleEvents.d.ts +20 -3
  32. package/docs/client-development/api/rest-api.md +32 -2
  33. package/docs/usage/configuration.md +22 -1
  34. package/docs/usage/plugins/README.md +1 -0
  35. package/docs/usage/plugins/auth/oidc.md +19 -0
  36. package/docs/usage/plugins/integrations/README.md +1 -0
  37. package/docs/usage/plugins/integrations/groups.md +225 -0
  38. package/docs/usage/plugins/integrations/instances.md +31 -3
  39. package/docs/usage/plugins/integrations/lifecycle-events.md +38 -5
  40. package/docs/usage/plugins/integrations/scim.md +44 -34
  41. package/docs/usage/plugins/integrations/tombstone.md +2 -10
  42. package/docs/usage/plugins/ldap/flat-generic.md +38 -2
  43. package/docs/usage/plugins/ldap/trash.md +4 -4
  44. package/docs/usage/upgrading.md +56 -0
  45. package/package.json +9 -1
  46. package/static/schemas/twake/organizationGroups.json +32 -0
@@ -60,11 +60,24 @@ export default class TwakeInstances extends DmPlugin {
60
60
  name: string;
61
61
  domain: string;
62
62
  }): Promise<'ready' | 'pending'>;
63
+ private organizationRequest;
64
+ /**
65
+ * The Cloudery builds no member instance for an organization it does not
66
+ * have, so a member asks for a missing one first. The organization is
67
+ * marked when its own workplace.created arrives. Not serialized on the
68
+ * organization: its work releases members, which would wait on this one.
69
+ */
70
+ private requestOrganization;
63
71
  onWorkplaceCreated(message: WorkplaceCreated): Promise<void>;
72
+ private settle;
64
73
  /** Find the instance, or ask for it; write it down once it exists. */
65
74
  private provide;
66
75
  protected ready(dn: string, entry: AttributesList, account: Account, fqdn: string, req?: Request): Promise<void>;
67
76
  private organizationReady;
77
+ /** Send the user.created held while the organization at `dn` was pending */
78
+ private releaseMembers;
79
+ /** Announce a held member, with the address rule ensureInstance applies */
80
+ private release;
68
81
  /**
69
82
  * Run `work` once the work already queued for `key` is over. One replica
70
83
  * only: across replicas, `user.created` may still be sent twice.
@@ -72,6 +85,7 @@ export default class TwakeInstances extends DmPlugin {
72
85
  private serialized;
73
86
  private get organizationId();
74
87
  private get organizationDomain();
88
+ private get organizationName();
75
89
  private get organizationFqdn();
76
90
  private organizationMail;
77
91
  private isOrganization;
@@ -1,14 +1,21 @@
1
1
  import DmPlugin, { type Role } from '../../abstract/plugin';
2
2
  import type { DM } from '../../bin';
3
3
  import type { Hooks } from '../../hooks';
4
- export declare const LIFECYCLE_EVENTS: readonly ["created", "roleChanged", "disabled", "enabled", "deleted"];
4
+ export declare const LIFECYCLE_EVENTS: readonly ["created", "roleChanged", "disabled", "enabled", "updated", "memberAdded", "memberRemoved", "deleted"];
5
5
  export type LifecycleEvent = (typeof LIFECYCLE_EVENTS)[number];
6
- type Payload = Record<string, string>;
6
+ declare const MEMBER_LISTS: readonly ["$members", "$added", "$removed"];
7
+ /** A payload field listing members, each read and shaped by `fields`. */
8
+ interface MemberList {
9
+ list: (typeof MEMBER_LISTS)[number];
10
+ fields: Record<string, string>;
11
+ }
12
+ type Payload = Record<string, string | MemberList>;
13
+ type Condition = Record<string, string>;
7
14
  interface Target {
8
15
  exchange: string;
9
16
  routingKey: string;
10
17
  payload: Payload;
11
- when: Payload;
18
+ when: Condition;
12
19
  }
13
20
  interface Rule {
14
21
  dn: RegExp;
@@ -24,6 +31,7 @@ export default class TwakeLifecycleEvents extends DmPlugin {
24
31
  };
25
32
  private readonly rules;
26
33
  private readonly attrs;
34
+ private readonly memberAttribute;
27
35
  constructor(server: DM);
28
36
  /**
29
37
  * core/rabbitmq connects lazily and hands back no client when it cannot:
@@ -39,6 +47,15 @@ export default class TwakeLifecycleEvents extends DmPlugin {
39
47
  private replay;
40
48
  private match;
41
49
  private onEntryChange;
50
+ /** The members of an entry, the group placeholder left out. */
51
+ private members;
52
+ private memberDiff;
53
+ /**
54
+ * A member list of a payload: one read per member, a tombstone left out,
55
+ * and a member no longer in the directory given its RDN alone.
56
+ */
57
+ private memberPayloads;
58
+ private readMember;
42
59
  private resolve;
43
60
  private publish;
44
61
  }
@@ -538,6 +538,12 @@ Retrieve all users or filter by attributes.
538
538
  - `match` - Filter value (partial match with wildcards)
539
539
  - `attribute` - Attribute name to filter on
540
540
  - `attributes` - Comma-separated list of attributes to return
541
+ - `limit` - Return at most this many users; `X-Result-Truncated: true` then
542
+ says others were left out. Without it, a list longer than the directory's
543
+ size limit is answered `422`
544
+ ([details](../../usage/plugins/ldap/flat-generic.md#long-lists)). Only the
545
+ flat entities' lists (users and the other schema-driven entities) take
546
+ `limit` for now; the [groups list](#list-groups) does not.
541
547
 
542
548
  **Example Request (all users):**
543
549
 
@@ -764,6 +770,11 @@ Retrieve all groups or filter by name.
764
770
  - `match` - Filter groups by CN (supports LDAP filter format or simple string)
765
771
  - `attributes` - Comma-separated list of attributes to return
766
772
 
773
+ This list does not take `limit` yet: `?limit=` is ignored, no
774
+ `X-Result-Truncated` header is sent, and a list longer than the directory's
775
+ size limit fails with `500` rather than the `422` of the
776
+ [users list](#list-users).
777
+
767
778
  **Example Request (all groups):**
768
779
 
769
780
  ```bash
@@ -1046,6 +1057,7 @@ curl -X DELETE \
1046
1057
  | 403 | Forbidden - Insufficient permissions |
1047
1058
  | 404 | Not Found - Resource doesn't exist |
1048
1059
  | 409 | Conflict - Resource already exists |
1060
+ | 422 | Unprocessable - see below |
1049
1061
  | 500 | Internal Server Error |
1050
1062
 
1051
1063
  ### Error Response Format
@@ -1140,6 +1152,18 @@ All errors return a JSON object with an error message:
1140
1152
  }
1141
1153
  ```
1142
1154
 
1155
+ #### 422 Unprocessable Entity
1156
+
1157
+ **List longer than the directory's size limit** (flat entities' lists, when
1158
+ no `limit` was given —
1159
+ [details](../../usage/plugins/ldap/flat-generic.md#long-lists)):
1160
+
1161
+ ```json
1162
+ {
1163
+ "error": "Too many entries to list at once: the directory's size limit was exceeded. Narrow the search, or pass `limit`."
1164
+ }
1165
+ ```
1166
+
1143
1167
  #### 500 Internal Server Error
1144
1168
 
1145
1169
  **LDAP Connection Error:**
@@ -1202,9 +1226,15 @@ Use the correct operation type:
1202
1226
  - **add**: Add values to multi-valued attributes (error if already exists)
1203
1227
  - **delete**: Remove attributes or specific values
1204
1228
 
1205
- ### Pagination
1229
+ ### Long Lists
1206
1230
 
1207
- For large result sets, consider implementing client-side pagination or use LDAP paging (controlled server-side).
1231
+ The API has no pagination. A flat entity's list (users and the other
1232
+ schema-driven entities) takes `limit` to bound its answer, and says with
1233
+ `X-Result-Truncated: true` that entries were left out: narrow the search with
1234
+ `match` and `attribute` to reach them. Without `limit`, a list longer than the
1235
+ directory's size limit is answered `422`. See
1236
+ [long lists](../../usage/plugins/ldap/flat-generic.md#long-lists); the groups
1237
+ list does not take `limit` yet.
1208
1238
 
1209
1239
  ### Schema Validation
1210
1240
 
@@ -191,7 +191,7 @@ at `0` otherwise.
191
191
  | CLI | Env | Default | Description |
192
192
  | ----------------------- | ------------------------ | ------- | --------------------------- |
193
193
  | `--trash-base` | `DM_TRASH_BASE` | | Trash container DN |
194
- | `--trash-watched-bases` | `DM_TRASH_WATCHED_BASES` | | DNs to watch for deletions |
194
+ | `--trash-watched-bases` | `DM_TRASH_WATCHED_BASES` | | `;`-separated DNs to watch |
195
195
  | `--trash-add-metadata` | `DM_TRASH_ADD_METADATA` | `true` | Add deletion metadata |
196
196
  | `--trash-auto-create` | `DM_TRASH_AUTO_CREATE` | `true` | Auto-create trash container |
197
197
 
@@ -457,6 +457,7 @@ Automatically creates/updates/deletes applicative account entries when users are
457
457
  | `--twake-lifecycle-deleted-at-attribute` | `DM_TWAKE_LIFECYCLE_DELETED_AT_ATTRIBUTE` | | Deletion date attribute |
458
458
  | `--twake-lifecycle-deleted-at-format` | `DM_TWAKE_LIFECYCLE_DELETED_AT_FORMAT` | `iso8601` | `iso8601` or `generalizedTime` |
459
459
  | `--twake-lifecycle-rules` | `DM_TWAKE_LIFECYCLE_RULES` | | Rules: a JSON file, or the JSON itself |
460
+ | `--twake-lifecycle-member-attribute` | `DM_TWAKE_LIFECYCLE_MEMBER_ATTRIBUTE` | `member` | Attribute holding a group's members |
460
461
 
461
462
  See [lifecycle events](plugins/integrations/lifecycle-events.md).
462
463
 
@@ -497,6 +498,7 @@ See [lifecycle events](plugins/integrations/lifecycle-events.md).
497
498
  | `--twake-instance-organization-base` | `DM_TWAKE_INSTANCE_ORGANIZATION_BASE` | | Branch of the organization entries |
498
499
  | `--twake-instance-organization-id-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_ID_ATTRIBUTE` | `ou` | Attribute holding an organization's id |
499
500
  | `--twake-instance-organization-domain-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_DOMAIN_ATTRIBUTE` | `twakeDomain` | Attribute holding an organization's domain |
501
+ | `--twake-instance-organization-name-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_NAME_ATTRIBUTE` | `description` | Attribute holding an organization's name |
500
502
  | `--twake-instance-organization-fqdn-attribute` | `DM_TWAKE_INSTANCE_ORGANIZATION_FQDN_ATTRIBUTE` | | Address attribute of an organization (empty: not written) |
501
503
 
502
504
  See [instances](plugins/integrations/instances.md).
@@ -520,6 +522,25 @@ Reads the `--twake-lifecycle-*` attributes above, and:
520
522
 
521
523
  See [tombstone](plugins/integrations/tombstone.md).
522
524
 
525
+ #### `core/twake/groups`
526
+
527
+ Extends `core/ldap/groups`, whose options it reads too.
528
+
529
+ | CLI | Env | Default | Description |
530
+ | --------------------------------------------- | ---------------------------------------------- | ------------------ | ----------------------------------------------------- |
531
+ | `--twake-group-base` | `DM_TWAKE_GROUP_BASE` | | Group branch of an organization, with `{org}` |
532
+ | `--twake-group-user-base` | `DM_TWAKE_GROUP_USER_BASE` | | User branch of an organization, with `{org}` |
533
+ | `--twake-group-organization-dn` | `DM_TWAKE_GROUP_ORGANIZATION_DN` | | Organization entry, with `{org}` (empty: not checked) |
534
+ | `--twake-group-organization-status-attribute` | `DM_TWAKE_GROUP_ORGANIZATION_STATUS_ATTRIBUTE` | `twakeOrgStatus` | Organization status attribute |
535
+ | `--twake-group-organization-deleted-value` | `DM_TWAKE_GROUP_ORGANIZATION_DELETED_VALUE` | `deleted` | Status of a deleted organization |
536
+ | `--twake-group-display-name-attribute` | `DM_TWAKE_GROUP_DISPLAY_NAME_ATTRIBUTE` | `twakeDisplayName` | Group display name attribute |
537
+ | `--twake-group-color-attribute` | `DM_TWAKE_GROUP_COLOR_ATTRIBUTE` | `twakeGroupColor` | Group color attribute |
538
+ | `--twake-group-created-at-attribute` | `DM_TWAKE_GROUP_CREATED_AT_ATTRIBUTE` | `twakeCreatedAt` | Group creation date attribute |
539
+ | `--twake-group-max-page-limit` | `DM_TWAKE_GROUP_MAX_PAGE_LIMIT` | `1000` | Largest page, and most users added at once |
540
+ | `--twake-group-member-fields` | `DM_TWAKE_GROUP_MEMBER_FIELDS` | `{}` | Member profile fields over the defaults, as JSON |
541
+
542
+ See [groups](plugins/integrations/groups.md).
543
+
523
544
  ### Utility Plugins
524
545
 
525
546
  #### `core/static`
@@ -62,6 +62,7 @@ Connect to external systems:
62
62
  | [lifecycle-events](integrations/lifecycle-events.md) | Account lifecycle events to RabbitMQ |
63
63
  | [instances](integrations/instances.md) | Workplace instance of each account |
64
64
  | [tombstone](integrations/tombstone.md) | Deleted accounts kept as tombstones |
65
+ | [groups](integrations/groups.md) | Organization groups |
65
66
 
66
67
  ## Utility Plugins
67
68
 
@@ -85,6 +85,25 @@ client (`Accept: application/json`) receives 401. Every other authentication
85
85
  plugin in this repository answers JSON 401 throughout — the difference is
86
86
  kept because it is what makes a login work at all.
87
87
 
88
+ ## Session lifetime
89
+
90
+ A session lasts no longer than its access token. Once the token has expired,
91
+ the next request renews it with the refresh token, when the provider gave
92
+ one; without a refresh token, or when the provider refuses the renewal, the
93
+ session ends and the request is answered as an unauthenticated one: the
94
+ browser goes back through the provider, which signs the caller in again
95
+ without asking anything while its own session lasts.
96
+
97
+ So that renewals do not cost a round trip through the provider, have it
98
+ issue refresh tokens to this client (LemonLDAP::NG: _Issue refresh tokens_
99
+ in the relying party's options). A token the provider gives no expiry keeps
100
+ the session for the cookie's own lifetime.
101
+
102
+ Nothing serialises the renewals: requests arriving together with the same
103
+ expired cookie each send the refresh token. A provider that rotates refresh
104
+ tokens refuses all but one, and the requests it refused end their session:
105
+ the caller signs in again, silently while the provider's session lasts.
106
+
88
107
  ## Logout
89
108
 
90
109
  `/logout` ends the session at the provider as well (RP-Initiated Logout): the
@@ -18,6 +18,7 @@ Plugins for connecting LDAP-Rest to external systems.
18
18
  | [lifecycle-events](lifecycle-events.md) | Account lifecycle events to RabbitMQ |
19
19
  | [instances](instances.md) | Workplace instance of each account |
20
20
  | [tombstone](tombstone.md) | Deleted accounts kept as tombstones |
21
+ | [groups](groups.md) | Organization groups |
21
22
 
22
23
  ## Prerequisites
23
24
 
@@ -0,0 +1,225 @@
1
+ # Organization Groups
2
+
3
+ `core/twake/groups` is [`core/ldap/groups`](../ldap/groups.md) with one group
4
+ branch per organization. It serves the groups of an organization under
5
+ `/api/v1/organizations/:id/groups`, in place of the flat `/api/v1/ldap/groups`.
6
+
7
+ ## Behaviour
8
+
9
+ - An organization's groups and users live in branches given by DN patterns,
10
+ where `{org}` stands for the organization id.
11
+ - A group is a `groupOfNames` with a generated `cn` (a UUID), its name in the
12
+ display name attribute, an optional color and its creation date.
13
+ - A group's members belong to its organization: adding a user of another
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.
17
+ - An empty group holds the placeholder, because `groupOfNames` needs a member.
18
+ The placeholder is never listed.
19
+ - A tombstone (see [tombstone](tombstone.md)) keeps its memberships until it
20
+ is erased, and is hidden from every member list read through LDAP-Rest.
21
+ A SCIM `PUT` of a group replaces its whole member list, so it drops the
22
+ hidden tombstones too; the routes below add and remove one member at a
23
+ time and keep them. Hiding them costs one search of each organization's
24
+ user branch per search that returns a group with its members, whichever
25
+ plugin makes it.
26
+ - An erased member leaves its groups through the directory: enable the
27
+ `refint` overlay on `member`. Unlike `core/ldap/groups`, this plugin does
28
+ not remove a deleted entry from its groups itself, since that write would
29
+ announce a member removed on an erase.
30
+ - Every route of an organization whose status says deleted answers 410.
31
+
32
+ ## Routes
33
+
34
+ Every route answers 404 `ORGANIZATION_NOT_FOUND` when the organization entry
35
+ is missing, and 410 `ORGANIZATION_DELETED` when it is deleted. Errors are
36
+ `{ "error": "...", "code": "..." }`.
37
+
38
+ A group reads:
39
+
40
+ ```json
41
+ {
42
+ "id": "8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10",
43
+ "cn": "8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10",
44
+ "displayName": "Engineering",
45
+ "description": "Engineering team",
46
+ "color": "#3366FF",
47
+ "organizationId": "acme",
48
+ "baseDN": "cn=8f14e45f-ceea-467d-9a3e-7c2b0a1d5e10,ou=groups,ou=acme,dc=example,dc=com",
49
+ "members": ["jdoe"],
50
+ "createdAt": "2026-01-23T11:00:00.000Z"
51
+ }
52
+ ```
53
+
54
+ `members` are the RDN values of the organization's users. A group without a
55
+ display name reads its `cn`.
56
+
57
+ - `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
60
+ `{ organizationId, groups, pagination: { page, limit, total, totalPages } }`.
61
+ - `POST /groups` with `{ name, description?, color? }`: `name` is at most 256
62
+ characters, not blank, without control characters, and unique in the
63
+ organization among groups made here (409 `GROUP_EXISTS`); `color` is `#RGB`
64
+ or `#RRGGBB`. Answers 201 with the group.
65
+ - `GET /groups/:groupId`: the group, or 404 `GROUP_NOT_FOUND`.
66
+ - `PATCH /groups/:groupId` with any of `name`, `description`, `color`: an
67
+ empty `description` or `color` clears it; any other field is a 400.
68
+ - `DELETE /groups/:groupId`.
69
+ - `GET /groups/:groupId/members?page&limit&search&sortBy&sortOrder`: the
70
+ members' public profiles, `sortBy` `uid` (default), `displayName`, `mail`
71
+ or `jobTitle`. A member that cannot be read is `{ "uid": "..." }`.
72
+ - `POST /groups/:groupId/members` with `{ usernames: [...] }`: every user must
73
+ exist in the organization (404 `USER_NOT_FOUND`, nothing added), at most
74
+ `--twake-group-max-page-limit` per request.
75
+ - `DELETE /groups/:groupId/members/:userId`: 404 `MEMBER_NOT_FOUND` for a user
76
+ who is not a member.
77
+
78
+ Name uniqueness holds for groups made through these routes only: an identity
79
+ provider may push two groups of one name, and a SCIM create is never refused
80
+ for it.
81
+
82
+ ## Configuration
83
+
84
+ ```bash
85
+ --plugin core/twake/groups \
86
+ --twake-group-base 'ou=groups,ou={org},dc=example,dc=com' \
87
+ --twake-group-user-base 'ou=users,ou={org},dc=example,dc=com' \
88
+ --twake-group-organization-dn 'ou={org},dc=example,dc=com' \
89
+ --group-class top,groupOfNames,twakeGroup \
90
+ --group-dummy-user cn=placeholder \
91
+ --group-schema static/schemas/twake/organizationGroups.json
92
+ ```
93
+
94
+ - `--group-schema`: `static/schemas/twake/organizationGroups.json` declares
95
+ the groups' `cn`, members and description. The default group schema
96
+ describes department groups and requires `twakeDepartmentLink`, which these
97
+ groups do not have. Groups created through SCIM are written directly and
98
+ are not checked against this schema.
99
+ - `--twake-group-base`, `--twake-group-user-base`: required, each with
100
+ `{org}`.
101
+ - `--twake-group-organization-dn`: the organization entry. Empty means no
102
+ organization is checked.
103
+ - `--twake-group-organization-status-attribute` (default `twakeOrgStatus`),
104
+ `--twake-group-organization-deleted-value` (default `deleted`).
105
+ - `--twake-group-display-name-attribute` (default `twakeDisplayName`),
106
+ `--twake-group-color-attribute` (default `twakeGroupColor`),
107
+ `--twake-group-created-at-attribute` (default `twakeCreatedAt`): the
108
+ group's attributes. The group classes (`--group-class`) must allow them.
109
+ The plugin adds them to the loaded group schema under these names, in any
110
+ case, so a renamed attribute needs no schema of its own.
111
+ - `--twake-group-max-page-limit` (default 1000): the largest page, and the
112
+ most users added in one request.
113
+ - `--twake-group-member-fields`: JSON of `{ field: attribute }` for the member
114
+ profile, over the defaults `uid`, `_id` (`entryUUID`), `cn`, `sn`,
115
+ `givenName`, `displayName`, `mail`, `mobile`, `jobTitle`
116
+ (`twakeJobTitle`), `company` (`twakeCompany`), `organizationRole`
117
+ (`twakeOrganizationRole`) and `organizationId` (`twakeOrganizationId`).
118
+ A profile also has `name` from `sn`, `givenName`, `twakeAdditionalName` and
119
+ `twakeNamePrefix`, and `isTechnical` from `twakeIsTechnical`.
120
+ - Tombstones are recognized by `--twake-lifecycle-deleted-attribute` and
121
+ `--twake-lifecycle-deleted-value`, as [tombstone](tombstone.md) writes them.
122
+
123
+ ## Events
124
+
125
+ [Lifecycle events](lifecycle-events.md) publishes a group's changes from the
126
+ directory writes, whichever API made them. A rule for the pattern above:
127
+
128
+ ```json
129
+ {
130
+ "dn": "^cn=(?<id>[^,]+),ou=groups,ou=(?<org>[^,]+),dc=example,dc=com$",
131
+ "exchange": "groups",
132
+ "events": {
133
+ "created": {
134
+ "routingKey": "group.created",
135
+ "payload": {
136
+ "organizationId": "$dn.org",
137
+ "id": "$dn.id",
138
+ "name": "$twakeDisplayName",
139
+ "description": "$description",
140
+ "color": "$twakeGroupColor",
141
+ "createdAt": "$twakeCreatedAt",
142
+ "members": { "$members": { "username": "$uid", "email": "$mail" } },
143
+ "timestamp": "$now"
144
+ }
145
+ },
146
+ "updated": {
147
+ "routingKey": "group.updated",
148
+ "payload": {
149
+ "organizationId": "$dn.org",
150
+ "id": "$dn.id",
151
+ "name": "$changed.twakeDisplayName",
152
+ "description": "$changed.description",
153
+ "color": "$changed.twakeGroupColor",
154
+ "timestamp": "$now"
155
+ }
156
+ },
157
+ "memberAdded": {
158
+ "routingKey": "group.member.added",
159
+ "payload": {
160
+ "organizationId": "$dn.org",
161
+ "id": "$dn.id",
162
+ "members": {
163
+ "$added": {
164
+ "username": "$uid",
165
+ "email": "$mail",
166
+ "firstName": "$givenName",
167
+ "lastName": "$sn"
168
+ }
169
+ },
170
+ "timestamp": "$now"
171
+ }
172
+ },
173
+ "memberRemoved": {
174
+ "routingKey": "group.member.removed",
175
+ "payload": {
176
+ "organizationId": "$dn.org",
177
+ "id": "$dn.id",
178
+ "members": { "$removed": { "username": "$uid", "email": "$mail" } },
179
+ "timestamp": "$now"
180
+ }
181
+ },
182
+ "deleted": {
183
+ "routingKey": "group.deleted",
184
+ "payload": {
185
+ "organizationId": "$dn.org",
186
+ "id": "$dn.id",
187
+ "timestamp": "$now"
188
+ }
189
+ }
190
+ }
191
+ }
192
+ ```
193
+
194
+ Erasing a member publishes no `memberRemoved` when the directory's `refint`
195
+ removes it from its groups, since that write does not go through LDAP-Rest.
196
+ Without `refint`, the [tombstone](tombstone.md) erase takes the member out
197
+ of the groups under `--twake-tombstone-group-bases` itself, through
198
+ LDAP-Rest: each of those groups then publishes a `memberRemoved` naming the
199
+ member by its RDN. That option defaults to the flat group base, which holds
200
+ no group here; point it at the organizations' group branches.
201
+
202
+ ## SCIM groups
203
+
204
+ SCIM groups take the organization's group branch through SCIM's per-request
205
+ group base (`--scim-group-base-header`, `--scim-base-header-root`), the same
206
+ classes through `--scim-group-object-class`, and the provider's `externalId`
207
+ through `--scim-group-external-id-attribute`. Map SCIM `displayName` to the
208
+ display name attribute in `--scim-group-mapping`: the name is kept there,
209
+ SCIM reads, updates and `displayName` filters go through it, and a SCIM group
210
+ of an organization gets a generated `cn` (a UUID), which is also its SCIM
211
+ `id`, and a creation date, as a group made by the routes does. The date is
212
+ added only to a group that holds the display name attribute, so classes that
213
+ allow the display name must allow the creation date too: the default
214
+ attributes both come with `twakeGroup`, but a display name kept in an
215
+ attribute `groupOfNames` allows, such as `description`, next to the default
216
+ `twakeCreatedAt` makes every SCIM create fail. Without that mapping, a SCIM
217
+ group's `cn` is its `displayName`, and the plugin logs a warning at startup. A group whose `cn` is already its `displayName` keeps
218
+ that `cn` and `id`.
219
+
220
+ ## Dependencies
221
+
222
+ ```
223
+ core/twake/groups
224
+ └─ extends: core/ldap/groups
225
+ ```
@@ -65,9 +65,37 @@ has written the organization entry. With the Cloudery it creates the
65
65
  Cloudery organization first. The organization entry, found under
66
66
  `--twake-instance-organization-base` by
67
67
  `--twake-instance-organization-id-attribute`, gets the address when
68
- `--twake-instance-organization-fqdn-attribute` names where, then
69
- `organization.created` is published on the b2b exchange, and the entry is
70
- marked. The mark, not the address, says the organization is done.
68
+ `--twake-instance-organization-fqdn-attribute` names where. With the
69
+ Cloudery the organization is then linked to its instance (`PATCH
70
+ /api/v2/organizations/<id>` with `instance_fqdn`), which the Cloudery needs
71
+ before it takes its members' `user.created`. Then `organization.created` is
72
+ published on the b2b exchange, and the entry is marked. The message carries
73
+ the organization's id, address, domain, and its name as `organization`, read
74
+ from `--twake-instance-organization-name-attribute`, which
75
+ `ensureOrganization` fills from `name` when the entry has none. The mark, not
76
+ the address, says the organization is done.
77
+
78
+ With the Cloudery, a member's `user.created` waits for its organization's: a
79
+ member under an organization entry that is not marked yet gets its address
80
+ but no event and no mark. A member outside its organization entry is not
81
+ held, since nothing would find it to release it: it is announced with a
82
+ warning, and needs sending again once the organization is linked. A stored
83
+ address the provider does not confirm keeps a member held, with a warning,
84
+ until `ensureInstance` sends it. Once the organization is
85
+ marked, every member under its entry with an address and no mark gets its
86
+ `user.created`, each on its own: one that fails is logged, the others are
87
+ still sent, and the message is redelivered. A stored address is sent only
88
+ if it is the one the provider gives the account, or once the provider
89
+ confirms it. Nothing of this is kept in memory: the organization's
90
+ `workplace.created` again, or `ensureOrganization` on a marked organization,
91
+ links the organization again and sends whatever is still held, from any
92
+ replica. That also links an organization marked before the link existed.
93
+
94
+ The Cloudery builds no member instance for an organization it does not
95
+ have. Before asking for a member's instance, the plugin asks for its
96
+ organization and the organization's instance when the Cloudery has neither,
97
+ so making sure of a member also repairs an organization whose creation
98
+ failed.
71
99
 
72
100
  ## Providers
73
101
 
@@ -1,11 +1,12 @@
1
1
  # Lifecycle Events
2
2
 
3
- `core/twake/lifecycleEvents` publishes an account's lifecycle to RabbitMQ from
4
- the directory write itself. REST, SCIM and any other plugin writing through
3
+ `core/twake/lifecycleEvents` publishes an account's or a group's lifecycle to
4
+ RabbitMQ from the directory write itself. REST, SCIM and any other plugin writing through
5
5
  LDAP-Rest announce the same events, with the same payloads.
6
6
 
7
- Which entries are accounts, which attributes carry the role, the lock and the
8
- deletion, and where each event goes are configuration.
7
+ Which entries are accounts or groups, which attributes carry the role, the
8
+ lock, the deletion and the members, and where each event goes are
9
+ configuration.
9
10
 
10
11
  ## Events
11
12
 
@@ -22,6 +23,14 @@ publishes nothing.
22
23
  role in a single-valued attribute.
23
24
  - `disabled`: the lock attribute takes the lock value.
24
25
  - `enabled`: the lock attribute no longer holds the lock value.
26
+ - `updated`: a target whose payload has `$changed.` sources publishes when one
27
+ of them changed, with those that did not left out. A target without them
28
+ publishes on every change of the entry.
29
+ - `memberAdded`, `memberRemoved`: the member attribute gains or loses values.
30
+ The old and new lists are compared as sets, so a whole list replaced
31
+ publishes only the members that came or went. The group placeholder
32
+ (`--group-dummy-user`) is never a member here. A group created with
33
+ members publishes `created` only, its members in `$members`.
25
34
  - `deleted`: the entry becomes a tombstone (the deleted attribute takes the
26
35
  deleted value), or an entry that is not a tombstone is removed.
27
36
 
@@ -102,6 +111,8 @@ operational one such as `pwdAccountLockedTime` is seen on both sides.
102
111
  - `--twake-lifecycle-deleted-at-attribute`,
103
112
  `--twake-lifecycle-deleted-at-format` (`iso8601`, the default, or
104
113
  `generalizedTime`): the deletion date. It is always published as ISO 8601.
114
+ - `--twake-lifecycle-member-attribute` (default `member`): the attribute
115
+ `memberAdded`, `memberRemoved` and member lists read.
105
116
  - `--twake-lifecycle-rules`: a JSON file, or the JSON itself.
106
117
 
107
118
  Each option has a `DM_` environment variable, for example
@@ -177,12 +188,34 @@ Payload and `when` values are sources:
177
188
  - `$context.actor`, `$context.requestId`, `$context.source`: who made the
178
189
  write, the request it belongs to, and the API it came through (`rest`,
179
190
  `scim`); left out for a write no request made, such as a scheduled task
191
+ - `$changed.attr`: the attribute after the write, only if the write changed
192
+ it; an attribute the write removed gives `""`
180
193
  - `$attr|domain`, `$previous.attr|domain`: what follows the `@`
181
194
  - `$now`: the current time, ISO 8601
182
195
  - anything else: the value itself
183
196
 
184
197
  `$attr` and `$previous.attr` carry the first value of a multi-valued
185
- attribute. A source with no value leaves its field out. Every message carries
198
+ attribute. A source with no value leaves its field out.
199
+
200
+ A payload field can also list members, as an object with one key:
201
+
202
+ - `$members`: the members after the write
203
+ - `$added`, `$removed`: the members `memberAdded` and `memberRemoved` are about
204
+
205
+ ```json
206
+ "members": { "$added": { "username": "$uid", "email": "$mail" } }
207
+ ```
208
+
209
+ Each member is read once, and shaped by the object it maps to, whose
210
+ sources are its own attributes (`$attr`) or plain values; any other source is
211
+ refused at startup. A tombstone is left
212
+ out, and a target whose `$added` or `$removed` is left empty publishes
213
+ nothing. A member no longer in the directory is known by its RDN alone:
214
+ `uid=jdoe,…` gives `{ "username": "jdoe" }` above. A group of 1,000 members
215
+ takes 1,000 reads, one after the other, off the path of the write's
216
+ response. A member that cannot be read, for a reason other than being gone,
217
+ drops that target, logged as an error: a member the service account may not
218
+ read silences every target of the group that lists its members. Every message carries
186
219
  a random AMQP `messageId`.
187
220
 
188
221
  A source can read any attribute of the entry: `$userPassword` would put the