Skip to content

feat: add update_user_attrs action for arbitrary LDAP attributes#170

Open
c1-squire-dev[bot] wants to merge 5 commits into
mainfrom
johnallers/cxp-806-add-update-user-action-for-setting-arbitrary-ldap-attributes
Open

feat: add update_user_attrs action for arbitrary LDAP attributes#170
c1-squire-dev[bot] wants to merge 5 commits into
mainfrom
johnallers/cxp-806-add-update-user-action-for-setting-arbitrary-ldap-attributes

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements CXP-806 — an Update User action that sets or clears arbitrary LDAP attributes on an existing user, requested by IDMWorks.

The new global action update_user_attrs backs ConductorOne's profile-push flow (ACTION_TYPE_ACCOUNT + ACTION_TYPE_ACCOUNT_UPDATE_PROFILE) and is also invocable directly. It's modeled on baton-active-directory's action of the same name but adapted for generic LDAP directories (OpenLDAP, 389-DS, OpenDJ, etc.). Active Directory and FreeIPA keep their own connectors and are out of scope.

Behavior

  • Identity is the user DN (resource_id), not an objectGUID — that's how baton-ldap identifies users.
  • Fail-closed scope check: only entries within the configured user-search-dn (or base-dn) may be modified; out-of-scope / non-user DNs are reported as NotFound (no existence leak).
  • Idempotent: the target entry is read first and already-satisfied changes (value already set, or already absent for a clear) are dropped, so re-runs report applied: 0. This avoids brittle value-equality read-back across directories that normalize stored values (caseIgnoreMatch lowercasing, whitespace stripping, etc.).
  • Real errors surface: adds LdapModifyStrict, a non-swallowing modify path, because the default LdapModify masks UnwillingToPerform / NoSuchAttribute / etc. to nil (great for grant idempotency, bad for detecting a rejected attribute write).
  • Synthetic profile keys aliased: first_namegivenName, last_namesn, display_namedisplayName, user_iduid; login/path are skipped; everything else passes through as a raw LDAP attribute name (case-insensitive).
  • Guardrails: password attributes (userPassword / anything containing password) and objectClass are rejected; the RDN attribute is skipped (renaming needs a ModifyDN). Single-valued only (per the StringMap contract); empty value clears the attribute; all changes go in one atomic ModifyRequest.

Returns success, applied (count modified), and skipped (mask entries not written).

Changes

  • pkg/connector/action.go — schema, registration in GlobalActions, handler, and pure helpers (buildUserAttrChanges, assertDNInScope, resolveUpdateAttrName, rdnAttrTypes).
  • pkg/ldap/client.goLdapModifyStrict (non-swallowing modify).
  • pkg/connector/action_test.go — container-free unit tests for the pure helpers plus OpenLDAP-testcontainer integration tests for the handler.
  • README.md — documents the action, its limitations, and the --provisioning requirement.

Testing

  • go build ./..., go vet ./..., gofmt, and golangci-lint run are clean.
  • Container-free unit tests pass locally (TestAssertDNInScope, TestResolveUpdateAttrName, TestBuildUserAttrChanges). The Docker/OpenLDAP integration tests (TestUpdateUserAttrs) run in CI — Docker isn't available in the dev sandbox, same as the existing TestCreateOU / TestLdapGetRaw.

🤖 Generated with Claude Code

Adds a global update_user_attrs action that sets or clears arbitrary LDAP
attributes on an existing user, backing ConductorOne's profile-push flow
(ACTION_TYPE_ACCOUNT_UPDATE_PROFILE) and also invocable directly. Modeled
on baton-active-directory's action but adapted for generic LDAP:

- Identity is the user DN (resource_id), not an objectGUID.
- Fail-closed scope check against user-search-dn / base-dn.
- Reads the entry first and drops no-op changes so re-runs are idempotent,
  avoiding brittle value read-back across directories that normalize values.
- Uses a new non-swallowing LdapModifyStrict so genuine schema/permission
  rejections surface (the default LdapModify masks UnwillingToPerform et al.).
- Aliases baton-ldap's synthetic profile keys (first_name->givenName, etc.);
  denies password* and objectClass; skips the RDN attribute; single-valued.

CXP-806

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@c1-squire-dev
c1-squire-dev Bot requested a review from a team July 22, 2026 16:07
@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CXP-806

Comment thread pkg/connector/action.go
}
}
log.Error("update_user_attrs: modify failed", fields...)
return nil, nil, fmt.Errorf("ldap-connector: update_user_attrs: failed to modify user %q: %w", acc.DN, err)

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: This modify failure returns a bare fmt.Errorf, unlike the other error paths in this handler which use status.Errorf. Since LdapModifyStrict surfaces real server rejections (permission denied, schema violation), the returned error lacks a gRPC status code, so the SDK maps it to Unknown and may retry/surface it incorrectly. Consider wrapping with an appropriate codes.Code (e.g. PermissionDenied/Internal). Low confidence — this mirrors the existing createOU pattern. (confidence: low)

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: feat: add update_user_attrs action for arbitrary LDAP attributes

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f6148bfe8ba6.
Review mode: incremental since 14a000c
View review run

Review Summary

The new commit drops the optional, ignored resource_type argument from the update_user_attrs action schema (plus matching README/docs edits) and adds a test that passes resource_type as a raw struct key to prove the handler still tolerates the profile-push pipeline sending it. This is backward-compatible: the handler never read resource_type, structpb ignores unknown keys, and no dangling references to the removed argResourceType constant remain. I scanned the full PR diff for security and correctness — the action's fail-closed scope check, password/objectClass denylist, RDN skip, and non-swallowing LdapModifyStrict error propagation all hold. No new issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Add an integration test asserting a schema-violating modify (clearing the
MUST attribute sn) surfaces as an error and does not partially apply, guarding
the handler's non-swallowing modify path.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Comment thread pkg/connector/action.go
}
}
log.Error("update_user_attrs: modify failed", fields...)
return nil, nil, fmt.Errorf("ldap-connector: update_user_attrs: failed to modify user %q: %w", acc.DN, err)

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: This modify failure returns a bare fmt.Errorf, unlike the other error paths in this handler which use status.Errorf. Since LdapModifyStrict surfaces real server rejections (permission denied, schema violation), the returned error lacks a gRPC status code, so the SDK maps it to Unknown and may retry/surface it incorrectly. Consider wrapping with an appropriate codes.Code (e.g. PermissionDenied/Internal). Low confidence — this mirrors the existing createOU pattern. (confidence: low)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Evaluated this and I'm respectfully declining — the premise doesn't hold for the baton-sdk action framework (v0.19.1).

The gRPC code on a handler error is discarded, not mapped to Unknown and retried. In pkg/actions/actions.go, invokeGlobalAction runs the handler and, on error, calls oa.SetError(err) which stores only err.Error() (the string) in oa.Rv.Fields["error"] and sets status FAILED. GetActionStatus explicitly returns oa.Status/oa.Rv and not oa.Err (its comment: "Don't return oa.Err here … oa.Rv contains any error"), and the synchronous InvokeAction returns a nil RPC error alongside the FAILED status. The FAILED status is derived from oaErr != nil, independent of any codes.Code. So there's no code-based retry or surfacing to change here.

It's also already consistent with the codebase convention. createOU uses the same split: arg/validation failures → status.Errorf(codes.…) (action.go:219, 228), and the actual LDAP-operation failure → bare fmt.Errorf(…%w) (action.go:242, 251). updateUserAttrs mirrors that exactly; line 338 is the operation-failure case.

Switching to status.Errorf here would add no functional behavior, inject a noisier rpc error: code = … desc = prefix into the operator-facing error string, and drop the %w chain that in-process logging benefits from. Leaving as-is.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Add the update_user_attrs action to the Connector actions section: its
arguments, return values, and the scope/single-value/denylist/RDN rules,
plus the synthetic profile-key mapping. Matches the existing create_ou
entry and the baton-okta docs style.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/connector/action.go Outdated
fields = append(fields, zap.String("attr:"+ch.Modification.Type, fmt.Sprintf("len=%d", len(ch.Modification.Vals[0]))))
}
}
log.Error("update_user_attrs: modify failed", fields...)

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: A single modify rejection is now logged at Error three times — here, plus getConnection (client.go:101) and LdapModifyStrict (client.go:393) — and the SDK logs the returned error a fourth time. Many LDAP modify failures (e.g. insufficientAccessRights, schema violations) are customer-config conditions that are expected rather than connector bugs, so Error level here generates alert noise and retained error spans. Consider Warn for this handler-level log (which adds the useful attr-name/value-length detail), or dropping it since the lower layers already log the raw error. (confidence: medium, non-blocking)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — addressed in 14a000c. This is valid: a modify rejection was logged at Error in three places (getConnection client.go:101, LdapModifyStrict client.go:393, and this handler), and most of these rejections (insufficientAccessRights, schema violations) are expected customer-config conditions rather than connector bugs.

Changes:

  • Handler log ErrorWarn, keeping the attr-name/value-length detail. The error is still returned and surfaced as a FAILED action by the SDK.
  • LdapModifyStrict log ErrorDebug, since its sole caller logs a contextual error and getConnection already logs the raw one.

Left getConnection (client.go:101) at Error — it's shared by every LDAP operation and predates this PR, so changing it is out of scope here.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Modify rejections in update_user_attrs (permission denied, schema
violations) are expected customer-config conditions, not connector bugs,
and were logged at Error up to three times (getConnection, LdapModifyStrict,
handler). Downgrade the handler log to Warn (keeping the attr-name/value-
length detail) and LdapModifyStrict's log to Debug (its sole caller logs a
contextual error and getConnection already logs the raw one), reducing
Error-level alert noise. Behavior is unchanged: the action still fails and
surfaces the error.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@c1-squire-dev

c1-squire-dev Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Re: latest review suggestion (getConnection catch-all still logs strict-modify rejections at Error, client.go:101)

Valid observation, but declining to change it in this PR — here's the reasoning:

  • The existing isModify param can't isolate the strict-modify path. getConnection(ctx, false, …) is shared by both _ldapSearch (client.go:278) and LdapModifyStrict (client.go:389); isModify=true covers add/modify/delete. So there's no way to lower severity for just strict-modify rejections without a signature change to the shared getConnection, which is on the hot path of every LDAP operation (search/add/modify/delete/strict-modify).

  • It's pre-existing shared behavior, not introduced here. This PR already lowered the two logs it owns — the handler (Warn, keeping attr-name/value-length detail) and LdapModifyStrict (Debug) — leaving exactly one Error line at the shared catch-all. Reworking that catch-all's severity model (e.g. "client-side LDAP result codes → Warn, server/network → Error" across all operations) is the right altitude, but it changes error-log visibility for sync/grant/revoke and deserves its own focused change and review rather than being bolted onto this feature PR.

The reviewer flagged this as non-trivial / awareness-only (non-blocking), which matches that assessment. Happy to open a separate issue to make getConnection's severity result-code-aware across all operations if that's wanted.

The earlier fmt.Errorf/gRPC-code suggestion remains declined for the reason documented in that thread (the SDK discards the handler error's gRPC code; only status FAILED + the error string are surfaced).

The action always targets users (the entry is resolved under userFilter),
so the resource_type argument was redundant and made the manual "Perform
connector action" setup in the C1 UI confusing. Remove it from the schema.

The handler never read resource_type, and the SDK invoke path validates only
constraints (not that provided args match the declared schema), so the C1
push-profile pipeline sending a resource_type is still tolerated. A test
asserts that.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant