Skip to content

[EPD-2938] Support Employee Information attributes in account provisioning - #130

Open
c1-squire-dev[bot] wants to merge 3 commits into
mainfrom
squire/EPD-2938/create-account-employee-info
Open

[EPD-2938] Support Employee Information attributes in account provisioning#130
c1-squire-dev[bot] wants to merge 3 commits into
mainfrom
squire/EPD-2938/create-account-employee-info

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Account provisioning previously created a bare Google Workspace account — primary email, given/family name, password — and the Employee Information attributes were writable only through the update_user_profile / update_user connector actions (shipped in v0.2.60/v0.2.61). A joiner therefore needed a second Automation step to set department, manager, and friends.

1. Create. CreateAccount now reads department, job_title, cost_center, employee_type, employee_id, and manager_email from the ConductorOne account profile and applies them in the same users.insert call:

Profile key Aliases Directory field
department organizations[0].department
job_title jobTitle, title organizations[0].title
cost_center costCenter organizations[0].costCenter
employee_type employeeType organizations[0].description
employee_id employeeId externalIds[] entry of type organization
manager_email managerEmail relations[] entry of type manager

Each field is shaped by the same builders the update path uses (buildUpdatedOrganizations / buildUpdatedExternalIDs / buildManagerRelations), starting from an empty current state, so create and update produce identical wire values. The accepted keys and aliases now live in one place (employeeInfoJSONFields) shared with profileFromJSON, so the two paths cannot drift — a key understood on create but dropped on update (or vice versa) would silently break a mover flow.

Two behaviours worth reviewing explicitly:

  • All six are optional, and none of them can fail account creation. They enrich an account rather than define it, so anything unusable is dropped and the account is still created: empty and whitespace-only values, wrong-typed values (an HRIS sending employee_id as a JSON number), and an unparseable manager_email (a manager not yet provisioned, or a display name where an address was expected). Each dropped attribute is named with a reason in a Warn log line (dropped_fields), and update_user applies it once the profile is corrected. The alternative — failing the insert — costs the joiner their entire account over data that only decorates it.
  • The update path stays strict. There the account already exists, so rejecting a wrong-typed value loudly costs nothing, and the action reports partial success through skipped_fields. profileFromJSON and its tests are unchanged; only the create path degrades gracefully.

manager_email is normalized to the bare address before it is stored: mail.ParseAddress also accepts the display-name form, but Google resolves relations[].value only as an email, so storing the raw string would produce a manager relation matching no user and reading back verbatim on the next sync.

2. Update. update_user — the ACCOUNT_UPDATE_PROFILE action C1 push rules drive, which already supports all six attributes — now accepts its user_id as a plain string as well as a resource reference, via the same extractUserId helper every other user-scoped action in this connector already used. Google's userKey accepts a primary email or Google user ID, so an automation author holding either previously had the call rejected before it ever reached the Directory API. That is the misconfiguration point the ticket calls out.

That helper's plain-string fallback selects args.Fields directly, which panics on a nil *structpb.Struct; the SDK passes request.GetArgs() straight through, so an action invoked with no arguments reached it as nil. extractUserId now guards, which fixes update_user and the eight other user-scoped actions that were already exposed.

Out of scope per the ticket and left untouched: custom-schema attributes and recovery email/phone at creation (still action-only), clearing the manager relation, new OAuth scopes (admin.directory.user already covers all of this), and group/role provisioning.

Testing

go build ./..., go test ./... -count=1, and golangci-lint run (0 issues) all pass.

Tests in pkg/connector/user_create_account_test.go run CreateAccount against a mock users.insert server and assert on the actual request body:

  • all six attributes land on organizations / externalIds / relations, and are present on the resource CreateAccount returns (so the joiner sees them without waiting for a sync)
  • an account created without them sends no organizations / externalIds / relations keys at all — asserted against the raw JSON, since those are interface{}-typed fields Google's marshaller serializes whenever non-nil, even when empty
  • empty-string and whitespace-only values are dropped the same way
  • partial input (department only) sends only what was given
  • job_title / jobTitle / title and the camelCase aliases all resolve
  • an invalid manager_email and a numeric employee_id are each dropped while the account is still created, with the valid attributes in the same profile still applied
  • a wrong-typed job_title does not discard a usable title alias alongside it
  • manager_email in display-name form or with surrounding whitespace is stored as the bare address
  • employeeInfoFromProfile ignores recovery/custom-schema/name keys, so the create path cannot quietly widen, and reports each dropped attribute with the reason CreateAccount logs

Plus TestUpdateUserGlobal_PlainStringUserID, covering update_user with both a primary email and a Google user ID as user_id, and TestUpdateUserGlobal_NilArgs / TestExtractUserId_NilAndEmptyArgs, covering the nil-argument guard.

Not covered by automated tests: behaviour against a live tenant. The wire fields are the ones the v0.2.61 action handlers already exercise in production, and the repeated-field quirks documented in applyUserProfilePatch don't apply on create (nothing to merge or shrink).

Follow-ups / caveats

  • CreateAccount remains non-idempotent: creating an account that already exists still surfaces Google's duplicate error rather than falling back to updating the existing user. Making create-on-existing apply the profile would silently overwrite a live user's org attributes, so it's deliberately left out — the mover path is update_user.
  • Clearing the manager relation is still unsupported on both paths (matches update_user_manager).
  • The manager_email display-name normalization is applied on the create path only. applyUserProfilePatch and update_user_manager still store the raw input and have the same latent issue; fixing them means touching actions this PR does not otherwise change, so it is left for a follow-up.

…oning

Account creation previously set only the primary email, given/family name,
and password: the Employee Information attributes were writable only through
the update_user_profile / update_user connector actions, so a joiner needed a
second Automation step to apply department, manager, and friends.

CreateAccount now reads department, job_title, cost_center, employee_type,
employee_id, and manager_email from the ConductorOne account profile and
applies them in the same users.insert call, shaping each field with the same
builders the update path uses (organizations[0] for department/title/cost
center/employee type, an externalIds entry of type organization for the
employee ID, and a relations entry of type manager). The accepted profile
keys and their aliases are now defined once, in employeeInfoJSONFields, and
shared with profileFromJSON so the create and update paths cannot drift on
which keys they understand.

Empty values are dropped rather than sent - an empty string means "clear" on
the update path, but a brand-new account has nothing to clear, and sending
one anyway would create a phantom empty organization. A malformed
manager_email or a wrong-typed value fails before the insert instead of being
skipped: the update path can report a partial success through skipped_fields,
CreateAccount has no such channel, and failing pre-insert leaves nothing
half-configured.

Also makes update_user (the ACCOUNT_UPDATE_PROFILE action that C1 push rules
drive) accept its user_id as a plain string as well as a resource reference,
matching every other user-scoped action in this connector. Google's userKey
accepts a primary email or Google user ID, so an automation author holding
either previously had the call rejected before it ever reached the Directory
API - the misconfiguration this ticket calls out.

Recovery email/phone and custom-schema attributes are deliberately left out of
the creation path; they remain action-only.

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

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

EPD-2938

Comment thread pkg/connector/user_actions.go Outdated
Comment on lines +1449 to +1457
func applyEmployeeInfoToNewUser(user *admin.User, patch userProfilePatch) error {
for _, dest := range []*(*string){
&patch.department, &patch.jobTitle, &patch.costCenter,
&patch.employeeType, &patch.employeeID, &patch.managerEmail,
} {
if *dest != nil && **dest == "" {
*dest = nil
}
}

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 makes CreateAccount newly fail on profile keys it previously ignored entirely. An existing tenant whose account profile already maps department/title/manager_email/employee_id (the sync read path emits all of those, so mappings commonly carry them) will now hard-fail joiner provisioning on a malformed value — e.g. a whitespace-only manager_email survives the == "" check here and then fails mail.ParseAddress, and a numeric employee_id fails in stringFromJSON. The hard-fail choice is well argued in the PR description, but consider at least strings.TrimSpace before the emptiness check so blank-ish values are treated as absent rather than aborting the insert.

// the synced resource ID, and an automation author who has one of those
// (rather than a ConductorOne-internal resource ID) previously had the call
// rejected here before it ever reached the Directory API.
userId, err := extractUserId(args, l, actionUpdateUser)

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: on the missing-user_id branch, extractUserId (pkg/connector/helpers.go:87) logs the whole args struct with zap.Any("args", args). For update_user that struct contains user_profile, which can carry recovery_email, recovery_phone, names, and custom-schema values — PII that this handler never logged before. Consider logging only the action name (or the arg keys) rather than the full args for this call site.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [EPD-2938] Support Employee Information attributes in account provisioning

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

Review Summary

The new commit inverts the create-path strictness: employeeInfoFromProfile now returns a dropped slice instead of an error (new stringFromJSONLenient/jsonTypeName), applyEmployeeInfoToNewUser drops an unusable manager_email instead of failing, and CreateAccount logs the union at Warn — a wrong-typed employee_id or an unresolvable manager no longer costs the joiner their account. That is the right call for optional enrichment, the log level matches the L1/L4 rules, and the two removed hard-failure tests were replaced with equivalent still-creates-account coverage plus new unit tests for the reporting. I scanned the full PR diff (not just the incremental artifact) for security and correctness; no dependency manifests changed, and no blocking issues were found. Prior feedback is partly addressed — the nil-args panic guard in extractUserId and its regression tests landed — but five earlier findings are still open and are carried below.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • docs/docs-info.md:12 — claims empty and whitespace-only values are dropped "each named with a reason in a Warn log line", but those are set to nil in the trimming loop before the dropped slice is built, so they never reach dropped_fields; only wrong-typed values and an unusable manager_email are reported.
  • pkg/connector/user_actions.go:1478 — the drop reason embeds the raw manager_email value, putting a person-identifying string into a Warn log on a routine joiner flow; the wrong-typed messages deliberately omit values.
  • pkg/connector/user_create_account_test.go:289 — the unusable-manager_email drop description is untested, even though that log line is now the only signal replacing the removed hard failure.
  • pkg/connector/helpers.go:96 — (carried over, still unaddressed) the missing-user_id debug log dumps the whole args struct via zap.Any("args", args), which for update_user includes the user_profile PII blob. The new nil-args branch above it logs safely; this branch is the one a real update_user call with a bad user_id hits.
  • pkg/connector/user_actions.go:1481 — (carried over, still unaddressed) create normalizes the manager relation to addr.Address, but the update paths (:1211, :838) still store the raw value, so a mover update from the same account profile overwrites a resolvable manager relation with the display-name form; normalizing inside buildManagerRelations would cover both.
  • pkg/connector/user_actions.go:1461 — (carried over, still unaddressed) TrimSpace is used only for the emptiness test, so padded-but-non-empty values (" Engineering ", " E-1234 ") are still written verbatim for the five non-email fields.
  • docs/connector.mdx:47 — (carried over, still unaddressed) update_user's user_id cell still says "(resource ID, required)" although the handler now accepts a primary email / Google user ID as a plain string; README.md and docs/docs-info.md were updated, this file was not.
  • docs/connector.mdx:22 — (carried over, still unaddressed) the account-provisioning section still omits that creation applies Employee Information attributes, their accepted key aliases, and — now more important than in the previous revision — that a malformed value is silently dropped rather than surfaced to the caller.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `docs/docs-info.md`:
- Around line 12: The sentence says empty, whitespace-only, wrong-typed, and unusable-manager_email values are dropped "(each named with a reason in a Warn log line)". Empty and whitespace-only values are set to nil by the trimming loop in applyEmployeeInfoToNewUser (pkg/connector/user_actions.go around line 1461) before the dropped slice is built, so they never appear in the dropped_fields log. Reword so the "named with a reason in a Warn log line" clause applies only to wrong-typed values and an unusable manager_email, and describe empty/whitespace-only values as "simply not sent" (matching README.md's wording).

In `pkg/connector/user_actions.go`:
- Around line 1478: The dropped-field description interpolates the raw manager_email value, and that string is logged at Warn via zap.Strings("dropped_fields", ...) in user.go around line 506. Drop the value from the message (name only the field and the reason) so a person's name or address is not written to logs, matching how the wrong-typed messages name only the key and the JSON type.
- Around line 1481: applyEmployeeInfoToNewUser normalizes the manager relation to addr.Address, but the update paths still pass the raw string: line ~1211 (update.Relations = buildManagerRelations(currentRelations, *patch.managerEmail)) and line ~838 (buildManagerRelations(currentRelations, managerEmail)). A display-name form such as Jane Doe <jane@example.com> passes mail.ParseAddress validation and is then stored verbatim, so Google resolves no user, and a mover update overwrites the correctly-normalized value the create path wrote. Move the mail.ParseAddress normalization into buildManagerRelations (or normalize at both call sites) so create and update agree.
- Around line 1461: The trimming loop uses strings.TrimSpace on the dereferenced value purely as an emptiness test and leaves the original value in place. For department, jobTitle, costCenter, employeeType and employeeID a padded-but-non-empty value such as "  Engineering  " or "  E-1234  " is written to Google verbatim, which then reads back padded on the next sync. Assign the trimmed value back instead of only testing it, for the five non-email fields.

In `pkg/connector/user_create_account_test.go`:
- Around line 289: TestCreateAccount_InvalidManagerEmail_StillCreatesAccount asserts the manager relation is absent but never asserts that the drop was reported. Since the dropped_fields log is now the only signal replacing the removed hard failure, add an assertion on applyEmployeeInfoToNewUser's returned slice for an unusable manager_email, mirroring TestEmployeeInfoFromProfile_WrongTypedValuesAreReported.

In `pkg/connector/helpers.go`:
- Around line 96: The missing-user_id debug log calls zap.Any("args", args), which for update_user serializes the entire args struct including the user_profile JSON blob (recovery email/phone, names, custom schema values). Log only the argument names present, or the action name alone, instead of the full struct.

In `docs/connector.mdx`:
- Around line 47: The update_user row still documents user_id as "(resource ID, required)". The handler now goes through extractUserId, which also accepts the user's primary email or Google user ID as a plain string. Update the cell to match README.md and docs/docs-info.md.
- Around line 22: The account-provisioning section does not mention that account creation now applies Employee Information attributes (department, job_title/jobTitle/title, cost_center, employee_type, employee_id, manager_email, each also accepted in camelCase) from the C1 account profile in the same users.insert call, nor that an empty, whitespace-only, wrong-typed, or unusable value is silently dropped while the account is still created. Add that to the docs so customers know a dropped attribute will not surface as a provisioning failure.

@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.

Three defects found reviewing the Employee Information provisioning change.

extractUserId's plain-string fallback selects args.Fields directly, which
panics on a nil *structpb.Struct. GetResourceIDArg nil-guards, so the previous
inline check in update_user returned a clean InvalidArgument; routing through
the shared helper made a no-argument invocation panic instead (recovered by the
SDK as "panic in action handler"). Guarding in extractUserId covers all nine
user-scoped actions, eight of which were already exposed.

A whitespace-only value survived the empty-drop loop, so a profile carrying "  "
for manager_email - routine in HRIS- and CSV-sourced account profiles - reached
mail.ParseAddress, failed, and aborted the whole create. Whitespace-only now
counts as empty for all six attributes, matching the documented "empty values
are dropped" rule.

mail.ParseAddress also accepts the display-name form, but the raw input was
stored rather than the parsed address, so "Jane Doe <jane@example.com>" produced
a manager relation matching no Google user and reading back verbatim on the next
sync. The bare address is now stored.

Each fix has a regression test that fails against the prior source.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/connector/user_actions.go Outdated
return uhttp.WrapErrors(codes.InvalidArgument,
fmt.Sprintf("google-workspace: invalid manager_email: %s", *patch.managerEmail), err)
}
user.Relations = buildManagerRelations(nil, addr.Address)

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 create path now stores addr.Address, but the update paths still store the raw string — buildManagerRelations(currentRelations, *patch.managerEmail) at line 1211 and buildManagerRelations(currentRelations, managerEmail) at line 838 both pass the caller's input through unnormalized. So a joiner created from an account profile carrying Jane Doe <jane@example.com> gets a resolvable manager relation, and the very next update_user/update_user_manager from that same profile overwrites it with the display-name form that (per this comment's own reasoning) matches no user. Normalizing inside buildManagerRelations instead would fix both paths and make the "create and update produce identical wire values" claim in this function's doc comment true again.

// real value would persist blank padding onto the new account and - for
// manager_email, which is validated below - fail the entire create on a
// profile that simply carries no manager.
if *dest != nil && strings.TrimSpace(**dest) == "" {

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: TrimSpace is used only for the emptiness test, so the value that is stored keeps its padding — the same HRIS/CSV profiles that motivate this loop will write " Engineering " as the department, " E-1234 " as the employee ID, etc. Only manager_email gets a trimmed value written (via addr.Address below). Consider trimming in place (v := strings.TrimSpace(**dest); if v == "" { *dest = nil } else { *dest = &v }) so all six fields are normalized consistently.

@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.

The six Employee Information attributes were declared IsRequired: false, but a
wrong-typed value or an unusable manager_email failed users.insert outright, so
in practice a malformed one was required to be correct. An HRIS-sourced profile
routinely carries employee_id or cost_center as a JSON number, and a manager who
has not been provisioned yet, so the joiner lost their account over data that
only enriches it.

Account creation now drops any attribute it cannot use - empty, whitespace-only,
wrong-typed, or an unparseable manager_email - and creates the account regardless,
naming each dropped attribute and its reason in a Warn log line. Warn rather than
Error: a misconfigured attribute mapping is a customer-side condition and the
operation still succeeds. update_user applies the attributes once the profile is
corrected.

Alias resolution degrades the same way, so a numeric job_title no longer discards
a usable title alongside it.

The update path keeps its strict rejection: there the account already exists, so
failing loudly costs nothing, and the action reports partial success through
skipped_fields. profileFromJSON and its tests are unchanged.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread docs/docs-info.md

Yes:
- **Create/Delete user accounts** — via Directory API `users.insert` and `users.delete`. New accounts are created with a generated random password.
- **Create/Delete user accounts** — via Directory API `users.insert` and `users.delete`. New accounts are created with a generated random password. Creation also applies any Employee Information attributes carried on the ConductorOne account profile in the same `users.insert` call — `department`, `job_title` (also `jobTitle`/`title`), `cost_center`, `employee_type`, `employee_id`, and `manager_email` (each also accepted in camelCase) — mapping to `organizations[0]` (`department`/`title`/`costCenter`/`description`), an `externalIds` entry of type `organization`, and a `relations` entry of type `manager`. All six are optional and never fail account creation: empty, whitespace-only, wrong-typed, and unusable-`manager_email` values are dropped (each named with a reason in a `Warn` log line) and the account is still created, with `update_user` available to apply them afterwards. Recovery email/phone and custom-schema attributes remain action-only.

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 says empty and whitespace-only values are dropped "(each named with a reason in a Warn log line)", but applyEmployeeInfoToNewUser nils those out in the trimming loop (user_actions.go:1461) before dropped is built, so only wrong-typed values and an unusable manager_email ever reach dropped_fields. An operator reading this will look for a log line that never appears. Suggest scoping the "named with a reason" clause to wrong-typed/unusable values and keeping empty/whitespace-only as "simply not sent" (the wording README.md already uses).

switch {
case err != nil:
dropped = append(dropped,
fmt.Sprintf("%s (not a valid email address: %q)", argManagerEmail, *patch.managerEmail))

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 drop reason embeds the raw manager_email value, and the string ends up in a Warn-level dropped_fields log at user.go:506. The wrong-typed messages deliberately name only the key and the JSON type — this one echoes what is often a real person's name or address ("Jane Doe", a typo'd address) into logs on a routine joiner flow. Consider naming the field and the failure without the value (e.g. manager_email (not a valid email address)), keeping the connector's "no PII in logs" posture consistent.

require.NotNil(t, resp)
require.Equal(t, 1, state.insertCount)

require.Nil(t, extractRelations(state.lastInsertBody), "the unusable manager relation is dropped")

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 pins that the relation is absent but never asserts the drop is reported. The dropped_fields log line is now the only signal an operator gets that the manager was skipped (the hard failure it replaced is gone), yet applyEmployeeInfoToNewUser's returned description for an unusable manager_email has no test — the wrong-typed equivalent does (TestEmployeeInfoFromProfile_WrongTypedValuesAreReported). Consider asserting on applyEmployeeInfoToNewUser's return for this case too.

@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.

0 participants