mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-22 05:19:54 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user