fix(okta): stop partial updates erasing stored profile data (#6751)

* fix(okta): stop partial updates erasing stored profile data

Post-merge audit of the Okta integration (follows #6741), verified against the
OpenAPI spec bundled in okta-sdk-golang/.generator.

Two updates could silently destroy data:

- `update_group` targets `PUT /api/v1/groups/{groupId}`, which Okta documents as
  `replaceGroup` — it swaps the profile wholesale. Sending only the two fields
  the tool exposes erased the stored description on every rename, and dropped
  every org-defined custom attribute along with it. The tool now reads the group
  and overlays the supplied fields before replacing, matching the read-modify-
  write `salesforce_update_custom_field` already uses for the same hazard.
- `update_user` gated its profile fields on `!== undefined`, so an empty string
  reached Okta and blanked the stored value. The block strips blanks before they
  get there, but the tool is `user-or-llm` and a model routinely emits `""` for a
  field it has nothing to say about, so the guard belongs on the tool.

Also corrected:

- `forgetDevices` defaults to true at Okta, so the unseeded switch rendered off
  while remembered factors were in fact being cleared.
- Group rules take a plain keyword on `search`, not the SCIM-style expression the
  shared Search field's wand generates, so they get their own field.
- `get_logs` dropped `limit=0`, which the spec documents as valid.
- `get_user` emitted an activation timestamp under `activated`, which the block
  declares as the lifecycle boolean; the timestamp is now `activatedAt`.
- Descriptions that overstated what an endpoint does: `list_users` omits
  DEPROVISIONED users, `delete_user` deactivates before it deletes,
  `delete_group_rule` answers 202, and `excludedGroupIds` is always empty because
  Okta does not support group exclusions.

* fix(okta): forward the abort signal through the group read-modify-write

* test(okta): rename the shared body-builder helper

* fix(okta): key the send-email and search mappings off the operation

* docs(okta): use TSDoc for the new block annotations
This commit is contained in:
Waleed
2026-08-15 17:18:28 -07:00
committed by GitHub
parent 852906ec91
commit cbbcca970c
17 changed files with 449 additions and 70 deletions
@@ -40,7 +40,7 @@ Integrate Okta identity management into your workflow. Manage users, groups, and
### List Users from Okta
List all users in your Okta organization with optional search and filtering
List users in your Okta organization with optional search and filtering. Users with a DEPROVISIONED status are omitted unless a search or filter expression selects them.
#### Input
@@ -308,7 +308,7 @@ Permanently delete a user from your Okta organization. Can only be performed on
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `userId` | string | Deleted user ID |
| `deleted` | boolean | Whether the user was deleted |
| `deleted` | boolean | Whether the delete request was accepted. An ACTIVE user is deactivated by the first call and needs a second call to actually be deleted. |
| `success` | boolean | Operation success status |
### List Groups from Okta
@@ -396,7 +396,7 @@ Create a new group in your Okta organization
### Update Group in Okta
Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement).
Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. Fields left blank keep their stored value.
#### Input
@@ -552,7 +552,7 @@ List the group rules in your Okta organization. Each rule assigns users to group
| ↳ `expressionType` | string | Expression language, typically urn:okta:expression:1.0 |
| ↳ `assignUserToGroupIds` | array | Groups that matching users are assigned to |
| ↳ `excludedUserIds` | array | Users excluded from the rule |
| ↳ `excludedGroupIds` | array | Groups excluded from the rule |
| ↳ `excludedGroupIds` | array | Groups excluded from the rule. Always empty — Okta does not currently support group exclusions. |
| `count` | number | Number of rules returned |
| `nextCursor` | string | Cursor for the next page, or null on the last page |
| `hasMore` | boolean | Whether more rules are available |
@@ -584,7 +584,7 @@ Retrieve a single Okta group rule by ID, including the expression that decides w
| `expressionType` | string | Expression language, typically urn:okta:expression:1.0 |
| `assignUserToGroupIds` | array | Groups that matching users are assigned to |
| `excludedUserIds` | array | Users excluded from the rule |
| `excludedGroupIds` | array | Groups excluded from the rule |
| `excludedGroupIds` | array | Groups excluded from the rule. Always empty — Okta does not currently support group exclusions. |
| `success` | boolean | Operation success status |
### Create Group Rule in Okta
@@ -616,7 +616,7 @@ Create a group rule that automatically assigns users matching an Okta expression
| `expressionType` | string | Expression language, typically urn:okta:expression:1.0 |
| `assignUserToGroupIds` | array | Groups that matching users are assigned to |
| `excludedUserIds` | array | Users excluded from the rule |
| `excludedGroupIds` | array | Groups excluded from the rule |
| `excludedGroupIds` | array | Groups excluded from the rule. Always empty — Okta does not currently support group exclusions. |
| `success` | boolean | Operation success status |
### Activate Group Rule in Okta
@@ -677,7 +677,7 @@ Permanently delete a group rule. Destructive and irreversible. Optionally also r
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `groupRuleId` | string | Deleted group rule ID |
| `deleted` | boolean | Whether the rule was deleted |
| `deleted` | boolean | Whether the deletion was accepted. Okta answers 202 and removes the rule asynchronously. |
| `success` | boolean | Operation success status |
### List Factors from Okta
+72 -12
View File
@@ -17,6 +17,11 @@ function toFiniteNumber(value: unknown): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined
}
/** Operations where Okta sends the notification email unless told otherwise. */
const SEND_EMAIL_DEFAULT_ON_OPERATIONS = ['okta_activate_user', 'okta_reset_password']
const SEND_EMAIL_DEFAULT_ON = new Set(SEND_EMAIL_DEFAULT_ON_OPERATIONS)
/** Treats a blank subBlock value as absent. */
function blankToUndefined(value: unknown): unknown {
return value === null || value === '' ? undefined : value
@@ -157,7 +162,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
],
okta_list_group_rules: [
'List group rules',
{ text: ', matching', field: 'search' },
{ text: ', matching', field: 'ruleSearch' },
{ text: ', up to', field: 'limit' },
],
okta_get_group_rule: [{ text: 'Read group rule', field: 'groupRuleId', core: true }],
@@ -252,7 +257,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
placeholder: 'profile.firstName eq "John"',
condition: {
field: 'operation',
value: ['okta_list_users', 'okta_list_groups', 'okta_list_group_rules'],
value: ['okta_list_users', 'okta_list_groups'],
},
wandConfig: {
enabled: true,
@@ -288,6 +293,19 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
value: ['okta_get_logs', 'okta_list_apps', 'okta_list_app_users', 'okta_list_app_groups'],
},
},
{
/**
* Group rules take a plain keyword on `search`, not the SCIM-style
* expression the Search field's wand generates, so they get their own
* field rather than sharing one that would produce a silently
* non-matching query.
*/
id: 'ruleSearch',
title: 'Search',
type: 'short-input',
placeholder: 'Keyword to search rules for',
condition: { field: 'operation', value: 'okta_list_group_rules' },
},
// User ID (shared across user operations that need it)
{
id: 'userId',
@@ -469,20 +487,30 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
placeholder: 'Description for the group',
condition: { field: 'operation', value: ['okta_create_group', 'okta_update_group'] },
},
// Send email option (activate, reset password, delete)
/**
* Okta's `sendEmail` default is not uniform: activation and password reset
* default to sending, deactivation and removal default to not sending. One
* shared switch could only be seeded for one of those, so the two groups get
* their own field and the params mapper picks by operation.
*/
{
id: 'sendEmail',
title: 'Send Email',
type: 'switch',
value: () => 'true',
condition: {
field: 'operation',
value: [
'okta_activate_user',
'okta_deactivate_user',
'okta_reset_password',
'okta_delete_user',
'okta_remove_user_from_app',
],
value: SEND_EMAIL_DEFAULT_ON_OPERATIONS,
},
mode: 'advanced',
},
{
id: 'sendDeactivationEmail',
title: 'Send Email',
type: 'switch',
condition: {
field: 'operation',
value: ['okta_deactivate_user', 'okta_delete_user', 'okta_remove_user_from_app'],
},
mode: 'advanced',
},
@@ -658,6 +686,11 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
id: 'forgetDevices',
title: 'Forget Devices',
type: 'switch',
/**
* Okta defaults this to true, so an unseeded switch would render off while
* remembered factors were in fact being cleared.
*/
value: () => 'true',
condition: { field: 'operation', value: 'okta_clear_user_sessions' },
mode: 'advanced',
},
@@ -945,9 +978,23 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
domain: params.domain,
limit: toFiniteNumber(params.limit),
priority: toFiniteNumber(params.priority),
// Group-specific UI fields carry the tool's generic param names.
/** Group-specific UI fields carry the tool's generic param names. */
name: blankToUndefined(params.groupName),
description: blankToUndefined(params.groupDescription),
/** Group rules get their own keyword field but the same wire param. */
search:
params.operation === 'okta_list_group_rules'
? blankToUndefined(params.ruleSearch)
: blankToUndefined(params.search),
/**
* Keyed off the operation rather than `??`: both switches are advanced,
* and `shouldSerializeSubBlock` skips `condition` for advanced fields,
* so a stale value from a previously selected operation can still be
* present here.
*/
sendEmail: SEND_EMAIL_DEFAULT_ON.has(String(params.operation))
? blankToUndefined(params.sendEmail)
: blankToUndefined(params.sendDeactivationEmail),
}
const mappedKeys = new Set([
@@ -958,6 +1005,10 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
'priority',
'groupName',
'groupDescription',
'search',
'ruleSearch',
'sendEmail',
'sendDeactivationEmail',
])
for (const [key, value] of Object.entries(params)) {
if (!mappedKeys.has(key)) result[key] = blankToUndefined(value)
@@ -975,6 +1026,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
userId: { type: 'string', description: 'User ID or login' },
groupId: { type: 'string', description: 'Group ID' },
search: { type: 'string', description: 'Search expression' },
ruleSearch: { type: 'string', description: 'Keyword to search group rules for' },
filter: { type: 'string', description: 'Filter expression' },
limit: { type: 'number', description: 'Max results to return' },
firstName: { type: 'string', description: 'First name' },
@@ -989,6 +1041,10 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
groupName: { type: 'string', description: 'Group name' },
groupDescription: { type: 'string', description: 'Group description' },
sendEmail: { type: 'boolean', description: 'Whether to send email notification' },
sendDeactivationEmail: {
type: 'boolean',
description: 'Whether to send the deactivation or removal email notification',
},
q: { type: 'string', description: 'Keyword search query' },
after: { type: 'string', description: 'Cursor for the next page of results' },
since: { type: 'string', description: 'Start of the System Log time window' },
@@ -1137,7 +1193,11 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
accessibility: { type: 'json', description: 'Application accessibility settings' },
assignUserToGroupIds: { type: 'json', description: 'Groups a rule assigns matching users to' },
excludedUserIds: { type: 'json', description: 'Users excluded from a group rule' },
excludedGroupIds: { type: 'json', description: 'Groups excluded from a group rule' },
excludedGroupIds: {
type: 'json',
description:
'Groups excluded from a group rule. Always empty — Okta does not currently support group exclusions.',
},
amr: { type: 'json', description: 'Authentication methods used to establish a session' },
features: { type: 'json', description: 'Provisioning features enabled on an application' },
label: { type: 'string', description: 'Application or role label' },
+3 -3
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-08-15",
"updatedAt": "2026-08-16",
"integrations": [
{
"type": "onepassword",
@@ -13731,7 +13731,7 @@
"operations": [
{
"name": "List Users",
"description": "List all users in your Okta organization with optional search and filtering"
"description": "List users in your Okta organization with optional search and filtering. Users with a DEPROVISIONED status are omitted unless a search or filter expression selects them."
},
{
"name": "Get User",
@@ -13783,7 +13783,7 @@
},
{
"name": "Update Group",
"description": "Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement)."
"description": "Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. Fields left blank keep their stored value."
},
{
"name": "Delete Group",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -150,7 +150,8 @@ export const oktaCreateGroupRuleTool: ToolConfig<
},
excludedGroupIds: {
type: 'array',
description: 'Groups excluded from the rule',
description:
'Groups excluded from the rule. Always empty — Okta does not currently support group exclusions.',
items: { type: 'string', description: 'Group ID' },
},
success: { type: 'boolean', description: 'Operation success status' },
+5 -1
View File
@@ -71,7 +71,11 @@ export const oktaDeleteGroupRuleTool: ToolConfig<
outputs: {
groupRuleId: { type: 'string', description: 'Deleted group rule ID' },
deleted: { type: 'boolean', description: 'Whether the rule was deleted' },
deleted: {
type: 'boolean',
description:
'Whether the deletion was accepted. Okta answers 202 and removes the rule asynchronously.',
},
success: { type: 'boolean', description: 'Operation success status' },
},
}
+5 -1
View File
@@ -67,7 +67,11 @@ export const oktaDeleteUserTool: ToolConfig<OktaDeleteUserParams, OktaDeleteUser
outputs: {
userId: { type: 'string', description: 'Deleted user ID' },
deleted: { type: 'boolean', description: 'Whether the user was deleted' },
deleted: {
type: 'boolean',
description:
'Whether the delete request was accepted. An ACTIVE user is deactivated by the first call and needs a second call to actually be deleted.',
},
success: { type: 'boolean', description: 'Operation success status' },
},
}
+2 -1
View File
@@ -92,7 +92,8 @@ export const oktaGetGroupRuleTool: ToolConfig<OktaGetGroupRuleParams, OktaGetGro
},
excludedGroupIds: {
type: 'array',
description: 'Groups excluded from the rule',
description:
'Groups excluded from the rule. Always empty — Okta does not currently support group exclusions.',
items: { type: 'string', description: 'Group ID' },
},
success: { type: 'boolean', description: 'Operation success status' },
+4 -1
View File
@@ -84,7 +84,10 @@ export const oktaGetLogsTool: ToolConfig<OktaGetLogsParams, OktaGetLogsResponse>
if (params.q) queryParams.append('q', params.q)
if (params.sortOrder) queryParams.append('sortOrder', params.sortOrder)
if (params.after) queryParams.append('after', params.after)
if (params.limit) queryParams.append('limit', params.limit.toString())
/** `0` is a documented limit on this endpoint, so it must not read as absent. */
if (params.limit !== undefined && params.limit !== null) {
queryParams.append('limit', params.limit.toString())
}
const queryString = queryParams.toString()
return queryString
+2 -1
View File
@@ -132,7 +132,8 @@ export const oktaListGroupRulesTool: ToolConfig<
},
excludedGroupIds: {
type: 'array',
description: 'Groups excluded from the rule',
description:
'Groups excluded from the rule. Always empty — Okta does not currently support group exclusions.',
items: { type: 'string', description: 'Group ID' },
},
},
+2 -1
View File
@@ -9,7 +9,8 @@ const logger = createLogger('OktaListUsers')
export const oktaListUsersTool: ToolConfig<OktaListUsersParams, OktaListUsersResponse> = {
id: 'okta_list_users',
name: 'List Users from Okta',
description: 'List all users in your Okta organization with optional search and filtering',
description:
'List users in your Okta organization with optional search and filtering. Users with a DEPROVISIONED status are omitted unless a search or filter expression selects them.',
version: '1.0.0',
params: {
+237
View File
@@ -0,0 +1,237 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OktaBlock } from '@/blocks/blocks/okta'
import { oktaGetLogsTool } from '@/tools/okta/get_logs'
import { oktaGetUserTool } from '@/tools/okta/get_user'
import { oktaUpdateGroupTool } from '@/tools/okta/update_group'
import { oktaUpdateUserTool } from '@/tools/okta/update_user'
import { mergeOktaGroupProfile } from '@/tools/okta/utils'
const AUTH = { apiKey: 'token', domain: 'dev-123456.okta.com' }
/** Narrows a declarative `body` builder's union return to a plain object. */
function builtBody(build: () => unknown): Record<string, unknown> {
return build() as Record<string, unknown>
}
/**
* Mirrors `generic-handler.ts`, which spreads the mapper's result over the raw
* serialized inputs. A key the mapper omits keeps its raw subBlock value, so a
* guard is only real if it survives this merge.
*/
function mergedBlockParams(inputs: Record<string, unknown>): Record<string, unknown> {
const mapper = OktaBlock.tools.config?.params
if (!mapper) throw new Error('Okta block defines no params mapper')
return { ...inputs, ...mapper(inputs) }
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('okta update_group profile merge', () => {
it('keeps the stored description when the caller omits it', () => {
const merged = mergeOktaGroupProfile(
{ name: 'Engineering', description: 'All engineers' },
{ name: 'Engineering EMEA' }
)
expect(merged.name).toBe('Engineering EMEA')
expect(merged.description).toBe('All engineers')
})
it('keeps org-defined custom profile attributes across the replace', () => {
const merged = mergeOktaGroupProfile(
{ name: 'Engineering', description: 'All engineers', costCenter: 'CC-42' },
{ name: 'Engineering', description: 'Updated' }
)
expect(merged.costCenter).toBe('CC-42')
expect(merged.description).toBe('Updated')
})
it('still applies an explicitly supplied empty description', () => {
const merged = mergeOktaGroupProfile(
{ name: 'Engineering', description: 'All engineers' },
{ name: 'Engineering', description: '' }
)
expect(merged.description).toBe('')
})
it('reads the group before replacing it and PUTs the merged profile', async () => {
const stored = {
id: '00g1',
created: '2026-01-01T00:00:00.000Z',
lastUpdated: '2026-01-01T00:00:00.000Z',
lastMembershipUpdated: null,
type: 'OKTA_GROUP',
profile: { name: 'Engineering', description: 'All engineers', costCenter: 'CC-42' },
}
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify(stored), { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
...stored,
profile: { ...stored.profile, name: 'Engineering EMEA' },
}),
{ status: 200 }
)
)
const result = await oktaUpdateGroupTool.directExecution!({
...AUTH,
groupId: '00g1',
name: 'Engineering EMEA',
})
expect(fetchMock).toHaveBeenCalledTimes(2)
const [readUrl, readInit] = fetchMock.mock.calls[0]
expect(String(readUrl)).toBe('https://dev-123456.okta.com/api/v1/groups/00g1')
expect(readInit?.method ?? 'GET').toBe('GET')
const [, writeInit] = fetchMock.mock.calls[1]
expect(writeInit?.method).toBe('PUT')
expect(JSON.parse(String(writeInit?.body))).toEqual({
profile: { name: 'Engineering EMEA', description: 'All engineers', costCenter: 'CC-42' },
})
expect(result.output.description).toBe('All engineers')
})
})
describe('okta update_user partial merge', () => {
it('drops blank profile fields so a partial update cannot erase stored values', () => {
const body = builtBody(() =>
oktaUpdateUserTool.request.body!({
...AUTH,
userId: '00u1',
firstName: 'Ada',
lastName: '',
email: '',
title: 'Engineer',
})
)
expect(body).toEqual({ profile: { firstName: 'Ada', title: 'Engineer' } })
})
it('still drops blanks when the block layer is bypassed by an LLM tool call', () => {
const body = builtBody(() =>
oktaUpdateUserTool.request.body!({
...AUTH,
userId: '00u1',
firstName: '',
lastName: '',
})
)
expect(body).toEqual({ profile: {} })
})
})
describe('okta get_logs query building', () => {
it('sends limit=0 rather than treating it as absent', () => {
const url = oktaGetLogsTool.request.url({ ...AUTH, limit: 0 })
expect(url).toContain('limit=0')
})
it('omits limit entirely when it was not supplied', () => {
const url = oktaGetLogsTool.request.url({ ...AUTH })
expect(url).not.toContain('limit=')
})
})
describe('okta block params mapping', () => {
it('maps the group-rule keyword field onto the shared search wire param', () => {
const merged = mergedBlockParams({
operation: 'okta_list_group_rules',
...AUTH,
ruleSearch: 'contractors',
})
expect(merged.search).toBe('contractors')
})
it('leaves the expression search field mapped for the operations that take one', () => {
const merged = mergedBlockParams({
operation: 'okta_list_users',
...AUTH,
search: 'profile.department eq "Engineering"',
})
expect(merged.search).toBe('profile.department eq "Engineering"')
})
it('drops blank profile fields before they reach a partial update', () => {
const merged = mergedBlockParams({
operation: 'okta_update_user',
...AUTH,
userId: '00u1',
firstName: 'Ada',
lastName: '',
})
expect(merged.firstName).toBe('Ada')
expect(merged.lastName).toBeUndefined()
})
it('reads the send-email toggle that belongs to the selected operation', () => {
expect(
mergedBlockParams({ operation: 'okta_activate_user', ...AUTH, sendEmail: false }).sendEmail
).toBe(false)
expect(
mergedBlockParams({
operation: 'okta_deactivate_user',
...AUTH,
sendDeactivationEmail: true,
}).sendEmail
).toBe(true)
})
it('ignores a stale advanced toggle left over from another operation', () => {
// `shouldSerializeSubBlock` skips `condition` for advanced fields, so both
// switches can reach the mapper at once.
const merged = mergedBlockParams({
operation: 'okta_deactivate_user',
...AUTH,
sendEmail: true,
sendDeactivationEmail: false,
})
expect(merged.sendEmail).toBe(false)
})
it('drops a non-numeric limit instead of forwarding the raw string', () => {
const merged = mergedBlockParams({
operation: 'okta_list_users',
...AUTH,
limit: 'lots',
})
expect(merged.limit).toBeUndefined()
})
})
describe('okta block output contract', () => {
it('keeps the get_user activation timestamp on its published output name', () => {
// Renaming it would break saved `<Okta.activated>` references, so the tool
// keeps the name and declares the real type.
expect(oktaGetUserTool.outputs?.activated).toMatchObject({ type: 'string' })
})
it('declares every subBlock the params mapper reads', () => {
const subBlockIds = new Set(OktaBlock.subBlocks.map((subBlock) => subBlock.id))
expect(subBlockIds.has('ruleSearch')).toBe(true)
expect(OktaBlock.inputs.ruleSearch).toBeDefined()
})
it('has no duplicate subBlock ids, which would silently seed the wrong default', () => {
const ids = OktaBlock.subBlocks.map((subBlock) => subBlock.id)
expect(ids.length).toBe(new Set(ids).size)
})
})
+7 -2
View File
@@ -57,11 +57,16 @@ export interface OktaUser {
}
/**
* Okta Group profile from the API
* Okta Group profile from the API.
*
* Okta's group profile is extensible orgs add custom attributes through the
* Schemas API so the index signature is what keeps those attributes alive
* across a read-modify-write instead of dropping them.
*/
interface OktaGroupProfile {
export interface OktaGroupProfile {
name: string
description?: string | null
[attribute: string]: unknown
}
/**
+62 -29
View File
@@ -1,16 +1,38 @@
import { createLogger } from '@sim/logger'
import { validateOktaDomain } from '@/lib/core/security/input-validation'
import type { OktaGroup, OktaUpdateGroupParams, OktaUpdateGroupResponse } from '@/tools/okta/types'
import { oktaHeaders, throwOktaError } from '@/tools/okta/utils'
import type { ToolConfig } from '@/tools/types'
import { mergeOktaGroupProfile, oktaHeaders, throwOktaError } from '@/tools/okta/utils'
import type { ToolConfig, ToolResponse } from '@/tools/types'
const logger = createLogger('OktaUpdateGroup')
/** Shared by the direct-execution and declarative paths so both emit one shape. */
async function transformUpdateGroupResponse(response: Response): Promise<OktaUpdateGroupResponse> {
if (!response.ok) {
await throwOktaError(response, logger, 'Failed to update group in Okta')
}
const group: OktaGroup = await response.json()
return {
success: true,
output: {
id: group.id,
name: group.profile?.name ?? '',
description: group.profile?.description ?? null,
type: group.type,
created: group.created,
lastUpdated: group.lastUpdated,
lastMembershipUpdated: group.lastMembershipUpdated ?? null,
success: true,
},
}
}
export const oktaUpdateGroupTool: ToolConfig<OktaUpdateGroupParams, OktaUpdateGroupResponse> = {
id: 'okta_update_group',
name: 'Update Group in Okta',
description:
'Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. All profile properties must be specified (full replacement).',
'Update a group profile in your Okta organization. Only groups of OKTA_GROUP type can be updated. Fields left blank keep their stored value.',
version: '1.0.0',
params: {
@@ -46,6 +68,41 @@ export const oktaUpdateGroupTool: ToolConfig<OktaUpdateGroupParams, OktaUpdateGr
},
},
/**
* Authoritative path: read the stored profile, overlay the supplied fields,
* then replace.
*
* `PUT /api/v1/groups/{groupId}` is `replaceGroup` it swaps the profile
* wholesale rather than merging, and the profile is extensible. Sending only
* the two fields this tool exposes therefore erased the stored description on
* every rename, along with any custom attribute the org had defined. Reading
* first is the only way an omitted field can mean "leave it alone".
*/
directExecution: async (params, signal): Promise<ToolResponse> => {
const domain = validateOktaDomain(params.domain)
const url = `https://${domain}/api/v1/groups/${encodeURIComponent(params.groupId.trim())}`
const headers = oktaHeaders(params.apiKey)
const readResponse = await fetch(url, { headers, signal })
if (!readResponse.ok) {
await throwOktaError(readResponse, logger, 'Failed to load group for update in Okta')
}
const existing: OktaGroup = await readResponse.json()
const writeResponse = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify({ profile: mergeOktaGroupProfile(existing.profile, params) }),
signal,
})
return transformUpdateGroupResponse(writeResponse)
},
/**
* Declarative fallback used only when direct execution is bypassed. It cannot
* merge, so it sends exactly what the caller supplied.
*/
request: {
url: (params) => {
const domain = validateOktaDomain(params.domain)
@@ -53,34 +110,10 @@ export const oktaUpdateGroupTool: ToolConfig<OktaUpdateGroupParams, OktaUpdateGr
},
method: 'PUT',
headers: (params) => oktaHeaders(params.apiKey),
body: (params) => ({
profile: {
name: params.name,
description: params.description ?? '',
},
}),
body: (params) => ({ profile: mergeOktaGroupProfile(undefined, params) }),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
await throwOktaError(response, logger, 'Failed to update group in Okta')
}
const group: OktaGroup = await response.json()
return {
success: true,
output: {
id: group.id,
name: group.profile?.name ?? '',
description: group.profile?.description ?? null,
type: group.type,
created: group.created,
lastUpdated: group.lastUpdated,
lastMembershipUpdated: group.lastMembershipUpdated ?? null,
success: true,
},
}
},
transformResponse: (response: Response) => transformUpdateGroupResponse(response),
outputs: {
id: { type: 'string', description: 'Group ID' },
+16 -7
View File
@@ -82,16 +82,25 @@ export const oktaUpdateUserTool: ToolConfig<OktaUpdateUserParams, OktaUpdateUser
},
method: 'POST',
headers: (params) => oktaHeaders(params.apiKey),
/**
* Blank values are dropped rather than sent.
*
* This is a partial merge, so any key present in `profile` overwrites the
* stored value an empty string blanks the field in Okta. The block strips
* blanks before they reach here, but this tool is also `user-or-llm` and a
* model routinely emits `""` for a field it has nothing to say about, so
* the guard has to live on the tool itself.
*/
body: (params) => {
const profile: Record<string, string> = {}
if (params.firstName !== undefined) profile.firstName = params.firstName
if (params.lastName !== undefined) profile.lastName = params.lastName
if (params.email !== undefined) profile.email = params.email
if (params.login !== undefined) profile.login = params.login
if (params.mobilePhone !== undefined) profile.mobilePhone = params.mobilePhone
if (params.title !== undefined) profile.title = params.title
if (params.department !== undefined) profile.department = params.department
if (params.firstName) profile.firstName = params.firstName
if (params.lastName) profile.lastName = params.lastName
if (params.email) profile.email = params.email
if (params.login) profile.login = params.login
if (params.mobilePhone) profile.mobilePhone = params.mobilePhone
if (params.title) profile.title = params.title
if (params.department) profile.department = params.department
return { profile }
},
+21 -1
View File
@@ -1,5 +1,5 @@
import type { Logger } from '@sim/logger'
import type { OktaApiError, OktaGroupRule } from '@/tools/okta/types'
import type { OktaApiError, OktaGroupProfile, OktaGroupRule } from '@/tools/okta/types'
/**
* Standard headers for every Okta Management API request.
@@ -65,6 +65,26 @@ export function parseOktaPagination(response: Response): {
return { nextCursor, hasMore: true }
}
/**
* Overlays the fields a caller supplied onto a group's stored profile.
*
* `PUT /api/v1/groups/{groupId}` replaces the profile wholesale, and the
* profile is extensible, so anything left out is erased the stored
* description on a rename, and every org-defined custom attribute on any
* update. Merging over the current profile is what makes an omitted field mean
* "leave it alone" instead of "delete it".
*/
export function mergeOktaGroupProfile(
existing: OktaGroupProfile | undefined,
updates: { name: string; description?: string }
): Record<string, unknown> {
return {
...existing,
name: updates.name,
...(updates.description === undefined ? {} : { description: updates.description }),
}
}
/**
* Flattens a group rule into the shape the list, get, and create tools all emit.
*