Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,27 @@ custom LDAP attributes on an existing user.

| Argument | Required | Description |
|---|---|---|
| `user_id` | yes | Resource ID reference to the user to update. |
| `user_id` | yes | Account resource ID reference to the user to update. From a C1 automation this is the C1 account identifier, not the LDAP DN -- see the notes below. |
| `first_name` | no | The user's first (given) name, mapped to `givenName`. Ignored if empty. |
| `last_name` | no | The user's last (surname) name, mapped to `sn`. Ignored if empty. |
| `display_name` | no | The user's display name, mapped to `displayName`. Ignored if empty. |
| `email` | no | The user's email address, mapped to `mail`. Ignored if empty. |
| `custom_attributes` | no | Map of arbitrary raw LDAP attribute name → value, for attributes beyond the named fields above. Keys are used verbatim as attribute names. An empty value clears the attribute. |

Returns `success`, `updated_user` (the user resource after the update, best-effort
re-fetched; absent if the read-back failed, though the write itself still succeeded),
Returns `success`, `updated_user` (the modified user resource, re-fetched after the
write; absent if the read-back failed, though the write itself still succeeded),
`applied` (the number of attributes modified), and `skipped` (named fields or
`custom_attributes` entries that were not written).

`updated_user` carries the resource identity, `displayName`, and the user trait -- not
the entry's full attribute set. A value the action just wrote appears there only when it
also feeds one of those: `display_name` through `displayName`, `email` through the
trait's email list, and a `custom_attributes` key only when it maps to a trait field
(`mail`, `displayName`, `sAMAccountName`, `userPrincipalName`, a non-RDN `uid` or `cn`,
`lastLogonTimestamp`, `authTimestamp`). `first_name`, `last_name`, and every other
`custom_attributes` key reach the directory but do not appear in `updated_user`. Use
`applied` to confirm those.

**Notes:**
- Scope: this action is **resource-scoped to `user`**. Resource-scoping is what makes
`update_profile` discoverable and usable from ConductorOne's attribute-push-rule
Expand All @@ -101,9 +110,10 @@ re-fetched; absent if the read-back failed, though the write itself still succee
rather than silently vanishing.
- `inetOrgPerson` requirement: of the four named fields, only `last_name` (`sn`) is
universal -- it's a MUST attribute of the base `person` object class. `first_name`
(`givenName`), `display_name` (`displayName`), and `email` (`mail`) are only defined by
RFC 2798's `inetOrgPerson` object class; writing one of them to an entry that doesn't
carry `inetOrgPerson` fails loudly with LDAP result code 65 ("Object Class Violation").
(`givenName`, defined in RFC 4519), `display_name` (`displayName`, RFC 2798), and
`email` (`mail`, RFC 4524) are permitted on an entry only by RFC 2798's
`inetOrgPerson` object class; writing one of them to an entry that doesn't carry
`inetOrgPerson` fails loudly with LDAP result code 65 ("Object Class Violation").
This is safe -- the failure is atomic, with no partial write and no data corruption --
but it means those three fields only work against `inetOrgPerson` entries.
- `custom_attributes` semantics: an entry is written whenever the key is present,
Expand All @@ -112,8 +122,10 @@ re-fetched; absent if the read-back failed, though the write itself still succee
four named arguments above are the only names mapped to a different LDAP attribute
(`first_name` → `givenName`, and so on). A `custom_attributes` key is used verbatim as
the attribute name, whatever it looks like: `{"user_id": "x"}` writes an attribute
literally named `user_id` (and fails with LDAP result code 17, "Undefined Attribute
Type", if your directory has no such attribute) -- it does **not** write `uid`.
literally named `user_id` -- it does **not** write `uid`. A name your directory does
not define is refused by the server, and the result code depends on the
implementation: OpenLDAP returns 17 ("Undefined Attribute Type"), ApacheDS returns 16
("No Such Attribute").
Likewise `login` and `path`, which are baton profile field names rather than LDAP
attributes, are attempted as literal attribute names rather than skipped. Only the
safety checks below still apply to `custom_attributes` entries; none of them changes
Expand All @@ -134,10 +146,25 @@ re-fetched; absent if the read-back failed, though the write itself still succee
discarding the extra values. Clearing (an empty value) a multi-valued attribute is
unaffected and still removes all values -- that remains an explicit, intentional
"remove all values" operation.
- **Value types:** `custom_attributes` carries one string per attribute, so binary
attributes (`jpegPhoto`, `userCertificate;binary`) and option-tagged attributes
(`;lang-xx`) cannot be set through this action.
- Only entries within the configured `user-search-dn` (or `base-dn`) may be modified;
out-of-scope or non-user DNs are rejected as "not found" (fail-closed).
- Provisioning must be enabled (`--provisioning` / `BATON_PROVISIONING=true`) for actions
to run, and the bind account must have permission to modify the target entry.
- **From a C1 automation, `user_id` takes the C1 account identifier, not the LDAP DN.**
C1 resolves the account to the connector's resource before dispatching the action. A
DN fails inside C1 with `resource <dn> with type user was not found` and never reaches
the connector, so it produces no connector log line and leaves the directory
untouched.
- **An attribute push rule must map from a single-valued attribute.** The connector's
user profile carries only attributes that hold exactly one value on the entry, so a
multi-valued source resolves to nothing: the rule saves and enables, and each push
reports zero attributes applied.
- **Actions are not gated by `--provisioning` / `BATON_PROVISIONING`.** That flag gates the
provisioning surface -- grant, revoke, account create/delete, credential rotation -- and
the SDK registers the action service outside it, so `update_profile` runs and writes with
the flag unset. What the action does require is a bind account with permission to modify
the target entry.

# Developing baton-ldap

Expand Down
16 changes: 12 additions & 4 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,27 @@ Connector actions are custom capabilities that extend C1 automations with app-sp

| Action name | Additional fields | Description |
| ----------- | ----------------- | ----------- |
| `update_profile` | `user_id` (resource ID, required), `first_name` (string), `last_name` (string), `display_name` (string), `email` (string), `custom_attributes` (string map) | Set core profile fields (first name, last name, display name, email) and/or arbitrary custom LDAP attributes on an existing user. This action is scoped to the `user` resource type, which is what makes it discoverable and usable from C1's attribute-push-rule feature. It also backs C1's [push profile](/product/admin/account-provisioning) flow for LDAP users. |
| `update_profile` | `user_id` (account resource ID, required), `first_name` (string), `last_name` (string), `display_name` (string), `email` (string), `custom_attributes` (string map) | Set core profile fields (first name, last name, display name, email) and/or arbitrary custom LDAP attributes on an existing user. This action is scoped to the `user` resource type, which is what makes it discoverable and usable from C1's attribute-push-rule feature. It also backs C1's [attribute push rule](/product/admin/push-rules) flow for LDAP users. |

The `update_profile` action returns `success` (bool), `updated_user` (the user resource after the update, best-effort re-fetched; absent if the read-back failed, though the write itself still succeeded), `applied` (the number of attributes changed), and `skipped` (named fields or `custom_attributes` entries that were not written).
The `update_profile` action returns `success` (bool), `updated_user` (the modified user resource, re-fetched after the write; absent if the read-back failed, though the write itself still succeeded), `applied` (the number of attributes changed), and `skipped` (named fields or `custom_attributes` entries that were not written).

`updated_user` carries the resource identity, `displayName`, and the user trait — not the entry's full attribute set. A value the action just wrote appears there only when it also feeds one of those: `display_name` through `displayName`, `email` through the trait's email list, and a `custom_attributes` key only when it maps to a trait field (`mail`, `displayName`, `sAMAccountName`, `userPrincipalName`, a non-RDN `uid` or `cn`, `lastLogonTimestamp`, `authTimestamp`). `first_name`, `last_name`, and every other `custom_attributes` key reach the directory but do not appear in `updated_user`. Use `applied` to confirm those.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: displayName is listed inside the "maps to a trait field" parenthetical, but it isn't a trait field — it feeds Resource.DisplayName (pkg/connector/user.go:263), which the sentence already accounts for separately as one of the three carriers. Also, parseUserLogin (user.go:126) sources the login/alias set from objectGUID too, so the trait-field list is one short. Both are small, but this paragraph is the deliverable of the change.


Two requirements come from the C1 side rather than from LDAP:

- **`user_id` takes the C1 account identifier, not the LDAP DN.** C1 resolves the account to the connector's resource before dispatching the action. A DN fails inside C1 with `resource <dn> with type user was not found` and never reaches the connector, so it produces no connector log line and leaves the directory untouched.
- **An attribute push rule must map from a single-valued attribute.** The connector's user profile carries only attributes that hold exactly one value on the entry, so a multi-valued source resolves to nothing: the rule saves and enables, and each push reports zero attributes applied.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: "holds exactly one value" is necessary but not sufficient. userResource admits an attribute into the profile only when len(v.Values) == 1 && !containsBinaryData(v.Values[0]) (pkg/connector/user.go:195), and containsBinaryData (user.go:160-167) rejects any rune outside ASCII 32-126. A single-valued sn: Müller or any non-Latin value is therefore also absent from the profile and produces the identical "rule enables, every push applies zero attributes" symptom this bullet attributes solely to multi-valuedness — the case most likely to be hit in a real directory. Worth naming alongside the multi-valued rule.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The README's new third C1-side note — that --provisioning / BATON_PROVISIONING does not gate actions (verified: RegisterActionServiceServer is called outside the opts.ProvisioningEnabled branch in the vendored SDK, and --invoke-action sets WithActionsEnabled() rather than provisioning) — has no counterpart here, even though every other clarification in this PR was mirrored in both files. Consider adding it to this "requirements from the C1 side" list, since the customer-facing doc is where readers are most likely to assume the flag is required. Confidence: medium; non-blocking.


<Note>
`update_profile` is intended for generic LDAP directories (Active Directory and FreeIPA have their own connectors). It applies the following rules:

- **Named fields:** `first_name` → `givenName`, `last_name` → `sn`, `display_name` → `displayName`, `email` → `mail`. A named field is applied only when present and non-empty; a present-but-empty named field cannot clear the attribute and is instead reported in `skipped`.
- **`inetOrgPerson` requirement:** only `last_name` (`sn`) is universal — it's a MUST attribute of the base `person` object class. `first_name` (`givenName`), `display_name` (`displayName`), and `email` (`mail`) are only defined by RFC 2798's `inetOrgPerson` object class, so writing one of them to an entry that doesn't carry `inetOrgPerson` fails loudly with LDAP result code 65 ("Object Class Violation") — an atomic, clearly-signaled failure with no partial write, not a silent no-op.
- **Custom attributes:** `custom_attributes` maps arbitrary raw LDAP attribute names to values; an empty value clears the attribute (unlike the named fields above, where empty just means "not supplied"). Keys are used **verbatim** as attribute names — the named-field mapping above applies only to the named arguments, never to `custom_attributes`. `{"user_id": "x"}` therefore writes an attribute literally named `user_id` (failing with LDAP result code 17, "Undefined Attribute Type", if the directory has no such attribute); it does not write `uid`. The same goes for baton profile field names such as `login` and `path`: they are attempted as literal attribute names rather than skipped.
- **`inetOrgPerson` requirement:** only `last_name` (`sn`) is universal — it's a MUST attribute of the base `person` object class. `first_name` (`givenName`, defined in RFC 4519), `display_name` (`displayName`, RFC 2798), and `email` (`mail`, RFC 4524) are permitted on an entry only by RFC 2798's `inetOrgPerson` object class, so writing one of them to an entry that doesn't carry `inetOrgPerson` fails loudly with LDAP result code 65 ("Object Class Violation") — an atomic, clearly-signaled failure with no partial write, not a silent no-op.
- **Custom attributes:** `custom_attributes` maps arbitrary raw LDAP attribute names to values; an empty value clears the attribute (unlike the named fields above, where empty just means "not supplied"). Keys are used **verbatim** as attribute names — the named-field mapping above applies only to the named arguments, never to `custom_attributes`. `{"user_id": "x"}` therefore writes an attribute literally named `user_id`; it does not write `uid`. A name the directory does not define is refused by the server, and the result code depends on the implementation: OpenLDAP returns 17 ("Undefined Attribute Type"), ApacheDS returns 16 ("No Such Attribute"). The same goes for baton profile field names such as `login` and `path`: they are attempted as literal attribute names rather than skipped.
- **Collisions:** a `custom_attributes` key is dropped (never merged with, or overwriting, a named field's slot) and reported once in `skipped` when it case-insensitively matches either one of the four named field names, or the LDAP attribute a supplied named field is writing (`givenName`, `sn`, `displayName`, `mail`). The latter only applies when that named field was actually supplied and non-empty; otherwise `{"givenName": "Jane"}` is an ordinary raw write.
- **Not modifiable:** password attributes (`userPassword`, or any name containing `password` — use credential rotation instead) and `objectClass` are rejected; the user's RDN attribute (for example `cn` when the DN is `cn=jdoe,...`) is skipped, since renaming requires a different operation.
- **Multi-valued attributes:** setting (not clearing) a value on an attribute that currently holds more than one value returns an error instead of silently discarding the extra values; clearing (an empty value) still removes all values.
- **Value types:** `custom_attributes` carries one string per attribute, so binary attributes (`jpegPhoto`, `userCertificate;binary`) and option-tagged attributes (`;lang-xx`) cannot be set through this action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The option-tagged half of this claim doesn't hold. custom_attributes keys are passed through verbatim as the LDAP attribute type (pkg/connector/action.go:527 and :546 set PartialAttribute{Type: attrName}), and the read-back comparison uses GetEqualFoldAttributeValues(attrName), which fold-matches the full attribute description including the option. So a text-valued option-tagged attribute — description;lang-en, cn;lang-fr — is settable through this action today. The genuine constraint is the value type, not the option tag: a string map can't express binary content (jpegPhoto, userCertificate;binary). Consider narrowing to binary values only.

- **Scope:** only entries within the configured user search scope (`user-search-dn`, falling back to `base-dn`) can be modified; out-of-scope or non-user DNs are rejected.

The connector's bind account must have permission to modify the target entry.
Expand Down
Loading