GeezerVison is showing github.com — fix(okta): make the params transform authoritative over the serialize… · simstudioai/sim@cc1976b
File treeExpand file treeCollapse file tree Open diff view settings docs/content/docs/en/integrations
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: apps/docs/content/docs/en/integrations/okta.mdx +1-1Lines changed: 1 addition & 1 deletion - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -497,7 +497,7 @@ List all members of a specific group in your Okta organization | 497 | 497 | | `domain` | string | Yes | Okta domain \(e.g., dev-123456.okta.com\) | | 498 | 498 | | `groupId` | string | Yes | Group ID to list members for | | 499 | 499 | | `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\) | | 501 | 501 |
| 502 | 502 | #### Output | 503 | 503 |
| |
Collapse file Copy file name to clipboard +80Lines changed: 80 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}) |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/blocks/blocks/okta.ts +83-26Lines changed: 83 additions & 26 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { OktaIcon } from '@/components/icons' | 2 | 2 | import type { BlockConfig, BlockMeta } from '@/blocks/types' | 3 | | -import { IntegrationType } from '@/blocks/types' | | 3 | +import { AuthMode, IntegrationType } from '@/blocks/types' | 4 | 4 | import type { OktaResponse } from '@/tools/okta/types' | 5 | 5 |
| 6 | 6 | /** | 7 | 7 | * Coerces a numeric subBlock value, dropping anything that is not a real number. | 8 | 8 | * | 9 | 9 | * These fields are free-text inputs, so a stray non-numeric entry would otherwise | 10 | 10 | * 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 | 12 | 12 | * apply its own default. | 13 | 13 | */ | 14 | 14 | function toFiniteNumber(value: unknown): number | undefined { | @@ -17,6 +17,11 @@ function toFiniteNumber(value: unknown): number | undefined { | 17 | 17 | return Number.isFinite(parsed) ? parsed : undefined | 18 | 18 | } | 19 | 19 |
| | 20 | +/** Treats a blank subBlock value as absent. */ | | 21 | +function blankToUndefined(value: unknown): unknown { | | 22 | + return value === null || value === '' ? undefined : value | | 23 | +} | | 24 | + | 20 | 25 | export const OktaBlock: BlockConfig<OktaResponse> = { | 21 | 26 | type: 'okta', | 22 | 27 | name: 'Okta', | @@ -25,6 +30,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 25 | 30 | '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.', | 26 | 31 | docsLink: 'https://docs.sim.ai/integrations/okta', | 27 | 32 | category: 'tools', | | 33 | + authMode: AuthMode.ApiKey, | 28 | 34 | integrationType: IntegrationType.Security, | 29 | 35 | bgColor: '#191919', | 30 | 36 | iconColor: '#007DC1', | @@ -248,6 +254,12 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 248 | 254 | field: 'operation', | 249 | 255 | value: ['okta_list_users', 'okta_list_groups', 'okta_list_group_rules'], | 250 | 256 | }, | | 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 | + }, | 251 | 263 | }, | 252 | 264 | { | 253 | 265 | id: 'filter', | @@ -259,6 +271,12 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 259 | 271 | value: ['okta_list_users', 'okta_list_groups', 'okta_list_apps', 'okta_get_logs'], | 260 | 272 | }, | 261 | 273 | 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 | + }, | 262 | 280 | }, | 263 | 281 | { | 264 | 282 | id: 'q', | @@ -510,8 +528,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 510 | 528 | required: { field: 'operation', value: 'okta_create_group_rule' }, | 511 | 529 | wandConfig: { | 512 | 530 | enabled: true, | | 531 | + placeholder: 'Describe which users the rule should match', | 513 | 532 | 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.', | 515 | 534 | }, | 516 | 535 | }, | 517 | 536 | { | @@ -785,7 +804,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 785 | 804 | condition: { field: 'operation', value: 'okta_get_logs' }, | 786 | 805 | wandConfig: { | 787 | 806 | 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.', | 789 | 810 | generationType: 'timestamp', | 790 | 811 | }, | 791 | 812 | }, | @@ -797,7 +818,9 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 797 | 818 | condition: { field: 'operation', value: 'okta_get_logs' }, | 798 | 819 | wandConfig: { | 799 | 820 | 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.', | 801 | 824 | generationType: 'timestamp', | 802 | 825 | }, | 803 | 826 | }, | @@ -904,28 +927,30 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 904 | 927 | ], | 905 | 928 | config: { | 906 | 929 | 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 | + */ | 907 | 942 | params: (params) => { | 908 | 943 | const result: Record<string, unknown> = { | 909 | 944 | apiKey: params.apiKey, | 910 | 945 | 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), | 911 | 951 | } | 912 | 952 |
| 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([ | 929 | 954 | 'operation', | 930 | 955 | 'apiKey', | 931 | 956 | 'domain', | @@ -935,9 +960,7 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 935 | 960 | 'groupDescription', | 936 | 961 | ]) | 937 | 962 | 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) | 941 | 964 | } | 942 | 965 |
| 943 | 966 | return result | @@ -1040,6 +1063,24 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 1040 | 1063 | name: { type: 'string', description: 'Group name' }, | 1041 | 1064 | description: { type: 'string', description: 'Group description' }, | 1042 | 1065 | 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 | + }, | 1043 | 1084 | count: { type: 'number', description: 'Number of results' }, | 1044 | 1085 | added: { type: 'boolean', description: 'Whether user was added to group' }, | 1045 | 1086 | removed: { type: 'boolean', description: 'Whether user was removed from group' }, | @@ -1090,12 +1131,10 @@ export const OktaBlock: BlockConfig<OktaResponse> = { | 1090 | 1131 | description: | 1091 | 1132 | 'Array of group rules (id, name, type, status, expression, assignUserToGroupIds, excludedUserIds)', | 1092 | 1133 | }, | 1093 | | - targets: { type: 'json', description: 'Entities a System Log event acted upon' }, | 1094 | 1134 | profile: { type: 'json', description: 'Factor, application, or app user profile attributes' }, | 1095 | 1135 | settings: { type: 'json', description: 'Application settings' }, | 1096 | 1136 | visibility: { type: 'json', description: 'Application visibility settings' }, | 1097 | 1137 | accessibility: { type: 'json', description: 'Application accessibility settings' }, | 1098 | | - debugData: { type: 'json', description: 'Free-form debug context from a System Log event' }, | 1099 | 1138 | assignUserToGroupIds: { type: 'json', description: 'Groups a rule assigns matching users to' }, | 1100 | 1139 | excludedUserIds: { type: 'json', description: 'Users excluded from a group rule' }, | 1101 | 1140 | excludedGroupIds: { type: 'json', description: 'Groups excluded from a group rule' }, | @@ -1239,6 +1278,24 @@ export const OktaBlockMeta = { | 1239 | 1278 | content: | 1240 | 1279 | '# 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.', | 1241 | 1280 | }, | | 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 | + }, | 1242 | 1299 | { | 1243 | 1300 | name: 'reset-user-password', | 1244 | 1301 | description: 'Trigger an Okta password reset for a user who is locked out.', | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/generated/tool-metadata.ts +1-1Lines changed: 1 addition & 1 deletion Large diffs are not rendered by default. Collapse file Copy file name to clipboardExpand all lines: apps/sim/tools/okta/activate_user.ts +4-18Lines changed: 4 additions & 18 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import { createLogger } from '@sim/logger' | 2 | 2 | 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' | 8 | 5 | import type { ToolConfig } from '@/tools/types' | 9 | 6 |
| 10 | 7 | const logger = createLogger('OktaActivateUser') | @@ -50,23 +47,12 @@ export const oktaActivateUserTool: ToolConfig<OktaActivateUserParams, OktaActiva | 50 | 47 | return `https://${domain}/api/v1/users/${encodeURIComponent(params.userId.trim())}/lifecycle/activate?sendEmail=${sendEmail}` | 51 | 48 | }, | 52 | 49 | 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), | 58 | 51 | }, | 59 | 52 |
| 60 | 53 | transformResponse: async (response: Response, params) => { | 61 | 54 | 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') | 70 | 56 | } | 71 | 57 |
| 72 | 58 | let activationUrl: string | null = null | |
|