ldap-rest 0.11.2 → 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 (56) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/bin/index.js +1 -1
  3. package/dist/config/args.js +1 -1
  4. package/dist/lib/authz/composition.js +1 -1
  5. package/dist/lib/errors.js +1 -1
  6. package/dist/lib/instanceProviders.js +1 -0
  7. package/dist/lib/ldapActions.js +1 -1
  8. package/dist/plugins/auth/openidconnect.js +1 -1
  9. package/dist/plugins/configApi.js +1 -1
  10. package/dist/plugins/ldap/groups.js +1 -1
  11. package/dist/plugins/ldap/trash.js +1 -1
  12. package/dist/plugins/scim/filter.js +1 -1
  13. package/dist/plugins/scim/groups.js +1 -1
  14. package/dist/plugins/scim/mapping.js +1 -1
  15. package/dist/plugins/scim/patch.js +1 -1
  16. package/dist/plugins/scim/users.js +1 -1
  17. package/dist/plugins/twake/groups.js +1 -0
  18. package/dist/plugins/twake/instances.js +1 -0
  19. package/dist/plugins/twake/lifecycleAttributes.js +1 -0
  20. package/dist/plugins/twake/lifecycleEvents.js +1 -0
  21. package/dist/plugins/twake/tombstone.js +1 -0
  22. package/dist/src/abstract/ldapFlat.d.ts +34 -6
  23. package/dist/src/abstract/plugin.d.ts +7 -4
  24. package/dist/src/config/args.d.ts +60 -0
  25. package/dist/src/hooks.d.ts +2 -0
  26. package/dist/src/lib/authz/composition.d.ts +1 -1
  27. package/dist/src/lib/errors.d.ts +3 -0
  28. package/dist/src/lib/instanceProviders.d.ts +88 -0
  29. package/dist/src/lib/ldapActions.d.ts +10 -2
  30. package/dist/src/plugins/auth/openidconnect.d.ts +39 -0
  31. package/dist/src/plugins/ldap/groups.d.ts +5 -0
  32. package/dist/src/plugins/scim/filter.d.ts +2 -1
  33. package/dist/src/plugins/scim/groups.d.ts +1 -0
  34. package/dist/src/plugins/scim/mapping.d.ts +22 -8
  35. package/dist/src/plugins/scim/patch.d.ts +9 -2
  36. package/dist/src/plugins/twake/groups.d.ts +89 -0
  37. package/dist/src/plugins/twake/instances.d.ts +102 -0
  38. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +29 -0
  39. package/dist/src/plugins/twake/lifecycleEvents.d.ts +62 -0
  40. package/dist/src/plugins/twake/tombstone.d.ts +86 -0
  41. package/docs/client-development/api/rest-api.md +34 -2
  42. package/docs/plugin-development/hooks.md +24 -0
  43. package/docs/usage/configuration.md +97 -1
  44. package/docs/usage/plugins/README.md +10 -6
  45. package/docs/usage/plugins/auth/oidc.md +46 -0
  46. package/docs/usage/plugins/integrations/README.md +12 -4
  47. package/docs/usage/plugins/integrations/groups.md +225 -0
  48. package/docs/usage/plugins/integrations/instances.md +150 -0
  49. package/docs/usage/plugins/integrations/lifecycle-events.md +233 -0
  50. package/docs/usage/plugins/integrations/scim.md +55 -34
  51. package/docs/usage/plugins/integrations/tombstone.md +107 -0
  52. package/docs/usage/plugins/ldap/flat-generic.md +38 -2
  53. package/docs/usage/plugins/ldap/trash.md +4 -4
  54. package/docs/usage/upgrading.md +114 -0
  55. package/package.json +25 -1
  56. package/static/schemas/twake/organizationGroups.json +32 -0
@@ -86,6 +86,11 @@ export default class LdapGroups extends DmPlugin {
86
86
  cn: string;
87
87
  schema?: Schema;
88
88
  constructor(server: DM);
89
+ /**
90
+ * The loaded group schema, before it is used: a subclass declares there
91
+ * the attributes it writes.
92
+ */
93
+ protected adaptSchema(schema: Schema): Schema;
89
94
  /**
90
95
  * Remove deleted entries from groups once the delete has landed: a delete
91
96
  * refused, failed or turned into something else by another plugin leaves
@@ -1,7 +1,8 @@
1
1
  import type { ResourceMapping } from './types';
2
2
  export interface FilterOptions {
3
- /** LDAP attribute whose presence marks a User as locked. */
3
+ /** LDAP attribute and value that mark a User as locked. */
4
4
  lockAttribute?: string;
5
+ lockValue?: string;
5
6
  /**
6
7
  * Whether this resource type carries SCIM `active`. Users do; Groups do
7
8
  * not, and must get `invalidFilter` rather than a filter built out of a
@@ -36,6 +36,7 @@ export declare class ScimGroups {
36
36
  private readonly users;
37
37
  private readonly hooks;
38
38
  private readonly mapping;
39
+ private readonly attributes;
39
40
  private readonly rdnAttribute;
40
41
  private readonly objectClass;
41
42
  private readonly idAttribute;
@@ -47,9 +47,9 @@ export interface MappingContext {
47
47
  baseUrl?: string;
48
48
  scimPrefix: string;
49
49
  /**
50
- * LDAP attribute whose *presence* marks the account as locked, and the
51
- * value written to lock it. Both come from `resolveLockConfig()`, which is
52
- * the only place the defaults live: re-defaulting here would let a context
50
+ * LDAP attribute, and the value in it that marks the account as locked and
51
+ * is written to lock it. Both come from `resolveLockConfig()`, which is the
52
+ * only place the defaults live: re-defaulting here would let a context
53
53
  * built outside the plugin reach the directory with a pair the startup
54
54
  * guard never saw. Groups have no `active`, so they pass empty strings.
55
55
  */
@@ -58,8 +58,8 @@ export interface MappingContext {
58
58
  }
59
59
  /**
60
60
  * SCIM `active` (RFC 7643 section 4.1.1) has no direct LDAP equivalent. We
61
- * model it on the presence of one attribute: present means locked, absent
62
- * means active.
61
+ * model it on one attribute holding one value: that value means locked,
62
+ * anything else, absence included, means active.
63
63
  *
64
64
  * The default is the ppolicy overlay's `pwdAccountLockedTime`, whose
65
65
  * conventional "locked forever" value is a GeneralizedTime in the distant
@@ -104,7 +104,10 @@ export declare function setLockConfigWarn(sink: (message: string) => void): (mes
104
104
  * told apart from a deliberate local schema, so they are warned about: what
105
105
  * the directory does with a value is beyond anything this can check.
106
106
  */
107
- export declare function resolveLockConfig(attribute: string, value: string): {
107
+ export declare function resolveLockConfig(attribute: string, value: string, options?: {
108
+ attribute: string;
109
+ value: string;
110
+ }): {
108
111
  attribute: string;
109
112
  value: string;
110
113
  };
@@ -119,8 +122,19 @@ export declare function resolveLockConfig(attribute: string, value: string): {
119
122
  * translator cannot drift apart.
120
123
  */
121
124
  export declare function readActive(value: unknown): boolean | undefined;
122
- /** Is this entry locked, per the configured lock attribute? */
123
- export declare function isLocked(entry: AttributesList, ctx: MappingContext): boolean;
125
+ /**
126
+ * Is this entry locked, per the configured lock pair? Only the lock value
127
+ * counts: a ppolicy lockout after failed binds is a timestamp in the same
128
+ * attribute, and it does not deactivate the account.
129
+ */
130
+ export declare function isLocked(entry: AttributesList, ctx: Pick<MappingContext, 'lockAttribute' | 'lockValue'>): boolean;
131
+ /**
132
+ * Does the entry hold this value in this attribute? Both compared
133
+ * case-insensitively, whatever the attribute's matching rule, and an
134
+ * attribute given as an empty array is absent. SCIM `active` and the twake
135
+ * lifecycle plugins read the same lock pair through it.
136
+ */
137
+ export declare function holdsValue(entry: AttributesList, attribute: string, value: string): boolean;
124
138
  /** Resolve the SCIM id for an LDAP entry, per configuration. */
125
139
  export declare function resolveScimId(entry: AttributesList, ctx: MappingContext): string | undefined;
126
140
  export declare function buildLocation(id: string, ctx: MappingContext): string | undefined;
@@ -28,8 +28,8 @@ export interface PatchContext {
28
28
  /** The LDAP attribute holding members, default 'member'. */
29
29
  memberAttribute?: string;
30
30
  /**
31
- * For Users: the attribute whose presence marks the account as locked, and
32
- * the value to write when SCIM `active` is set to false.
31
+ * For Users: the attribute and value that mark the account as locked;
32
+ * SCIM `active: false` writes that value.
33
33
  */
34
34
  lockAttribute?: string;
35
35
  lockValue?: string;
@@ -44,6 +44,13 @@ export interface PatchContext {
44
44
  current: AttributesList;
45
45
  }
46
46
  export declare function patchToModifyRequest(patch: PatchRequest, ctx: PatchContext): Promise<ModifyRequest>;
47
+ /**
48
+ * Write a PATCH, read and computed anew by `attempt` each time. A delete
49
+ * only names values the read showed, so noSuchAttribute means a concurrent
50
+ * write removed one of them: the PATCH is played once more on a fresh read,
51
+ * then refused with 409.
52
+ */
53
+ export declare function writePatch(dn: string, attempt: () => Promise<void>): Promise<void>;
47
54
  /**
48
55
  * Apply PATCH to a SCIM resource object in-memory (used for PUT-equivalent
49
56
  * or when the resource handler prefers object-level manipulation before
@@ -0,0 +1,89 @@
1
+ import type { Express } from 'express';
2
+ import type { DM } from '../../bin';
3
+ import type { Schema } from '../../config/schema';
4
+ import type { Hooks } from '../../hooks';
5
+ import type { AttributeValue } from '../../lib/ldapActions';
6
+ import LdapGroups from '../ldap/groups';
7
+ import { type LifecycleAttributes } from './lifecycleAttributes';
8
+ /** The values of an attribute, whatever shape the directory gave them. */
9
+ export declare function values(value: AttributeValue | undefined): string[];
10
+ export default class TwakeGroups extends LdapGroups {
11
+ name: string;
12
+ protected readonly groupBase: string;
13
+ protected readonly userBase: string;
14
+ protected readonly groupPattern: RegExp;
15
+ protected readonly attrs: LifecycleAttributes;
16
+ private readonly orgPattern;
17
+ private readonly displayName;
18
+ private readonly color;
19
+ private readonly createdAt;
20
+ private readonly maxPage;
21
+ private readonly memberFields;
22
+ constructor(server: DM);
23
+ /**
24
+ * Declare the group attributes under their configured names, whatever case
25
+ * the schema spells them in, or add them when it does not: the schema does
26
+ * not have to follow a renamed attribute.
27
+ */
28
+ protected adaptSchema(schema: Schema): Schema;
29
+ /**
30
+ * No `ldapdeletedone`: an erased member leaves its groups through the
31
+ * directory's referential integrity, whatever made the delete. The cleanup
32
+ * inherited from core/ldap/groups writes through LDAP-Rest, so every group
33
+ * would announce a member removed on an erase, which publishes nothing.
34
+ */
35
+ hooks: Hooks;
36
+ /**
37
+ * The id a `scimgroupcreate` hook assigns names the entry only when the
38
+ * SCIM group mapping leaves the RDN attribute alone.
39
+ */
40
+ afterLoad(): void;
41
+ /** The organization a group DN belongs to, if it is a group at all. */
42
+ organizationOf(dn: string): string | undefined;
43
+ groupBaseOf(org: string): string;
44
+ userBaseOf(org: string): string;
45
+ /**
46
+ * A group's members belong to its organization, as a provider is trusted
47
+ * within its own organization only. The placeholder belongs to none.
48
+ */
49
+ private checkMembers;
50
+ /**
51
+ * A tombstone keeps its memberships until it is erased, and is hidden from
52
+ * them, so every API reads the same member list.
53
+ */
54
+ private hideTombstones;
55
+ private tombstonesOf;
56
+ /**
57
+ * Only the organization routes: the flat `/v1/ldap/groups` of
58
+ * core/ldap/groups would read one base that holds no group here.
59
+ */
60
+ api(app: Express): void;
61
+ private groupDn;
62
+ private get userAttribute();
63
+ /** Every route answers 404 for a missing organization, 410 for a deleted one. */
64
+ private checkOrganization;
65
+ /** Page, search and sort of a list route, or a refusal of them. */
66
+ private page;
67
+ private sorted;
68
+ /** A group as the routes answer it: members named by their RDN value. */
69
+ private groupOf;
70
+ private groups;
71
+ private readGroup;
72
+ /** The group of that display name in the organization, if any. */
73
+ private named;
74
+ /** A write to a group that is not there answers 404. */
75
+ private onGroup;
76
+ private listRoute;
77
+ private createRoute;
78
+ private updateRoute;
79
+ private membersRoute;
80
+ /**
81
+ * Users of the organization by name, read in pages of the size limit. A
82
+ * tombstone is no user: it cannot be added, as it would join hidden.
83
+ */
84
+ private users;
85
+ private profiles;
86
+ private addMembersRoute;
87
+ private dropPlaceholder;
88
+ private removeMemberRoute;
89
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @module plugins/twake/instances
3
+ *
4
+ * Gives every account this server creates its workplace instance, once its
5
+ * entry is written, from the Cloudery or from the cozy-stack admin API. The
6
+ * address is written once the instance exists, then `user.created` is
7
+ * published and the entry marked. The event is sent at least once.
8
+ *
9
+ * See `docs/usage/plugins/integrations/instances.md`.
10
+ */
11
+ import type { Express, Request } from 'express';
12
+ import DmPlugin, { type Role } from '../../abstract/plugin';
13
+ import type { DM } from '../../bin';
14
+ import type { Hooks } from '../../hooks';
15
+ import { type InstanceProvider, type InstanceRequest } from '../../lib/instanceProviders';
16
+ import type { AttributesList } from '../../lib/ldapActions';
17
+ export interface WorkplaceCreated {
18
+ twakeId?: string;
19
+ internalEmail?: string;
20
+ workplaceFqdn?: string;
21
+ }
22
+ /** An account's instance request, and the id `user.created` names it by. */
23
+ interface Account extends InstanceRequest {
24
+ twakeId: string;
25
+ }
26
+ export default class TwakeInstances extends DmPlugin {
27
+ name: string;
28
+ roles: Role[];
29
+ dependencies: {
30
+ rabbitmq: string;
31
+ };
32
+ protected readonly patterns: RegExp[];
33
+ protected readonly provider: InstanceProvider;
34
+ protected readonly fqdnAttribute: string;
35
+ protected readonly sentAttribute: string;
36
+ private readonly locale;
37
+ private readonly lifecycle;
38
+ /** Work on one account, or organization, waits for the work before it */
39
+ private readonly queues;
40
+ constructor(server: DM);
41
+ hooks: Hooks;
42
+ /**
43
+ * Without a broker `user.created` could not be sent, and with the Cloudery
44
+ * no address would ever arrive: the server does not start.
45
+ */
46
+ api(app: Express): Promise<void>;
47
+ /**
48
+ * Make sure this account has its instance: covers a refused or lost
49
+ * creation, and a `workplace.created` that never arrived. With a request,
50
+ * the account is read and written with the caller's rights.
51
+ */
52
+ ensureInstance(dn: string, req?: Request): Promise<'ready' | 'pending'>;
53
+ /**
54
+ * Ask for an organization's own instance, which the Cloudery also needs as
55
+ * an organization. `organization.created` follows once it exists. For the
56
+ * application that creates organizations to call.
57
+ */
58
+ ensureOrganization(org: {
59
+ id: string;
60
+ name: string;
61
+ domain: string;
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;
71
+ onWorkplaceCreated(message: WorkplaceCreated): Promise<void>;
72
+ private settle;
73
+ /** Find the instance, or ask for it; write it down once it exists. */
74
+ private provide;
75
+ protected ready(dn: string, entry: AttributesList, account: Account, fqdn: string, req?: Request): Promise<void>;
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;
81
+ /**
82
+ * Run `work` once the work already queued for `key` is over. One replica
83
+ * only: across replicas, `user.created` may still be sent twice.
84
+ */
85
+ private serialized;
86
+ private get organizationId();
87
+ private get organizationDomain();
88
+ private get organizationName();
89
+ private get organizationFqdn();
90
+ private organizationMail;
91
+ private isOrganization;
92
+ private findOrganization;
93
+ private read;
94
+ protected announce(exchange: string, key: string, message: Record<string, unknown>): Promise<void>;
95
+ /** What to ask for this account, when it gets an instance at all. */
96
+ protected account(dn: string, entry: AttributesList): Account | undefined;
97
+ private match;
98
+ private id;
99
+ protected value(entry: AttributesList, attribute: string): unknown;
100
+ protected findByMail(email: string, req?: Request): Promise<AttributesList | undefined>;
101
+ }
102
+ export {};
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @module plugins/twake/lifecycleAttributes
3
+ *
4
+ * The attributes that carry an account's lifecycle (its role, its lock, its
5
+ * deletion), read from the `--twake-lifecycle-*` options: the ones
6
+ * `core/twake/lifecycleEvents` reads, and `core/twake/tombstone` writes.
7
+ */
8
+ import type { Config } from '../../bin';
9
+ import type { AttributesList, AttributeValue } from '../../lib/ldapActions';
10
+ export type DeletedAtFormat = 'iso8601' | 'generalizedTime';
11
+ export interface LifecycleAttributes {
12
+ role: string;
13
+ lock: string;
14
+ lockValue: string;
15
+ deleted: string;
16
+ deletedValue: string;
17
+ deletedAt: string;
18
+ deletedAtFormat: DeletedAtFormat;
19
+ reason: string;
20
+ }
21
+ export declare function lifecycleAttributes(config: Config): LifecycleAttributes;
22
+ /** An attribute of an entry, whatever case the caller spelled its name in. */
23
+ export declare function valueOf<T>(entry: Record<string, T>, attribute: string): T | undefined;
24
+ export declare function first(value: AttributeValue | null | undefined): string | undefined;
25
+ export declare const isTombstone: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
26
+ export declare const isLocked: (entry: AttributesList, attrs: LifecycleAttributes) => boolean;
27
+ export declare function formatDeletedAt(date: Date, format: DeletedAtFormat): string;
28
+ /** A deletion date as the directory holds it, or undefined if unreadable. */
29
+ export declare function parseDeletedAt(value: string, format: DeletedAtFormat): Date | undefined;
@@ -0,0 +1,62 @@
1
+ import DmPlugin, { type Role } from '../../abstract/plugin';
2
+ import type { DM } from '../../bin';
3
+ import type { Hooks } from '../../hooks';
4
+ export declare const LIFECYCLE_EVENTS: readonly ["created", "roleChanged", "disabled", "enabled", "updated", "memberAdded", "memberRemoved", "deleted"];
5
+ export type LifecycleEvent = (typeof LIFECYCLE_EVENTS)[number];
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>;
14
+ interface Target {
15
+ exchange: string;
16
+ routingKey: string;
17
+ payload: Payload;
18
+ when: Condition;
19
+ }
20
+ interface Rule {
21
+ dn: RegExp;
22
+ targets: Partial<Record<LifecycleEvent, Target[]>>;
23
+ }
24
+ export declare function parseRules(source: string): Rule[];
25
+ export default class TwakeLifecycleEvents extends DmPlugin {
26
+ name: string;
27
+ roles: Role[];
28
+ dependencies: {
29
+ onLdapChange: string;
30
+ rabbitmq: string;
31
+ };
32
+ private readonly rules;
33
+ private readonly attrs;
34
+ private readonly memberAttribute;
35
+ constructor(server: DM);
36
+ /**
37
+ * core/rabbitmq connects lazily and hands back no client when it cannot:
38
+ * every event would then be lost, so the server does not start.
39
+ */
40
+ assertComposition(): Promise<void>;
41
+ /**
42
+ * SCIM deactivates by writing its own lock: on another attribute or
43
+ * value, `disabled` and `enabled` never follow a SCIM `active` change.
44
+ */
45
+ afterLoad(): void;
46
+ hooks: Hooks;
47
+ private replay;
48
+ private match;
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;
59
+ private resolve;
60
+ private publish;
61
+ }
62
+ export {};
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @module plugins/twake/tombstone
3
+ *
4
+ * Turns the delete of an account into a tombstone: the entry stays, flagged
5
+ * as deleted, dated, locked, with the reason and without the attributes the
6
+ * deployment wants gone. The address it holds cannot be taken again until the
7
+ * tombstone is erased.
8
+ *
9
+ * SCIM treats a tombstone as gone. Erasing removes the entry and its group
10
+ * memberships, once the deletion is old enough or when forced, and announces
11
+ * nothing: the deletion already did.
12
+ *
13
+ * See `docs/usage/plugins/integrations/tombstone.md`.
14
+ */
15
+ import type { Express, Request } from 'express';
16
+ import DmPlugin, { type Role } from '../../abstract/plugin';
17
+ import type { DM } from '../../bin';
18
+ import type { Hooks } from '../../hooks';
19
+ export default class TwakeTombstone extends DmPlugin {
20
+ name: string;
21
+ roles: Role[];
22
+ private readonly attrs;
23
+ private readonly patterns;
24
+ private readonly defaultReason;
25
+ private readonly reasonHeader;
26
+ private readonly reasons;
27
+ private readonly clearAttributes;
28
+ private readonly eraseMinAge;
29
+ private readonly groupBases;
30
+ private readonly scimPrefix;
31
+ /** Deletes asked for through {@link tombstone}, with their reason. */
32
+ private readonly reasonFor;
33
+ /** Entries being erased: their delete must reach the directory. */
34
+ private readonly erasing;
35
+ constructor(server: DM);
36
+ /**
37
+ * Last in the delete and add chains: a plugin that may refuse the request
38
+ * judges it before a tombstone is written or erased.
39
+ */
40
+ afterLoad(): void;
41
+ hooks: Hooks;
42
+ api(app: Express): void;
43
+ /**
44
+ * Delete an entry as a tombstone recording this reason. Goes through the
45
+ * directory's delete, so every plugin judging deletes judges this one.
46
+ */
47
+ tombstone(dn: string, reason: string, req?: Request): Promise<void>;
48
+ /**
49
+ * Remove a tombstone and its group memberships. Refused while the deletion
50
+ * is younger than the configured age, unless forced.
51
+ */
52
+ erase(dn: string, { force, req }?: {
53
+ force?: boolean;
54
+ req?: Request;
55
+ }): Promise<void>;
56
+ /**
57
+ * core/ldap/trash moves a deleted entry away before this plugin, last in
58
+ * the delete chain, sees it: no tombstone would be written, and an erase
59
+ * would move the tombstone instead of removing it.
60
+ */
61
+ assertComposition(): void;
62
+ /**
63
+ * Erase a tombstone so a SCIM create can take its identity. The directory
64
+ * may still refuse the add; the tombstone and its memberships are then put
65
+ * back once the response is sent.
66
+ */
67
+ private replace;
68
+ /**
69
+ * Put back a tombstone the add did not replace, with its memberships. A
70
+ * group the erase left holding only the placeholder loses it again.
71
+ */
72
+ private restore;
73
+ private matches;
74
+ private isScim;
75
+ private checkReason;
76
+ private reasonOf;
77
+ private read;
78
+ private writeTombstone;
79
+ private groupsOf;
80
+ private leaveGroups;
81
+ /**
82
+ * core/ldap/groups and refint clean the same memberships once the delete
83
+ * lands, so each outcome of a race with them counts as done.
84
+ */
85
+ private leaveGroup;
86
+ }
@@ -244,6 +244,8 @@ curl -H "Accept: application/json" \
244
244
  - `features.ldapFlatGeneric.flatResources`: Array of flat LDAP resources (users, positions, etc.)
245
245
  - `features.ldapGroups`: Group management configuration (if enabled)
246
246
  - `features.ldapOrganizations`: Organization tree configuration (if enabled)
247
+ - `features.openidconnect.endpoints.logout`: the logout route, when OpenID Connect
248
+ guards the server ([details](../../usage/plugins/auth/oidc.md#logout))
247
249
 
248
250
  ---
249
251
 
@@ -536,6 +538,12 @@ Retrieve all users or filter by attributes.
536
538
  - `match` - Filter value (partial match with wildcards)
537
539
  - `attribute` - Attribute name to filter on
538
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.
539
547
 
540
548
  **Example Request (all users):**
541
549
 
@@ -762,6 +770,11 @@ Retrieve all groups or filter by name.
762
770
  - `match` - Filter groups by CN (supports LDAP filter format or simple string)
763
771
  - `attributes` - Comma-separated list of attributes to return
764
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
+
765
778
  **Example Request (all groups):**
766
779
 
767
780
  ```bash
@@ -1044,6 +1057,7 @@ curl -X DELETE \
1044
1057
  | 403 | Forbidden - Insufficient permissions |
1045
1058
  | 404 | Not Found - Resource doesn't exist |
1046
1059
  | 409 | Conflict - Resource already exists |
1060
+ | 422 | Unprocessable - see below |
1047
1061
  | 500 | Internal Server Error |
1048
1062
 
1049
1063
  ### Error Response Format
@@ -1138,6 +1152,18 @@ All errors return a JSON object with an error message:
1138
1152
  }
1139
1153
  ```
1140
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
+
1141
1167
  #### 500 Internal Server Error
1142
1168
 
1143
1169
  **LDAP Connection Error:**
@@ -1200,9 +1226,15 @@ Use the correct operation type:
1200
1226
  - **add**: Add values to multi-valued attributes (error if already exists)
1201
1227
  - **delete**: Remove attributes or specific values
1202
1228
 
1203
- ### Pagination
1229
+ ### Long Lists
1204
1230
 
1205
- 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.
1206
1238
 
1207
1239
  ### Schema Validation
1208
1240
 
@@ -13,10 +13,34 @@ Typescript definitions into [hooks.ts](./src/hooks.ts)
13
13
  - **ldapopts**: called before any ldapsearch to modify search options
14
14
  - **ldapsearchresult**: called after any ldapsearch to modify search result
15
15
  - **ldapaddrequest**: called before any ldapadd
16
+ - **ldapadddone**: launched once an ldapadd has written the entry, not awaited
17
+ - **ldapaddafter**: awaited once an ldapadd has written the entry, before it
18
+ answers, see [below](#ldapaddafter)
16
19
  - **ldapmodifyrequest**: called before any ldapmodify
17
20
  - **ldapdeleterequest**: called before any ldapdelete
18
21
  - **ldaprenamerequest**: called before any ldap rename/modifyDN operation
19
22
 
23
+ ### ldapaddafter
24
+
25
+ For what must follow an add within the same request, such as provisioning
26
+ the account the entry describes.
27
+
28
+ - Subscribers get `[dn, entry]` and the change context, as `ldapadddone`
29
+ does. They run one after the other, and `add()` answers once the last has
30
+ returned.
31
+ - An error is logged and goes no further: the add has succeeded, and the
32
+ next subscriber still runs. A subscriber that can fail owns its repair
33
+ path, and should be safe to run again on the same entry.
34
+ - It fires for every add, including those made without a request (trash,
35
+ storage, appAccountsConsistency, externalUsersInGroups), whose context is
36
+ empty. Some of these follow an API call all the same, so select entries by
37
+ their DN or attributes rather than by an empty context.
38
+ - The entry is the one submitted, after `ldapaddrequest`, not what the
39
+ directory holds: no operational attributes, and the password as given.
40
+ - `ldapadddone` and `onLdapEntryChange` fire whatever this hook does, and in
41
+ no set order with it: an add event may or may not include what a
42
+ subscriber here wrote.
43
+
20
44
  ## [onChange](../usage/plugins/ldap/on-change.md) hooks
21
45
 
22
46
  - **onLdapEntryChange**: called after any write, with the entry before and