Skip to content

Commit cc1976b

Browse files
committed
fix(okta): make the params transform authoritative over the serialized inputs
The generic block handler merges the transform on top of the raw serialized inputs (`{ ...inputs, ...transformedParams }`), so a key the transform omits keeps the raw subBlock string rather than being dropped. Two intended behaviors were silently defeated by that merge: - a non-numeric `limit` or `priority` reached Okta verbatim instead of being dropped so Okta could apply its own default - a blank profile field in `update_user` — a POST merge, so a partial update — overwrote the stored Okta value with an empty string instead of leaving it untouched Assign every key the block can send, including the ones it drops, so `undefined` actually removes them. Cover both cases with tests that assert on the merge rather than on the transform alone. Also align the block with what the tools really return: drop `targets` and `debugData`, which only exist nested inside a System Log event and were never emitted at the top level, and surface the fifteen user and group profile fields `get_user` and `get_group` emit but the block did not declare. Declare `authMode` explicitly rather than leaning on the docs generator's credential-subBlock heuristic, route the remaining fifteen tools through the shared `oktaHeaders`/`throwOktaError` helpers so there is one auth and error path, add wand prompts for the search, filter, expression, and timestamp fields, and add skills for MFA reset, sign-in investigation, and application access review to match the operations this block now has. `limit` on List Group Members is Okta's default of 1000, not a maximum.
1 parent aa2f6d1 commit cc1976b

20 files changed

Lines changed: 226 additions & 297 deletions

apps/docs/content/docs/en/integrations/okta.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ List all members of a specific group in your Okta organization
497497
| `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) |
498498
| `groupId` | string | Yes | Group ID to list members for |
499499
| `after` | string | No | Opaque pagination cursor returned as nextCursor by a previous call |
500-
| `limit` | number | No | Maximum number of members to return per page \(max: 1000\) |
500+
| `limit` | number | No | Maximum number of members to return per page \(default: 1000, but Okta recommends 200\) |
501501

502502
#### Output
503503

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { OktaBlock } from '@/blocks/blocks/okta'
6+
7+
/**
8+
* The generic block handler runs `{ ...inputs, ...transformedParams }`, so the
9+
* transform can only drop a value by assigning `undefined` to its key. Omitting
10+
* the key leaves the raw subBlock string in place. These cases pin that
11+
* behavior by asserting on the merge, not on the transform alone.
12+
*/
13+
function merge(inputs: Record<string, unknown>): Record<string, unknown> {
14+
const transform = OktaBlock.tools.config?.params
15+
if (!transform) throw new Error('Okta block has no params transform')
16+
return { ...inputs, ...transform(inputs) }
17+
}
18+
19+
const BASE = { operation: 'okta_list_users', apiKey: 'token', domain: 'dev-1.okta.com' }
20+
21+
describe('Okta block params transform', () => {
22+
it('coerces a numeric limit', () => {
23+
expect(merge({ ...BASE, limit: '25' }).limit).toBe(25)
24+
})
25+
26+
it('drops a non-numeric limit rather than forwarding the raw entry', () => {
27+
expect(merge({ ...BASE, limit: 'twenty' }).limit).toBeUndefined()
28+
})
29+
30+
it('drops a non-numeric priority rather than forwarding the raw entry', () => {
31+
const merged = merge({
32+
...BASE,
33+
operation: 'okta_assign_group_to_app',
34+
priority: 'high',
35+
})
36+
expect(merged.priority).toBeUndefined()
37+
})
38+
39+
it('drops a blank profile field so a partial update leaves Okta untouched', () => {
40+
const merged = merge({
41+
...BASE,
42+
operation: 'okta_update_user',
43+
userId: '00u1',
44+
firstName: 'Ada',
45+
lastName: '',
46+
})
47+
expect(merged.firstName).toBe('Ada')
48+
expect(merged.lastName).toBeUndefined()
49+
})
50+
51+
it('maps the group name and description onto the tool param names', () => {
52+
const merged = merge({
53+
...BASE,
54+
operation: 'okta_create_group',
55+
groupName: 'Engineering',
56+
groupDescription: 'Eng team',
57+
})
58+
expect(merged.name).toBe('Engineering')
59+
expect(merged.description).toBe('Eng team')
60+
})
61+
62+
it('keeps a false toggle, which is a real choice rather than a blank field', () => {
63+
const merged = merge({
64+
...BASE,
65+
operation: 'okta_activate_user',
66+
userId: '00u1',
67+
sendEmail: false,
68+
})
69+
expect(merged.sendEmail).toBe(false)
70+
})
71+
})
72+
73+
describe('Okta block outputs', () => {
74+
it('declares only fields a tool emits at the top level', () => {
75+
const declared = Object.keys(OktaBlock.outputs)
76+
expect(declared).not.toContain('targets')
77+
expect(declared).not.toContain('debugData')
78+
expect(declared).toContain('events')
79+
})
80+
})

apps/sim/blocks/blocks/okta.ts

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { OktaIcon } from '@/components/icons'
22
import type { BlockConfig, BlockMeta } from '@/blocks/types'
3-
import { IntegrationType } from '@/blocks/types'
3+
import { AuthMode, IntegrationType } from '@/blocks/types'
44
import type { OktaResponse } from '@/tools/okta/types'
55

66
/**
77
* Coerces a numeric subBlock value, dropping anything that is not a real number.
88
*
99
* These fields are free-text inputs, so a stray non-numeric entry would otherwise
1010
* become `NaN` and serialize to `null`, which Okta rejects with a validation
11-
* error that points at the wrong thing. Omitting the field instead lets Okta
11+
* error that points at the wrong thing. Dropping the field instead lets Okta
1212
* apply its own default.
1313
*/
1414
function toFiniteNumber(value: unknown): number | undefined {
@@ -17,6 +17,11 @@ function toFiniteNumber(value: unknown): number | undefined {
1717
return Number.isFinite(parsed) ? parsed : undefined
1818
}
1919

20+
/** Treats a blank subBlock value as absent. */
21+
function blankToUndefined(value: unknown): unknown {
22+
return value === null || value === '' ? undefined : value
23+
}
24+
2025
export const OktaBlock: BlockConfig<OktaResponse> = {
2126
type: 'okta',
2227
name: 'Okta',
@@ -25,6 +30,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
2530
'Integrate Okta identity management into your workflow. Manage users, groups, and group rules. Run service desk actions like resetting MFA factors and clearing sessions. Review and change application assignments and admin roles. Query the System Log to audit sign-ins and admin changes.',
2631
docsLink: 'https://docs.sim.ai/integrations/okta',
2732
category: 'tools',
33+
authMode: AuthMode.ApiKey,
2834
integrationType: IntegrationType.Security,
2935
bgColor: '#191919',
3036
iconColor: '#007DC1',
@@ -248,6 +254,12 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
248254
field: 'operation',
249255
value: ['okta_list_users', 'okta_list_groups', 'okta_list_group_rules'],
250256
},
257+
wandConfig: {
258+
enabled: true,
259+
placeholder: 'Describe who or what to search for',
260+
prompt:
261+
'Generate an Okta search expression. The grammar is SCIM-style: a property, an operator (eq, sw, co, gt, ge, lt, le), and a quoted value, combined with and/or and parentheses. User properties are prefixed with profile (profile.firstName, profile.email, profile.department) plus the top-level id, status, created, activated, statusChanged and lastUpdated. Group properties are type plus profile.name and profile.description. Example: profile.department eq "Engineering" and status eq "ACTIVE". Return ONLY the expression - no explanations, no extra text.',
262+
},
251263
},
252264
{
253265
id: 'filter',
@@ -259,6 +271,12 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
259271
value: ['okta_list_users', 'okta_list_groups', 'okta_list_apps', 'okta_get_logs'],
260272
},
261273
mode: 'advanced',
274+
wandConfig: {
275+
enabled: true,
276+
placeholder: 'Describe how to narrow the results',
277+
prompt:
278+
'Generate an Okta filter expression. The grammar is SCIM-style: a property, an operator (eq, and for lastUpdated also gt, ge, lt, le), and a quoted value, combined with and/or. Each listing supports a limited property set. Users: status, lastUpdated, id, profile.login, profile.email, profile.firstName, profile.lastName. Groups: id, type, lastUpdated, lastMembershipUpdated. Applications: id, status, name. System Log: any event property, such as eventType, outcome.result, actor.alternateId or client.ipAddress. Example: eventType eq "user.session.start" and outcome.result eq "FAILURE". Return ONLY the expression - no explanations, no extra text.',
279+
},
262280
},
263281
{
264282
id: 'q',
@@ -510,8 +528,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
510528
required: { field: 'operation', value: 'okta_create_group_rule' },
511529
wandConfig: {
512530
enabled: true,
531+
placeholder: 'Describe which users the rule should match',
513532
prompt:
514-
'Generate an Okta expression language predicate that evaluates to a boolean over a user profile, for example user.department=="Engineering". Return ONLY the expression.',
533+
'Generate an Okta Expression Language predicate that evaluates to a boolean over a user profile. Reference profile attributes as user.<attribute>, combine them with && and ||, and compare with == or !=. Example: user.department=="Engineering" && user.countryCode=="US". Return ONLY the expression - no explanations, no extra text.',
515534
},
516535
},
517536
{
@@ -785,7 +804,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
785804
condition: { field: 'operation', value: 'okta_get_logs' },
786805
wandConfig: {
787806
enabled: true,
788-
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
807+
placeholder: 'Describe the start of the time window',
808+
prompt:
809+
'Generate an ISO 8601 UTC timestamp for the start of a System Log query window, for example 2026-08-01T00:00:00.000Z. Return ONLY the timestamp - no explanations, no extra text.',
789810
generationType: 'timestamp',
790811
},
791812
},
@@ -797,7 +818,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
797818
condition: { field: 'operation', value: 'okta_get_logs' },
798819
wandConfig: {
799820
enabled: true,
800-
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
821+
placeholder: 'Describe the end of the time window',
822+
prompt:
823+
'Generate an ISO 8601 UTC timestamp for the end of a System Log query window, for example 2026-08-15T00:00:00.000Z. Return ONLY the timestamp - no explanations, no extra text.',
801824
generationType: 'timestamp',
802825
},
803826
},
@@ -904,28 +927,30 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
904927
],
905928
config: {
906929
tool: (params) => params.operation as string,
930+
/**
931+
* Every param this block can send is assigned here, including the ones it
932+
* decides to drop.
933+
*
934+
* The executor merges the result over the raw serialized inputs
935+
* (`{ ...inputs, ...transformedParams }`), so a key this function *omits*
936+
* keeps the raw subBlock string instead of being dropped. Assigning
937+
* `undefined` is what actually removes it: otherwise a non-numeric `limit`
938+
* would still reach Okta verbatim, and a blank field in a partial update
939+
* (`update_user` is a POST merge) would overwrite the stored Okta value
940+
* with an empty string rather than leaving it untouched.
941+
*/
907942
params: (params) => {
908943
const result: Record<string, unknown> = {
909944
apiKey: params.apiKey,
910945
domain: params.domain,
946+
limit: toFiniteNumber(params.limit),
947+
priority: toFiniteNumber(params.priority),
948+
// Group-specific UI fields carry the tool's generic param names.
949+
name: blankToUndefined(params.groupName),
950+
description: blankToUndefined(params.groupDescription),
911951
}
912952

913-
const limit = toFiniteNumber(params.limit)
914-
if (limit !== undefined) result.limit = limit
915-
916-
const priority = toFiniteNumber(params.priority)
917-
if (priority !== undefined) result.priority = priority
918-
919-
// Map group-specific UI fields to tool param names
920-
if (params.groupName) result.name = params.groupName
921-
if (params.groupDescription !== undefined) result.description = params.groupDescription
922-
923-
// Pass through all other params, skipping empty values. Blank fields in a
924-
// partial update (e.g. update_user, a POST merge) must be omitted so they
925-
// leave the existing Okta value unchanged rather than overwriting it with
926-
// an empty string. This mirrors the agent tool-call path, which already
927-
// filters empty params before execution.
928-
const skipKeys = new Set([
953+
const mappedKeys = new Set([
929954
'operation',
930955
'apiKey',
931956
'domain',
@@ -935,9 +960,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
935960
'groupDescription',
936961
])
937962
for (const [key, value] of Object.entries(params)) {
938-
if (!skipKeys.has(key) && value !== undefined && value !== null && value !== '') {
939-
result[key] = value
940-
}
963+
if (!mappedKeys.has(key)) result[key] = blankToUndefined(value)
941964
}
942965

943966
return result
@@ -1040,6 +1063,24 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
10401063
name: { type: 'string', description: 'Group name' },
10411064
description: { type: 'string', description: 'Group description' },
10421065
type: { type: 'string', description: 'Group type' },
1066+
mobilePhone: { type: 'string', description: 'Mobile phone number' },
1067+
secondEmail: { type: 'string', description: 'Secondary email address' },
1068+
displayName: { type: 'string', description: 'Display name' },
1069+
title: { type: 'string', description: 'Job title' },
1070+
department: { type: 'string', description: 'Department' },
1071+
organization: { type: 'string', description: 'Organization' },
1072+
manager: { type: 'string', description: 'Manager name' },
1073+
managerId: { type: 'string', description: 'Manager ID' },
1074+
division: { type: 'string', description: 'Division' },
1075+
employeeNumber: { type: 'string', description: 'Employee number' },
1076+
userType: { type: 'string', description: 'User type' },
1077+
lastLogin: { type: 'string', description: 'Last sign-in timestamp' },
1078+
statusChanged: { type: 'string', description: 'Status change timestamp' },
1079+
passwordChanged: { type: 'string', description: 'Password change timestamp' },
1080+
lastMembershipUpdated: {
1081+
type: 'string',
1082+
description: 'Timestamp of the last group membership change',
1083+
},
10431084
count: { type: 'number', description: 'Number of results' },
10441085
added: { type: 'boolean', description: 'Whether user was added to group' },
10451086
removed: { type: 'boolean', description: 'Whether user was removed from group' },
@@ -1090,12 +1131,10 @@ export const OktaBlock: BlockConfig<OktaResponse> = {
10901131
description:
10911132
'Array of group rules (id, name, type, status, expression, assignUserToGroupIds, excludedUserIds)',
10921133
},
1093-
targets: { type: 'json', description: 'Entities a System Log event acted upon' },
10941134
profile: { type: 'json', description: 'Factor, application, or app user profile attributes' },
10951135
settings: { type: 'json', description: 'Application settings' },
10961136
visibility: { type: 'json', description: 'Application visibility settings' },
10971137
accessibility: { type: 'json', description: 'Application accessibility settings' },
1098-
debugData: { type: 'json', description: 'Free-form debug context from a System Log event' },
10991138
assignUserToGroupIds: { type: 'json', description: 'Groups a rule assigns matching users to' },
11001139
excludedUserIds: { type: 'json', description: 'Users excluded from a group rule' },
11011140
excludedGroupIds: { type: 'json', description: 'Groups excluded from a group rule' },
@@ -1239,6 +1278,24 @@ export const OktaBlockMeta = {
12391278
content:
12401279
'# Audit Group Membership\n\nReview who belongs to Okta groups, focusing on privileged access.\n\n## Steps\n1. Run List Groups to enumerate the groups, or Get Group for a specific one.\n2. For each group of interest, run List Group Members.\n3. Highlight privileged or admin groups and call out any unexpected members.\n\n## Output\nA per-group roster with member counts, and a short list of access concerns to review.',
12411280
},
1281+
{
1282+
name: 'reset-user-mfa',
1283+
description: 'Reset a locked-out user MFA enrollment so they can enroll a factor again.',
1284+
content:
1285+
'# Reset User MFA\n\nClear a stuck multifactor enrollment, the most common Okta help desk request.\n\n## Steps\n1. Run List Factors for the user to see which factors are enrolled and their status.\n2. Reset the narrowest thing that fixes it: Reset Factor for one factor id, or Reset All Factors when every enrollment must go.\n3. Note that resetting push also unenrolls the related Okta Verify factors, and that factors cannot be reset on a deactivated user.\n4. Run Clear User Sessions if the user must be signed out of existing sessions before re-enrolling.\n\n## Output\nName the factors that were reset and state that the user must re-enroll before they can complete MFA again. Both resets are irreversible, so confirm the target user before running either.',
1286+
},
1287+
{
1288+
name: 'investigate-sign-in-failures',
1289+
description: 'Query the Okta System Log for failed sign-ins and suspicious authentication.',
1290+
content:
1291+
'# Investigate Sign-In Failures\n\nUse the System Log to explain why a user cannot sign in, or to review suspicious authentication.\n\n## Steps\n1. Run Get System Log Events with a Since and Until that bracket the incident.\n2. Filter to the events that matter, for example eventType eq "user.session.start" and outcome.result eq "FAILURE" for failed sign-ins.\n3. Narrow to one person or origin by adding actor.alternateId or client.ipAddress to the filter, or use the keyword query for a free-text sweep.\n4. Read outcomeReason, clientIpAddress, and the client geography on each event to separate a wrong password from an unexpected location.\n5. Page with the returned cursor while hasMore is true when the window is wide.\n\n## Output\nA short timeline of the matching events with actor, time, outcome, reason, and source IP, plus a plain statement of the likely cause.',
1292+
},
1293+
{
1294+
name: 'review-app-access',
1295+
description: 'Audit who can reach an Okta application through direct and group assignments.',
1296+
content:
1297+
'# Review Application Access\n\nEstablish who has access to an application and how they got it.\n\n## Steps\n1. Run List Applications to resolve the application id, or Get Application when the id is known.\n2. Run List Application Users and read the scope on each assignment: USER means a direct grant, GROUP means it was inherited.\n3. Run List Application Groups to see which groups confer access, since every member of those groups reaches the app.\n4. Expand any group of interest with List Group Members to get the real roster.\n5. Revoke with Remove User from Application for a direct grant, or Remove Group from Application to cut the whole group.\n\n## Output\nA roster split into direct and group-inherited access, naming the groups that grant it, and a list of assignments that look unjustified.',
1298+
},
12421299
{
12431300
name: 'reset-user-password',
12441301
description: 'Trigger an Okta password reset for a user who is locked out.',

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/okta/activate_user.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { validateOktaDomain } from '@/lib/core/security/input-validation'
3-
import type {
4-
OktaActivateUserParams,
5-
OktaActivateUserResponse,
6-
OktaApiError,
7-
} from '@/tools/okta/types'
3+
import type { OktaActivateUserParams, OktaActivateUserResponse } from '@/tools/okta/types'
4+
import { oktaHeaders, throwOktaError } from '@/tools/okta/utils'
85
import type { ToolConfig } from '@/tools/types'
96

107
const logger = createLogger('OktaActivateUser')
@@ -50,23 +47,12 @@ export const oktaActivateUserTool: ToolConfig<OktaActivateUserParams, OktaActiva
5047
return `https://${domain}/api/v1/users/${encodeURIComponent(params.userId.trim())}/lifecycle/activate?sendEmail=${sendEmail}`
5148
},
5249
method: 'POST',
53-
headers: (params) => ({
54-
Authorization: `SSWS ${params.apiKey}`,
55-
Accept: 'application/json',
56-
'Content-Type': 'application/json',
57-
}),
50+
headers: (params) => oktaHeaders(params.apiKey),
5851
},
5952

6053
transformResponse: async (response: Response, params) => {
6154
if (!response.ok) {
62-
let error: OktaApiError = {}
63-
try {
64-
error = await response.json()
65-
} catch {
66-
// empty response body
67-
}
68-
logger.error('Okta API request failed', { data: error, status: response.status })
69-
throw new Error(error.errorSummary || 'Failed to activate user in Okta')
55+
await throwOktaError(response, logger, 'Failed to activate user in Okta')
7056
}
7157

7258
let activationUrl: string | null = null

0 commit comments

Comments
 (0)