Skip to content
Open
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
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,50 @@ baton resources

| Operation | Description |
| ----------------------------- | ------------------------------------------------------------------- |
| Create/Delete user | Directory API `users.insert` / `users.delete` |
| Create/Delete user | Directory API `users.insert` / `users.delete`. Account creation also applies any Employee Information attributes present on the account profile — see below |
| Delete group | Directory API `groups.delete` (group creation is the `create_group` connector action, below) |
| Grant/Revoke group membership | Directory API `members.insert` / `members.delete` |
| Grant/Revoke role assignment | Directory API `roleAssignments.insert` / `roleAssignments.delete` |

### Account profile attributes at creation

Account creation reads the following keys from the ConductorOne account profile
and applies them in the same `users.insert` call, so a joiner provisions a
fully-populated account in one step:

| Profile key | Accepted aliases | Google Directory field |
| ----------- | ---------------- | ---------------------- |
| `email`, `given_name`, `family_name` | — | `primaryEmail`, `name.givenName`, `name.familyName` (required) |
| `department` | — | `organizations[0].department` |
| `job_title` | `jobTitle`, `title` | `organizations[0].title` |
| `cost_center` | `costCenter` | `organizations[0].costCenter` |
| `employee_type` | `employeeType` | `organizations[0].description` (the Admin console's "Employee type") |
| `employee_id` | `employeeId` | `externalIds[]` entry of type `organization` (the Admin console's "Employee ID") |
| `manager_email` | `managerEmail` | `relations[]` entry of type `manager` |

The Employee Information keys are the same ones `update_user`'s `user_profile`
object accepts, so a single account profile drives both the joiner (create) and
subsequent mover (update) flows. Notes:

- All six are optional; keys that are absent, empty, or whitespace-only are
simply not sent (an empty value means "clear" on the update path, but a
brand-new account has nothing to clear).
- `manager_email` is normalized to the bare address before it is stored, so a
value that arrives padded or in display-name form
(`Jane Doe <jane@example.com>`) still produces a `manager` relation Google can
resolve.
- **None of them can fail account creation.** They enrich an account rather than
define it, so a value that is wrong-typed (e.g. `employee_id` sent as a JSON
number) or an unusable `manager_email` is dropped and the account is still
created. Every dropped attribute is named with a reason in a `Warn` log line
(`dropped_fields`). Use `update_user` to apply them once the profile is
corrected. Note this is deliberately *less* strict than the update path, which
rejects a wrong-typed value outright — there the account already exists, so
failing loudly costs nothing, whereas here it would cost the joiner their
account.
- Recovery email/phone and custom-schema attributes are **not** settable at
creation; they remain available through `update_user_profile` / `update_user`.

## Connector actions

Connector actions are custom operations invoked on demand from C1 automations:
Expand All @@ -90,7 +129,7 @@ Connector actions are custom operations invoked on demand from C1 automations:
| ------ | ------------- | ----------- |
| `update_user_status` / `disable_user` / `enable_user` | `user_id` / `is_suspended` | Suspend or activate a user (idempotent) |
| `update_user_profile` | `user_id`, plus any of `given_name`, `family_name`, `recovery_email`, `recovery_phone`, `department`, `job_title`, `cost_center`, `employee_type`, `employee_id`, `manager_email`, `custom_schemas` | Partial profile update (patch semantics); supports Employee Information attributes and custom-schema attribute values. Exception: an `employee_id` change that reduces the number of external IDs on the account (clearing it, or consolidating duplicate entries down to the new value) uses a full-object update instead, since Google does not reliably shrink a repeated field via patch. An empty or malformed `manager_email` does not fail the whole call — see the partial-success note below. |
| `update_user` | `user_id` (resource ID), `user_profile` (JSON string; same keys as `update_user_profile` above) | Profile update from a JSON object; consumed by C1 push rules for automated profile sync. Same partial-success behavior as `update_user_profile` for `manager_email`. |
| `update_user` | `user_id` (user resource reference, or the primary email / Google user ID as a plain string), `user_profile` (JSON string; same keys as `update_user_profile` above) | Profile update from a JSON object; consumed by C1 push rules for automated profile sync. Same partial-success behavior as `update_user_profile` for `manager_email`. |
| `update_user_manager` | `user_id`, `manager_email` | Set the user's `manager` relation |
| `make_admin` | `user_id`, `status` (bool) | Promote/demote a user to/from super administrator |
| `change_user_org_unit` | `user_id`, `org_unit_path` | Move a user to a different organizational unit |
Expand All @@ -108,6 +147,8 @@ Connector actions are custom operations invoked on demand from C1 automations:

> **Job title round-trip:** the synced user profile exposes the job title under both `title` and `job_title` for backward compatibility. `update_user`'s `user_profile` JSON object accepts any of `job_title`, `jobTitle`, or `title` as the source key. `update_user_profile` has a fixed schema and only exposes `job_title` as an argument name — pass the value under that key.

> **Identifying the user:** the user-scoped actions accept `user_id` either as a user resource reference (what the UI's resource picker and C1 push rules send) or as a plain string. Google's `userKey` accepts a user's primary email or Google user ID as well as the synced resource ID, so any of the three works when the action is wired up by hand.

> **Partial success and `manager_email`:** `update_user_profile`/`update_user` never clear an assigned manager through this action (matching `update_user_manager`), so an empty or invalid `manager_email` is not applied — but unlike other invalid fields, it does not fail the whole call when at least one other field in the same payload is valid. The response's `success: true` only means the call completed; check the `skipped_fields` return field (a comma-separated list naming any provided field that wasn't applied, and why) to detect this — a caller that checks `success` alone will not be told that `manager_email` specifically was skipped.

# Credentials Setup
Expand Down
4 changes: 2 additions & 2 deletions docs/docs-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
2. Can the connector provision any resources? If so, which ones?

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

- **Grant/Revoke group membership** — via Directory API `members.insert` and `members.delete`.
- **Grant/Revoke role assignment** — via Directory API `roleAssignments.insert` and `roleAssignments.delete`.
- **Create/Delete groups** — creation via the `create_group` connector action (Directory API `groups.insert`); deletion via Directory API `groups.delete`.
Expand All @@ -20,7 +20,7 @@
| --- | --- | --- |
| `update_user_status` / `disable_user` / `enable_user` | `users.update` | Suspend / activate a user (idempotent) |
| `update_user_profile` | `users.patch` (`users.update` when an `employee_id` change reduces the number of external IDs on the account) | Partial profile update: name, recovery details, Employee Information (department, job title, cost center, employee ID, employee type), manager relation, custom-schema values. An empty/invalid `manager_email` is skipped (not applied, reported via the `skipped_fields` return field) rather than failing the whole call, as long as another provided field is valid. |
| `update_user` | `users.patch` (`users.update` when an `employee_id` change reduces the number of external IDs on the account) | Profile update from a `user_profile` JSON object (same fields as `update_user_profile`); consumed by C1 push rules for automated profile sync. Same `manager_email` partial-success behavior as `update_user_profile`. |
| `update_user` | `users.patch` (`users.update` when an `employee_id` change reduces the number of external IDs on the account) | Profile update from a `user_profile` JSON object (same fields as `update_user_profile`); consumed by C1 push rules for automated profile sync. `user_id` accepts a user resource reference, or the user's primary email or Google user ID as a plain string. Same `manager_email` partial-success behavior as `update_user_profile`. |
| `update_user_manager` | `users.update` | Set the user's `manager` relation |
| `make_admin` | `users.makeAdmin` | Promote/demote a user to/from super administrator |
| `change_user_org_unit` | `users.update` | Move a user to a different organizational unit |
Expand Down
9 changes: 9 additions & 0 deletions pkg/connector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ func extractUserId(args *structpb.Struct, l *zap.Logger, actionName string) (str
if ref, ok := actions.GetResourceIDArg(args, argUserID); ok && ref.GetResource() != "" {
return ref.GetResource(), nil
}
// GetResourceIDArg nil-guards internally, but the plain-string fallback below
// 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 at all arrives here as nil - that has to read back as a clean
// InvalidArgument, not a recovered panic.
if args == nil || args.Fields == nil {
l.Debug("google-workspace: user action handler: missing arguments", zap.String("action", actionName))
return "", uhttp.WrapErrors(codes.InvalidArgument, "google-workspace: missing user_id argument")
}
userIdValue, ok := args.Fields[argUserID]
if !ok || userIdValue == nil {
l.Debug("google-workspace: user action handler: missing user_id argument", zap.String("action", actionName), zap.Any("args", args))
Expand Down
25 changes: 25 additions & 0 deletions pkg/connector/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,31 @@ func (o *userResourceType) CreateAccount(ctx context.Context, accountInfo *v2.Ac
ChangePasswordAtNextLogin: changePasswordAtNextLogin,
}

// Employee Information attributes (department, job title, cost center,
// employee type, employee ID, manager email) are optional; any the account
// profile carries are applied in the same users.insert call, so a joiner
// provisions a fully-populated account in one step instead of needing a
// follow-up update_user_profile action. The key aliases accepted here are
// the ones the update path accepts (employeeInfoJSONFields), so the same
// profile object drives both.
//
// None of them can fail the create. They enrich an account rather than
// define it, and the joiner needs the account far more than it needs a
// department: an attribute that is empty, wrong-typed, or (for
// manager_email) unusable is dropped and reported below, and the account is
// still created. update_user fills in anything that was dropped once the
// profile is corrected.
employeeInfo, droppedFields := employeeInfoFromProfile(pMap)
droppedFields = append(droppedFields, applyEmployeeInfoToNewUser(user, employeeInfo)...)
if len(droppedFields) > 0 {
// Warn, not Error: this is a misconfigured attribute mapping on the
// customer side, not a connector bug, and the operation still succeeds.
ctxzap.Extract(ctx).Warn(
"google-workspace: dropping unusable Employee Information attributes while creating account",
zap.Strings("dropped_fields", droppedFields),
)
}

if credentialOptions == nil {
return nil, nil, nil, fmt.Errorf("credentialOptions cannot be nil")
}
Expand Down
Loading
Loading