ldap-rest 0.6.2 → 0.7.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.
@@ -16,11 +16,10 @@
16
16
  * `tooMany` RFC 7644 §3.12 provides for a filter yielding "many more results
17
17
  * than the server is willing to calculate or process".
18
18
  */
19
- import type ldapActions from '../../lib/ldapActions';
20
- import type { AttributesList } from '../../lib/ldapActions';
21
- import type { DmRequest } from '../../lib/auth/base';
19
+ import type { AttributesList, RequestBoundLdap } from '../../lib/ldapActions';
22
20
  export interface PagedSearch {
23
- ldap: ldapActions;
21
+ /** The directory bound to the request, so authorization always applies. */
22
+ directory: RequestBoundLdap;
24
23
  base: string;
25
24
  filter: string;
26
25
  attributes: string[];
@@ -30,7 +29,6 @@ export interface PagedSearch {
30
29
  count: number;
31
30
  /** Upper bound on entries walked before answering `tooMany`. */
32
31
  maxScanned: number;
33
- req?: DmRequest;
34
32
  }
35
33
  export interface PagedResult {
36
34
  entries: AttributesList[];
@@ -1,7 +1,22 @@
1
1
  import type { AttributesList } from '../../lib/ldapActions';
2
2
  import { type ScimUser, type ScimGroup, type ResourceMapping, type MultiValued } from './types';
3
3
  export declare const DEFAULT_USER_MAPPING: ResourceMapping;
4
+ /**
5
+ * Group mapping.
6
+ *
7
+ * `externalId` is deliberately absent: RFC 7643 section 3.1 defines it as the
8
+ * *provisioning client's* identifier, so it cannot be served from a
9
+ * server-assigned value. groupOfNames has no attribute meant to hold one
10
+ * either, so the deployment names one with
11
+ * `--scim-group-external-id-attribute` and the entry is added at runtime by
12
+ * `withExternalId()`.
13
+ */
4
14
  export declare const DEFAULT_GROUP_MAPPING: ResourceMapping;
15
+ /**
16
+ * Add the `externalId` entry when the deployment named an attribute to store
17
+ * it in. An entry already present — from a mapping override — wins.
18
+ */
19
+ export declare function withExternalId(mapping: ResourceMapping, ldapAttribute: string): ResourceMapping;
5
20
  /**
6
21
  * LDAP GeneralizedTime (RFC 4517 §3.3.13) → SCIM DateTime (RFC 7643 §2.3.5,
7
22
  * an `xsd:dateTime`).
@@ -31,7 +46,81 @@ export interface MappingContext {
31
46
  resourceType: 'User' | 'Group';
32
47
  baseUrl?: string;
33
48
  scimPrefix: string;
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
53
+ * built outside the plugin reach the directory with a pair the startup
54
+ * guard never saw. Groups have no `active`, so they pass empty strings.
55
+ */
56
+ lockAttribute: string;
57
+ lockValue: string;
34
58
  }
59
+ /**
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.
63
+ *
64
+ * The default is the ppolicy overlay's `pwdAccountLockedTime`, whose
65
+ * conventional "locked forever" value is a GeneralizedTime in the distant
66
+ * past. A directory without that overlay points
67
+ * `--scim-user-lock-attribute` / `--scim-user-lock-value` somewhere else
68
+ * (`nsAccountLock` / `TRUE`, for instance).
69
+ */
70
+ export declare const DEFAULT_LOCK_ATTRIBUTE = "pwdAccountLockedTime";
71
+ export declare const DEFAULT_LOCK_VALUE = "000001010000Z";
72
+ /**
73
+ * Where the configuration warnings go. A seam rather than a direct
74
+ * `console.warn` so the unit tests can read what was said instead of
75
+ * asserting on stderr.
76
+ */
77
+ export declare let warn: (message: string) => void;
78
+ /** Testing seam: replace the warning sink, and get the previous one back. */
79
+ export declare function setLockConfigWarn(sink: (message: string) => void): (message: string) => void;
80
+ /**
81
+ * Settle the lock configuration, or refuse to start.
82
+ *
83
+ * Ways to get a deployment that answers 200 to every deactivation while the
84
+ * directory locks nothing:
85
+ *
86
+ * - naming an attribute without its value. `000001010000Z` is the ppolicy
87
+ * convention and means nothing to `nsAccountLock`, which 389-ds reads as
88
+ * locked only when it says `true` — so every deactivation would be
89
+ * written, read back as `active: false`, and the account would keep
90
+ * binding.
91
+ * - naming an attribute and pinning that same ppolicy value on it, which a
92
+ * deployment template that always sets both flags does by default. The
93
+ * guard above sees a value and passes; the outcome is identical.
94
+ * - the mirror: a value the default attribute cannot hold.
95
+ * `pwdAccountLockedTime` is a GeneralizedTime, so `TRUE` is refused by the
96
+ * directory's schema — or, where the attribute was redefined locally,
97
+ * stored and ignored.
98
+ * - a name that is not an attribute description. It is interpolated into the
99
+ * emitted LDAP filter for `active eq …`, where a metacharacter makes
100
+ * every list either malformed or quietly wrong.
101
+ *
102
+ * All are operator mistakes rather than attacks, and all are silent. The
103
+ * first and the last are refused outright. The two in the middle cannot be
104
+ * told apart from a deliberate local schema, so they are warned about: what
105
+ * the directory does with a value is beyond anything this can check.
106
+ */
107
+ export declare function resolveLockConfig(attribute: string, value: string): {
108
+ attribute: string;
109
+ value: string;
110
+ };
111
+ /**
112
+ * Read a SCIM `active` value, wherever it came from.
113
+ *
114
+ * `undefined` means the body did not mention it — which is not the same as
115
+ * `true`. Only a boolean, or the two canonical strings some providers send
116
+ * instead, are accepted: guessing at `"0"`, `null` or `1` would let a
117
+ * deprovisioning request be executed as a re-activation, and answer 200
118
+ * doing it. The one rule lives here so POST, PUT, PATCH and the filter
119
+ * translator cannot drift apart.
120
+ */
121
+ 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;
35
124
  /** Resolve the SCIM id for an LDAP entry, per configuration. */
36
125
  export declare function resolveScimId(entry: AttributesList, ctx: MappingContext): string | undefined;
37
126
  export declare function buildLocation(id: string, ctx: MappingContext): string | undefined;
@@ -44,6 +133,7 @@ export declare function ldapToScimUser(entry: AttributesList, mapping: ResourceM
44
133
  export declare function scimUserToLdap(user: ScimUser, mapping: ResourceMapping, ctx: MappingContext, objectClass: string[]): {
45
134
  rdn: string;
46
135
  attributes: AttributesList;
136
+ active?: boolean;
47
137
  };
48
138
  export declare function ldapToScimGroup(entry: AttributesList, mapping: ResourceMapping, ctx: MappingContext, memberResolver?: (dn: string) => MultiValued | undefined): ScimGroup;
49
139
  /**
@@ -58,15 +148,6 @@ export declare function scimGroupToLdap(group: ScimGroup, mapping: ResourceMappi
58
148
  * List of LDAP attributes to request from the directory so all
59
149
  * mapped SCIM attributes can be populated.
60
150
  */
61
- /**
62
- * The ppolicy overlay's lock marker, whose presence means `active: false`.
63
- *
64
- * Operational, so a search that does not name it never answers it: the read
65
- * path has to ask for it explicitly.
66
- */
67
- export declare const LOCK_ATTRIBUTE = "pwdAccountLockedTime";
68
- /** Is this entry locked, per the ppolicy lock marker? */
69
- export declare function isLocked(entry: AttributesList): boolean;
70
151
  export declare function requiredLdapAttributes(mapping: ResourceMapping, extra?: string[]): string[];
71
152
  /**
72
153
  * Given a SCIM attribute path like "emails.value" or "name.familyName",
@@ -19,7 +19,7 @@
19
19
  * member operations for Groups are handled via the caller-provided
20
20
  * `resolveMemberRef` hook (SCIM id → LDAP DN lookup).
21
21
  */
22
- import type { ModifyRequest } from '../../lib/ldapActions';
22
+ import type { AttributesList, ModifyRequest } from '../../lib/ldapActions';
23
23
  import { type ResourceMapping, type PatchRequest } from './types';
24
24
  export interface PatchContext {
25
25
  mapping: ResourceMapping;
@@ -27,6 +27,21 @@ export interface PatchContext {
27
27
  resolveMemberRef?: (value: string) => Promise<string | undefined>;
28
28
  /** The LDAP attribute holding members, default 'member'. */
29
29
  memberAttribute?: string;
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.
33
+ */
34
+ lockAttribute?: string;
35
+ lockValue?: string;
36
+ /** True when this resource type carries SCIM `active` (Users only). */
37
+ supportsActive?: boolean;
38
+ /**
39
+ * The entry as it stands. RFC 7644 section 3.5.2 applies operations in
40
+ * order, so they are played against this and only the resulting difference
41
+ * is sent — without it, two operations touching one attribute would
42
+ * collapse into whichever the emitter happened to keep.
43
+ */
44
+ current: AttributesList;
30
45
  }
31
46
  export declare function patchToModifyRequest(patch: PatchRequest, ctx: PatchContext): Promise<ModifyRequest>;
32
47
  /**
@@ -0,0 +1,15 @@
1
+ export interface ProjectionQuery {
2
+ attributes?: string[];
3
+ excludedAttributes?: string[];
4
+ }
5
+ /**
6
+ * Raise when both parameters are given: RFC 7644 §3.9 makes them mutually
7
+ * exclusive, and honouring one silently would hide the mistake.
8
+ */
9
+ export declare function assertProjection(query: ProjectionQuery): void;
10
+ /** Apply `attributes` / `excludedAttributes` to one resource. */
11
+ export declare function projectResource<T extends Record<string, unknown>>(resource: T, query: ProjectionQuery): T;
12
+ /** Apply the projection to every resource of a ListResponse. */
13
+ export declare function projectList<T extends Record<string, unknown>, L extends {
14
+ Resources: T[];
15
+ }>(list: L, query: ProjectionQuery): L;
@@ -419,6 +419,11 @@ export default class Scim extends DmPlugin {
419
419
  * `--scim-base-url` to make it absolute and trustworthy too.
420
420
  */
421
421
  private scimResource;
422
+ /**
423
+ * `attributes` / `excludedAttributes` (RFC 7644 section 3.9). Accepted on
424
+ * every route that answers a resource, not only the list ones.
425
+ */
426
+ private parseProjection;
422
427
  private readScimBody;
423
428
  api(app: Express): void;
424
429
  private parseListQuery;
@@ -41,14 +41,35 @@ export declare class ScimUsers {
41
41
  private readonly maxResults;
42
42
  private readonly maxScanned;
43
43
  private readonly scimPrefix;
44
+ private readonly lockAttribute;
45
+ private readonly lockValue;
44
46
  constructor(opts: ScimUsersOptions);
45
47
  private ctx;
48
+ /**
49
+ * LDAP attributes to fetch. The lock attribute backing SCIM `active` is
50
+ * operational on most directories, so it is never returned unless asked
51
+ * for by name.
52
+ */
53
+ private ldapAttributes;
46
54
  private dnForId;
47
55
  get(req: DmRequest, id: string): Promise<ScimUser>;
48
56
  list(req: DmRequest, query: ListQuery): Promise<ListResponse<ScimUser>>;
49
57
  create(req: DmRequest, resource: ScimUser): Promise<ScimUser>;
50
58
  replace(req: DmRequest, id: string, resource: ScimUser): Promise<ScimUser>;
51
59
  patch(req: DmRequest, id: string, patch: PatchRequest): Promise<ScimUser>;
60
+ /**
61
+ * Re-raise a directory refusal that names the schema, pointing at the flag
62
+ * that caused it.
63
+ *
64
+ * The generic translation in `errors` cannot do this: it sees the error,
65
+ * not the configuration. Only this class knows which attribute was
66
+ * configured to back `active`, and the shipped default —
67
+ * `pwdAccountLockedTime` — exists only where slapd loads the ppolicy
68
+ * overlay, which is the common way to meet this.
69
+ */
70
+ private lockSchemaError;
71
+ /** Fetch the raw LDAP entry backing a SCIM id, or raise 404. */
72
+ private currentEntry;
52
73
  delete(req: DmRequest, id: string): Promise<void>;
53
74
  /**
54
75
  * Given a SCIM member value (typically an id), return the LDAP DN.
package/docs/README.md CHANGED
@@ -10,6 +10,7 @@ Installation, configuration and plugin usage.
10
10
 
11
11
  - **[Getting Started](usage/README.md)** - Quick start guide
12
12
  - **[Configuration](usage/configuration.md)** - CLI options and environment variables
13
+ - **[Upgrading](usage/upgrading.md)** - What to check before deploying a new version
13
14
  - **[Plugins](usage/plugins/README.md)** - Documentation for all available plugins
14
15
 
15
16
  ### [Plugin Development](plugin-development/README.md)
@@ -455,6 +455,28 @@ export default class MyPlugin extends DmPlugin {
455
455
 
456
456
  The `this.server.ldap` object provides access to all LDAP operations:
457
457
 
458
+ > **Serving an HTTP request? Bind it first.**
459
+ >
460
+ > The authorization plugins (`core/auth/authzPerBranch`,
461
+ > `core/auth/authzDynamic`, …) hook `ldapsearchrequest`, `ldapaddrequest`,
462
+ > `ldapmodifyrequest`, `ldaprenamerequest` and `ldapdeleterequest`, and they
463
+ > **skip every check when the request is missing**. The methods below take it as a trailing
464
+ > optional argument, so forgetting it does not fail — it silently authorizes
465
+ > the operation. Three such bugs shipped in the SCIM plugin.
466
+ >
467
+ > In a request handler, bind once and use the result:
468
+ >
469
+ > ```typescript
470
+ > const directory = this.server.ldap.forRequest(req);
471
+ > await directory.search({ scope: 'base' }, dn);
472
+ > await directory.modify(dn, changes);
473
+ > ```
474
+ >
475
+ > `forRequest()` returns a `RequestBoundLdap`, whose methods have no request
476
+ > parameter at all — the omission stops being expressible. Use the unbound
477
+ > methods only for work that genuinely belongs to no request, such as startup
478
+ > or scheduled tasks, where passing nothing is the deliberate answer.
479
+
458
480
  #### Search
459
481
 
460
482
  ```typescript
@@ -86,4 +86,5 @@ The system will:
86
86
 
87
87
  - **[Configuration](configuration.md)** - CLI options and environment variables
88
88
  - **[Plugins](plugins/README.md)** - Choose and configure plugins
89
+ - **[Upgrading](upgrading.md)** - What to check before deploying a new version
89
90
  - **[Troubleshooting](troubleshooting.md)** - Problem resolution
@@ -41,27 +41,30 @@ All SCIM endpoints sit behind the auth middleware registered before the plugin
41
41
 
42
42
  ### CLI / environment
43
43
 
44
- | Argument | Environment | Default | Description |
45
- | ------------------------------ | ------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
46
- | `--scim-prefix` | `DM_SCIM_PREFIX` | `/scim/v2` | Base URL path for all SCIM endpoints |
47
- | `--scim-user-base` | `DM_SCIM_USER_BASE` | `{ldap_base}` | LDAP branch containing users |
48
- | `--scim-group-base` | `DM_SCIM_GROUP_BASE` | `{ldap_base}` | LDAP branch containing groups |
49
- | `--scim-user-base-template` | `DM_SCIM_USER_BASE_TEMPLATE` | — | DN template with `{user}` placeholder (see _multi-tenant_) |
50
- | `--scim-group-base-template` | `DM_SCIM_GROUP_BASE_TEMPLATE` | — | Same for groups |
51
- | `--scim-base-map` | `DM_SCIM_BASE_MAP` | — | Path to JSON file mapping authenticated user → `{userBase, groupBase}` |
52
- | `--scim-user-object-class` | `DM_SCIM_USER_OBJECT_CLASSES` | `top, inetOrgPerson, organizationalPerson, person` | Object classes for created Users |
53
- | `--scim-user-rdn-attribute` | `DM_SCIM_USER_RDN_ATTRIBUTE` | `uid` | RDN attribute for Users |
54
- | `--scim-group-object-class` | `DM_SCIM_GROUP_OBJECT_CLASSES` | `top, groupOfNames` | Object classes for created Groups |
55
- | `--scim-group-rdn-attribute` | `DM_SCIM_GROUP_RDN_ATTRIBUTE` | `cn` | RDN attribute for Groups |
56
- | `--scim-id-attribute` | `DM_SCIM_ID_ATTRIBUTE` | `rdn` | `rdn` (default) or `entryUUID` for SCIM `id` |
57
- | `--scim-user-mapping` | `DM_SCIM_USER_MAPPING` | — | Path to JSON mapping override |
58
- | `--scim-group-mapping` | `DM_SCIM_GROUP_MAPPING` | — | Same for Groups |
59
- | `--scim-max-results` | `DM_SCIM_MAX_RESULTS` | `200` | Maximum page size (caps `count`) |
60
- | `--scim-max-scanned` | `DM_SCIM_MAX_SCANNED` | `10000` | Entries a list may walk before answering `400 tooMany` |
61
- | `--scim-bulk-max-operations` | `DM_SCIM_BULK_MAX_OPERATIONS` | `100` | Max `/Bulk` operations per request |
62
- | `--scim-bulk-max-payload-size` | `DM_SCIM_BULK_MAX_PAYLOAD_SIZE` | `1048576` | Max `/Bulk` payload size in bytes |
63
- | `--scim-etag` | `DM_SCIM_ETAG` | `false` | Advertise ETag support in discovery (not yet implemented) |
64
- | `--scim-base-url` | `DM_SCIM_BASE_URL` | auto from request | Override external base URL for `meta.location` values |
44
+ | Argument | Environment | Default | Description |
45
+ | ------------------------------------ | ------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
46
+ | `--scim-prefix` | `DM_SCIM_PREFIX` | `/scim/v2` | Base URL path for all SCIM endpoints |
47
+ | `--scim-user-base` | `DM_SCIM_USER_BASE` | `{ldap_base}` | LDAP branch containing users |
48
+ | `--scim-group-base` | `DM_SCIM_GROUP_BASE` | `{ldap_base}` | LDAP branch containing groups |
49
+ | `--scim-user-base-template` | `DM_SCIM_USER_BASE_TEMPLATE` | — | DN template with `{user}` placeholder (see _multi-tenant_) |
50
+ | `--scim-group-base-template` | `DM_SCIM_GROUP_BASE_TEMPLATE` | — | Same for groups |
51
+ | `--scim-base-map` | `DM_SCIM_BASE_MAP` | — | Path to JSON file mapping authenticated user → `{userBase, groupBase}` |
52
+ | `--scim-user-object-class` | `DM_SCIM_USER_OBJECT_CLASSES` | `top, inetOrgPerson, organizationalPerson, person` | Object classes for created Users |
53
+ | `--scim-user-rdn-attribute` | `DM_SCIM_USER_RDN_ATTRIBUTE` | `uid` | RDN attribute for Users |
54
+ | `--scim-user-lock-attribute` | `DM_SCIM_USER_LOCK_ATTRIBUTE` | `pwdAccountLockedTime` | LDAP attribute whose presence marks a User as `active: false` |
55
+ | `--scim-user-lock-value` | `DM_SCIM_USER_LOCK_VALUE` | — | Value written to that attribute when deactivating (required unless the attribute is the default) |
56
+ | `--scim-group-object-class` | `DM_SCIM_GROUP_OBJECT_CLASSES` | `top, groupOfNames` | Object classes for created Groups |
57
+ | `--scim-group-rdn-attribute` | `DM_SCIM_GROUP_RDN_ATTRIBUTE` | `cn` | RDN attribute for Groups |
58
+ | `--scim-group-external-id-attribute` | `DM_SCIM_GROUP_EXTERNAL_ID_ATTRIBUTE` | — | LDAP attribute storing a Group's `externalId` (unset: not supported) |
59
+ | `--scim-id-attribute` | `DM_SCIM_ID_ATTRIBUTE` | `rdn` | `rdn` (default) or `entryUUID` for SCIM `id` |
60
+ | `--scim-user-mapping` | `DM_SCIM_USER_MAPPING` | — | Path to JSON mapping override |
61
+ | `--scim-group-mapping` | `DM_SCIM_GROUP_MAPPING` | — | Same for Groups |
62
+ | `--scim-max-results` | `DM_SCIM_MAX_RESULTS` | `200` | Maximum page size (caps `count`) |
63
+ | `--scim-max-scanned` | `DM_SCIM_MAX_SCANNED` | `10000` | Entries a list may walk before answering `400 tooMany` |
64
+ | `--scim-bulk-max-operations` | `DM_SCIM_BULK_MAX_OPERATIONS` | `100` | Max `/Bulk` operations per request |
65
+ | `--scim-bulk-max-payload-size` | `DM_SCIM_BULK_MAX_PAYLOAD_SIZE` | `1048576` | Max `/Bulk` payload size in bytes |
66
+ | `--scim-etag` | `DM_SCIM_ETAG` | `false` | Advertise ETag support in discovery (not yet implemented) |
67
+ | `--scim-base-url` | `DM_SCIM_BASE_URL` | auto from request | Override external base URL for `meta.location` values |
65
68
 
66
69
  ### Authorization
67
70
 
@@ -110,27 +113,91 @@ All responses have `Content-Type: application/scim+json`.
110
113
 
111
114
  ### Default mapping (LDAP ⇄ SCIM User)
112
115
 
113
- | SCIM | LDAP |
114
- | ------------------------------------ | ----------------------------------------------- |
115
- | `id` | `uid` (or `entryUUID`) |
116
- | `userName` | `uid` |
117
- | `externalId` | `employeeNumber` |
118
- | `name.familyName` | `sn` |
119
- | `name.givenName` | `givenName` |
120
- | `name.formatted` | `cn` |
121
- | `displayName` | `displayName` |
122
- | `title` | `title` |
123
- | `preferredLanguage` | `preferredLanguage` |
124
- | `emails[primary=true].value` | `mail` |
125
- | `emails[primary=false].value` | `mailAlternateAddress` |
126
- | `phoneNumbers[primary=true].value` | `telephoneNumber` |
127
- | `phoneNumbers[primary=false].value` | `mobile` |
128
- | `active` | pseudo (false if `pwdAccountLockedTime` is set) |
129
- | `meta.created` / `meta.lastModified` | `createTimestamp` / `modifyTimestamp` |
116
+ | SCIM | LDAP |
117
+ | ------------------------------------ | ------------------------------------------- |
118
+ | `id` | `uid` (or `entryUUID`) |
119
+ | `userName` | `uid` |
120
+ | `externalId` | `employeeNumber` |
121
+ | `name.familyName` | `sn` |
122
+ | `name.givenName` | `givenName` |
123
+ | `name.formatted` | `cn` |
124
+ | `displayName` | `displayName` |
125
+ | `title` | `title` |
126
+ | `preferredLanguage` | `preferredLanguage` |
127
+ | `emails[primary=true].value` | `mail` |
128
+ | `emails[primary=false].value` | `mailAlternateAddress` |
129
+ | `phoneNumbers[primary=true].value` | `telephoneNumber` |
130
+ | `phoneNumbers[primary=false].value` | `mobile` |
131
+ | `active` | pseudo (false if the lock attribute is set) |
132
+ | `meta.created` / `meta.lastModified` | `createTimestamp` / `modifyTimestamp` |
130
133
 
131
134
  Override with `--scim-user-mapping /path/to/user-mapping.json` (same schema as
132
135
  `static/schemas/scim/default-mapping.json`).
133
136
 
137
+ ## Deactivating an account (`active`)
138
+
139
+ SCIM models account status with the boolean `active` (RFC 7643 §4.1.1), and it
140
+ is how Okta, Entra ID and JumpCloud disable a user — they PATCH `active` to
141
+ `false` rather than DELETE the resource.
142
+
143
+ LDAP has no single equivalent, so the plugin models it on the **presence of one
144
+ attribute**: present means locked, absent means active.
145
+
146
+ | SCIM | LDAP |
147
+ | ----------------- | ----------------------------------------------- |
148
+ | `"active": false` | write `--scim-user-lock-value` to the attribute |
149
+ | `"active": true` | remove the attribute |
150
+ | attribute absent | `"active": true` |
151
+
152
+ The default targets the ppolicy overlay:
153
+
154
+ ```bash
155
+ --scim-user-lock-attribute pwdAccountLockedTime
156
+ --scim-user-lock-value 000001010000Z # "locked forever"
157
+ ```
158
+
159
+ `pwdAccountLockedTime` only exists when slapd loads the ppolicy overlay. On a
160
+ directory without it — plain OpenLDAP, 389-ds, AD — the directory refuses the
161
+ write against its schema, and the plugin answers `400 invalidValue` naming
162
+ the attribute and the flags to change, on `/Bulk` as on the single-resource
163
+ routes. Point the two flags elsewhere:
164
+
165
+ ```bash
166
+ --scim-user-lock-attribute nsAccountLock
167
+ --scim-user-lock-value TRUE
168
+ ```
169
+
170
+ **Name the value whenever you name the attribute.** `000001010000Z` is the
171
+ ppolicy convention and means nothing to any other attribute: 389-ds reads
172
+ `nsAccountLock` as locked only when it says `true`. Setting the attribute
173
+ alone would write a value the directory ignores — every deactivation answers
174
+ 200 and reads back `active: false` while the account keeps binding. The
175
+ plugin refuses to start instead.
176
+
177
+ Two more pairings get a warning at startup rather than a refusal, because
178
+ neither can be told apart from a deliberate local schema:
179
+
180
+ - the ppolicy value `000001010000Z` pinned on some other attribute, which is
181
+ what a deployment template that always sets both flags produces by default
182
+ - a value `pwdAccountLockedTime` cannot hold, `TRUE` for instance: it holds a
183
+ GeneralizedTime
184
+
185
+ **None of this can tell whether your directory honours the value you chose.**
186
+ The checks are on the shape of the configuration. `active` is read back from
187
+ the mere presence of the attribute, so a value the directory stores and
188
+ ignores still reads as `false`. Verify once, by hand, that a deactivation
189
+ actually prevents a bind.
190
+
191
+ `active` is not a mapping entry and must not be added to a mapping file: it is
192
+ handled by these two flags on every path — create, PUT, PATCH and the
193
+ `active eq true|false` filter alike.
194
+
195
+ PUT is a full replacement (RFC 7644 §3.5.1) for every attribute except this
196
+ one: a body that omits `active` leaves the lock as it stands. Clearing it
197
+ would release locks SCIM never set — a ppolicy auto-lockout, or one an
198
+ administrator placed — so a routine profile sync would defeat the
199
+ brute-force control. Send `"active": true` to reactivate deliberately.
200
+
134
201
  ## Writing a custom mapping schema
135
202
 
136
203
  The mapping file is a plain JSON document describing how each SCIM attribute is
@@ -247,6 +314,23 @@ adjust the loader). Exposing a schema there is **independent** from the
247
314
  LDAP↔SCIM projection: the schema file describes the public SCIM contract, the
248
315
  mapping file describes the internal translation.
249
316
 
317
+ ### Group `externalId`
318
+
319
+ RFC 7643 §3.1 defines `externalId` as the **provisioning client's** identifier —
320
+ the id Okta or Entra ID keeps for its own copy of the resource, which it later
321
+ uses to find the group again (`filter=externalId eq "…"`).
322
+
323
+ For Users it lands in `employeeNumber`. groupOfNames has no attribute meant to
324
+ hold one, so nothing is stored unless the deployment names an attribute:
325
+
326
+ ```bash
327
+ --scim-group-external-id-attribute description
328
+ ```
329
+
330
+ Without it, `externalId` sent on a Group is ignored and a filter on it answers
331
+ `400 invalidFilter` — which is at least honest. Any free attribute of your
332
+ group entries works; use a mapping override if you would rather name it there.
333
+
250
334
  ## Filter syntax
251
335
 
252
336
  SCIM filters are translated to LDAP filters (RFC 4515). Values are always escaped
@@ -259,6 +343,7 @@ via `escapeLdapFilter()` — no LDAP injection is possible.
259
343
  | `emails.value co "@example.com"` | `(mail=*@example.com*)` |
260
344
  | `displayName pr` | `(displayName=*)` |
261
345
  | `active eq true` | `(!(pwdAccountLockedTime=*))` |
346
+ | `active pr` | `(objectClass=*)` |
262
347
  | `a eq "x" and b eq "y"` | `(&(a=x)(b=y))` |
263
348
  | `not (a eq "x")` | `(!(a=x))` |
264
349
 
@@ -297,6 +382,30 @@ willing to calculate or process". Raise the flag, or narrow the query.
297
382
  If the directory's own size limit is lower than the collection, it refuses the
298
383
  search first and the answer is the same `tooMany` — raise `sizelimit` in slapd.
299
384
 
385
+ ## Partial representations (`attributes` / `excludedAttributes`)
386
+
387
+ Any route that answers a resource accepts the two RFC 7644 §3.9 parameters —
388
+ the lists, the single-resource GETs, and the POST/PUT/PATCH answers alike.
389
+
390
+ ```bash
391
+ curl '.../Users/alice?attributes=userName,name.familyName'
392
+ curl '.../Users?excludedAttributes=emails'
393
+ ```
394
+
395
+ - `attributes` replaces the default set: only what you name comes back.
396
+ - `excludedAttributes` keeps the default set minus what you name.
397
+ - Both accept standard attribute notation, so `name.familyName` and
398
+ `emails.value` narrow a complex or multi-valued attribute rather than
399
+ dropping it whole, and a core schema URN prefix is accepted and stripped.
400
+ - `id` and `schemas` are `returned: always` (RFC 7643 §7): they survive an
401
+ exclusion and are added back to an `attributes` list.
402
+ - `meta` is `returned: default`, so an `attributes` list that does not name it
403
+ does not get it. Ask for `meta` explicitly if you need `meta.location`.
404
+ - The two are mutually exclusive; sending both answers `400 invalidValue`.
405
+
406
+ The projection applies to the response only — the LDAP search still reads the
407
+ attributes the mapping needs.
408
+
300
409
  ## Multi-tenant: per-user LDAP bases
301
410
 
302
411
  SCIM does not expose any tenant concept to clients — resource `id`s are opaque
@@ -0,0 +1,122 @@
1
+ # Upgrading
2
+
3
+ What to check before deploying, newest first. Only releases that need a
4
+ decision or a configuration change appear here; see the
5
+ [CHANGELOG](../../CHANGELOG.md) for everything else.
6
+
7
+ ## To 0.7.0
8
+
9
+ ### `externalId` on Groups is no longer served
10
+
11
+ **Who is affected:** anyone provisioning Groups from an identity provider and
12
+ reading `externalId` back, or filtering on it.
13
+
14
+ A Group's `externalId` used to answer its `entryUUID`. RFC 7643 section 3.1
15
+ makes `externalId` the _provisioning client's_ identifier, so the id Okta or
16
+ Entra ID sent was discarded on write, and `filter=externalId eq "<their id>"`
17
+ searched `entryUUID` and matched nothing.
18
+
19
+ Name an attribute to store it in:
20
+
21
+ ```bash
22
+ --scim-group-external-id-attribute description
23
+ ```
24
+
25
+ Left unset, `externalId` is simply not supported on Groups — absent from
26
+ answers, and refused in filters. That is the honest behaviour; the previous
27
+ one looked like support and was not.
28
+
29
+ Users are unaffected: theirs was already stored in `employeeNumber`.
30
+
31
+ **Migrating existing Groups.** Values sent before this release were never
32
+ stored, so there is nothing to convert — re-send them from the provisioning
33
+ side once the flag is set.
34
+
35
+ ### `active` is now writable, and needs an attribute your directory holds
36
+
37
+ **Who is affected:** every SCIM deployment. Deactivations that used to be
38
+ silently dropped now reach the directory.
39
+
40
+ `active` is modelled on the presence of one LDAP attribute:
41
+
42
+ ```bash
43
+ --scim-user-lock-attribute pwdAccountLockedTime # default
44
+ --scim-user-lock-value 000001010000Z # default, "locked forever"
45
+ ```
46
+
47
+ The default is the ppolicy overlay's convention, and **`pwdAccountLockedTime`
48
+ only exists where slapd loads that overlay**. On a directory without it —
49
+ plain OpenLDAP, 389-ds, AD — a deactivation is refused by the schema and
50
+ answers `400 invalidValue` naming the flags. Point them elsewhere:
51
+
52
+ ```bash
53
+ --scim-user-lock-attribute nsAccountLock
54
+ --scim-user-lock-value TRUE
55
+ ```
56
+
57
+ Two configuration mistakes are now refused at startup rather than accepted
58
+ and silently ineffective: naming an attribute without a value, and a name
59
+ that is not an LDAP attribute description. Two more are warned about. See
60
+ [the SCIM plugin documentation](plugins/integrations/scim.md#deactivating-an-account-active) for the
61
+ detail.
62
+
63
+ **Verify once, by hand, that a deactivation actually prevents a bind.**
64
+ Nothing here can check that your directory honours the value you chose:
65
+ `active` is read back from the mere presence of the attribute, so a value the
66
+ directory stores and ignores still reads as `false`.
67
+
68
+ **One deliberate deviation from RFC 7644 section 3.5.1:** a `PUT` that omits
69
+ `active` leaves the lock as it stands rather than clearing it. Clearing it
70
+ would release locks SCIM never set — a ppolicy auto-lockout after failed
71
+ binds, or one an administrator placed — so a routine profile sync would
72
+ defeat the brute-force control. Send `"active": true` to reactivate
73
+ deliberately.
74
+
75
+ ### `forRequest()` for plugin authors
76
+
77
+ No action needed to upgrade. If you maintain a plugin that serves HTTP
78
+ requests, `this.server.ldap.forRequest(req)` returns a directory whose methods
79
+ carry the request, so the authorization hooks always see it. The unbound
80
+ methods take the request as a trailing optional argument, and omitting it
81
+ skips every authorization check silently. See the
82
+ [plugin development guide](../plugin-development/README.md#using-ldap-operations).
83
+
84
+ ## To 0.6.2
85
+
86
+ ### Writes that a bypass was letting through are now refused
87
+
88
+ **Who is affected:** deployments using `core/auth/authzPerBranch` or
89
+ `core/auth/authzLinid1` together with `plugins/ldap/onChange` or
90
+ `plugins/ldap/organizations`.
91
+
92
+ Both plugins rebuilt the hook tuple without the request, which made every
93
+ authorization plugin registered after them skip its check. Every `modify`,
94
+ and every `rename` for the second, went through unchecked.
95
+
96
+ **Check your grants before rolling this out widely.** An identity whose
97
+ grants were never quite right may start seeing `403` where it saw `200`. That
98
+ is the fix, not a regression.
99
+
100
+ Whether a given deployment was exposed was decided by plugin load order,
101
+ which is a race — it could differ from one restart to the next.
102
+ `core/auth/authzDynamic` was never affected: it reads its token from an
103
+ `AsyncLocalStorage` rather than from the request.
104
+
105
+ ## To 0.6.1
106
+
107
+ ### SCIM reads are authorized
108
+
109
+ **Who is affected:** any SCIM identity granted `write` without `read` on the
110
+ same branch.
111
+
112
+ Every search the SCIM plugin issued omitted the request, so
113
+ `ldapsearchrequest` — the hook the authorization plugins use — skipped its
114
+ check, and any authenticated identity could read any branch the plugin was
115
+ pointed at.
116
+
117
+ **An identity that writes now needs `read` on the same branch.** A SCIM write
118
+ answers with the resource it just changed, so it reads the entry back; a
119
+ grant of `write` without `read` no longer serves a write.
120
+
121
+ `GET /Users`, `GET /Users/{id}` and their Group counterparts answer `403`
122
+ where read is denied.