Skip to content

Commit e424eea

Browse files
waleedlatif1claude
andcommitted
fix(idc): address cubic review findings
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJV2dCcAYvf1JuxquCMZAd
1 parent 5f455d1 commit e424eea

10 files changed

Lines changed: 177 additions & 16 deletions

File tree

‎apps/docs/content/docs/integrations/identity_center.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ With AWS IAM Identity Center, you can:
2222
- **Monitor assignment status**: Poll the provisioning status of create/delete operations, which are asynchronous in AWS
2323
- **List accounts in your organization**: Enumerate all AWS accounts in your AWS Organizations structure to populate access request dropdowns
2424
- **Manage groups**: List groups, resolve group IDs by display name, and enumerate group memberships for group-based access grants
25-
- **Audit an account's access**: List every assignment on a given AWS account, then resolve each principal ID back to the user or group behind it
25+
- **Audit an account's access**: List the assignments on a given AWS account for one permission set, then resolve each principal ID back to the user or group behind it
2626

2727
In Sim, the AWS Identity Center integration is designed to power **TEAM (Temporary Elevated Access Management)** workflows — automated pipelines where users request elevated access, approvers approve or deny it, access is provisioned with a time limit, and auto-revocation removes it when the window expires. This replaces manual console-based access management with auditable, agent-driven workflows that integrate with Slack, email, ticketing systems, and CloudTrail for full traceability.
2828

2929
The same operations support the reverse direction — access review. Starting from an account, an agent can list its assignments, resolve the principals, expand groups into their members, and produce a written report of exactly who can reach that account and through which permission set.
3030

31+
One detail shapes how that review has to be built. AWS requires a permission set ARN alongside the account ID on this call, so *List Assignments For Account* returns only the assignments granted through that one permission set — not every assignment on the account. To cover an account completely, run *List Permission Sets* for the instance first, then call *List Assignments For Account* once per permission set and combine the results. Skipping that loop silently omits access granted through the permission sets you did not ask about.
32+
3133
Two AWS behaviors are worth knowing. Creating and deleting an account assignment are **asynchronous**: both return a request ID, and each has its own status poller — use *Check Assignment Status* for creations and *Check Assignment Deletion Status* for deletions, as the two request-ID types are not interchangeable. And the account-listing operations call AWS Organizations, which is global per partition; the block resolves the correct endpoint for commercial, GovCloud, and China regions automatically.
3234
{/* MANUAL-CONTENT-END */}
3335

@@ -192,10 +194,10 @@ Resolve an Identity Store user ID to the user behind it. Use to turn the princip
192194
| --------- | ---- | ----------- |
193195
| `userId` | string | Identity Store user ID |
194196
| `userName` | string | Username in the Identity Store |
195-
| `displayName` | string | Display name of the user |
196-
| `email` | string | Primary email address |
197-
| `userStatus` | string | Account status: ENABLED or DISABLED |
198-
| `title` | string | Job title |
197+
| `displayName` | string | Display name of the user, or null when the Identity Store omits it |
198+
| `email` | string | Primary email address, or null when the user has no email attribute |
199+
| `userStatus` | string | Account status \(ENABLED or DISABLED\), or null when the Identity Store omits it |
200+
| `title` | string | Job title, or null when the Identity Store omits it |
199201
| `externalIds` | array | External identity provider IDs linked to the user |
200202
| ↳ `issuer` | string | Identity provider that issued the ID |
201203
| ↳ `id` | string | Identifier at the issuer |

‎apps/sim/blocks/blocks/identity_center.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const IdentityCenterBlock: BlockConfig<IdentityCenterBaseResponse> = {
4545
],
4646
list_assignments_for_account: [
4747
{ text: 'List assignments on account', field: 'accountId', core: true },
48-
{ text: 'for permission set', field: 'permissionSetArn' },
48+
{ text: 'for permission set', field: 'permissionSetArn', core: true },
4949
],
5050
describe_user: [{ text: 'Look up the user with ID', field: 'userId', core: true }],
5151
describe_group: [{ text: 'Look up the group with ID', field: 'groupId', core: true }],
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { awsIdentityCenterListAccountsContract } from '@/lib/api/contracts/tools/aws/identity-center-list-accounts'
6+
import { awsIdentityCenterListAssignmentsForAccountContract } from '@/lib/api/contracts/tools/aws/identity-center-list-assignments-for-account'
7+
8+
const connection = {
9+
region: 'us-east-1',
10+
accessKeyId: 'AKIAEXAMPLE',
11+
secretAccessKey: 'secret',
12+
}
13+
14+
/**
15+
* Organizations documents a 100,000-character maximum for the `ListAccounts`
16+
* continuation token, well past the Identity Store bound the rest of the family
17+
* shares.
18+
*
19+
* @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html
20+
*/
21+
describe('identity center list-accounts nextToken bound', () => {
22+
it('accepts an Organizations token longer than the Identity Store bound', () => {
23+
const parsed = awsIdentityCenterListAccountsContract.body?.safeParse({
24+
...connection,
25+
nextToken: 'a'.repeat(100_000),
26+
})
27+
expect(parsed?.success).toBe(true)
28+
})
29+
30+
it('still rejects a token past the documented Organizations maximum', () => {
31+
const parsed = awsIdentityCenterListAccountsContract.body?.safeParse({
32+
...connection,
33+
nextToken: 'a'.repeat(100_001),
34+
})
35+
expect(parsed?.success).toBe(false)
36+
})
37+
38+
it('leaves the SSO Admin token bound where it was', () => {
39+
const parsed = awsIdentityCenterListAssignmentsForAccountContract.body?.safeParse({
40+
...connection,
41+
instanceArn: 'arn:aws:sso:::instance/ssoins-0123456789abcdef',
42+
accountId: '111111111111',
43+
permissionSetArn: 'arn:aws:sso:::permissionSet/ssoins-0123456789abcdef/ps-0123456789abcdef',
44+
nextToken: 'a'.repeat(100_000),
45+
})
46+
expect(parsed?.success).toBe(false)
47+
})
48+
})

‎apps/sim/lib/api/contracts/tools/aws/identity-center-list-accounts.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { z } from 'zod'
22
import {
33
identityCenterAccountsMaxResultsSchema,
44
identityCenterConnectionShape,
5-
identityCenterNextTokenSchema,
5+
identityCenterOrganizationsNextTokenSchema,
66
} from '@/lib/api/contracts/tools/aws/identity-center-shared'
77
import type {
88
ContractBody,
@@ -14,7 +14,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1414
const Schema = z.object({
1515
...identityCenterConnectionShape,
1616
maxResults: identityCenterAccountsMaxResultsSchema.optional(),
17-
nextToken: identityCenterNextTokenSchema.optional(),
17+
nextToken: identityCenterOrganizationsNextTokenSchema.optional(),
1818
})
1919

2020
const ResponseSchema = z.object({

‎apps/sim/lib/api/contracts/tools/aws/identity-center-shared.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ export const identityCenterNextTokenSchema = z
109109
.min(1, 'Pagination token cannot be empty')
110110
.max(65535, 'Pagination token is too long')
111111

112+
/**
113+
* Organizations pagination tokens are documented far longer than the Identity
114+
* Store bound above, so `ListAccounts` gets its own ceiling rather than sharing
115+
* one that would reject a valid continuation token before AWS sees it.
116+
*
117+
* @see https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListAccounts.html
118+
*/
119+
export const identityCenterOrganizationsNextTokenSchema = z
120+
.string()
121+
.min(1, 'Pagination token cannot be empty')
122+
.max(100000, 'Pagination token is too long')
123+
112124
/** Every list operation in the family except Organizations `ListAccounts`. */
113125
export const identityCenterMaxResultsSchema = z
114126
.number()

‎apps/sim/lib/internal/identity-center/concurrency.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,34 @@ describe('withThrottleRetry', () => {
9393
await expect(withThrottleRetry(operation, controller.signal)).rejects.toThrow()
9494
expect(operation).not.toHaveBeenCalled()
9595
})
96+
97+
it('exits a throttling backoff as soon as the caller aborts', async () => {
98+
const controller = new AbortController()
99+
let calls = 0
100+
let abortedAt = 0
101+
102+
/**
103+
* Aborts on the third attempt, whose pending backoff is at least 640ms
104+
* (200ms base doubled twice, minus the 20% jitter floor). A backoff that
105+
* ignored the signal would therefore keep the caller waiting far longer
106+
* than the assertion below allows.
107+
*/
108+
const operation = vi.fn(async () => {
109+
calls++
110+
if (calls === 3) {
111+
abortedAt = Date.now()
112+
controller.abort()
113+
}
114+
throw throttlingError()
115+
})
116+
117+
const rejection = await withThrottleRetry(operation, controller.signal).catch(
118+
(error: unknown) => error
119+
)
120+
const elapsedSinceAbort = Date.now() - abortedAt
121+
122+
expect((rejection as Error).name).toBe('AbortError')
123+
expect(operation).toHaveBeenCalledTimes(3)
124+
expect(elapsedSinceAbort).toBeLessThan(200)
125+
})
96126
})

‎apps/sim/lib/internal/identity-center/concurrency.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sleep } from '@sim/utils/helpers'
1+
import { interruptibleSleep } from '@sim/utils/helpers'
22
import { backoffWithJitter } from '@sim/utils/retry'
33

44
/**
@@ -34,6 +34,10 @@ function isThrottlingError(error: unknown): boolean {
3434
* Runs an AWS call, retrying with jittered exponential backoff while the
3535
* service reports throttling. Non-throttling failures and aborts propagate
3636
* immediately.
37+
*
38+
* The backoff itself is interruptible: an abort during the wait resolves it
39+
* early, so the next loop iteration's `throwIfAborted` fires without the caller
40+
* having to sit out the remaining delay.
3741
*/
3842
export async function withThrottleRetry<T>(
3943
operation: () => Promise<T>,
@@ -45,7 +49,10 @@ export async function withThrottleRetry<T>(
4549
return await operation()
4650
} catch (error) {
4751
if (attempt >= MAX_THROTTLE_ATTEMPTS || !isThrottlingError(error)) throw error
48-
await sleep(backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 5_000 }))
52+
await interruptibleSleep(
53+
backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 5_000 }),
54+
signal
55+
)
4956
}
5057
}
5158
}

‎apps/sim/tools/generated/tool-outputs.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { describeUserTool } from '@/tools/identity_center/describe_user'
6+
7+
/**
8+
* Identity Store `DescribeUser` marks `DisplayName`, `Emails`, `UserStatus` and
9+
* `Title` optional, so the transform substitutes `null` for each. The output
10+
* metadata has to advertise that same shape.
11+
*
12+
* @see https://docs.aws.amazon.com/singlesignon/latest/IdentityStoreAPIReference/API_DescribeUser.html
13+
*/
14+
const NULLABLE_OUTPUTS = ['displayName', 'email', 'userStatus', 'title'] as const
15+
16+
function jsonResponse(body: unknown): Response {
17+
return new Response(JSON.stringify(body), {
18+
status: 200,
19+
headers: { 'content-type': 'application/json' },
20+
})
21+
}
22+
23+
describe('identity_center_describe_user outputs', () => {
24+
it('keeps the tool id byte-identical', () => {
25+
expect(describeUserTool.id).toBe('identity_center_describe_user')
26+
})
27+
28+
it.each(NULLABLE_OUTPUTS)('declares %s nullable rather than optional', (key) => {
29+
const output = describeUserTool.outputs?.[key]
30+
expect(output).toBeDefined()
31+
expect(output?.nullable).toBe(true)
32+
expect(output?.optional).toBeUndefined()
33+
})
34+
35+
it('emits null for every field the Identity Store omitted', async () => {
36+
const result = await describeUserTool.transformResponse?.(
37+
jsonResponse({ userId: 'u-1', userName: 'someone' })
38+
)
39+
40+
expect(result?.output).toEqual({
41+
userId: 'u-1',
42+
userName: 'someone',
43+
displayName: null,
44+
email: null,
45+
userStatus: null,
46+
title: null,
47+
externalIds: [],
48+
})
49+
})
50+
})

‎apps/sim/tools/identity_center/describe_user.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,26 @@ export const describeUserTool: InternalToolConfig<
7979
outputs: {
8080
userId: { type: 'string', description: 'Identity Store user ID' },
8181
userName: { type: 'string', description: 'Username in the Identity Store' },
82-
displayName: { type: 'string', description: 'Display name of the user', optional: true },
83-
email: { type: 'string', description: 'Primary email address', optional: true },
82+
displayName: {
83+
type: 'string',
84+
description: 'Display name of the user, or null when the Identity Store omits it',
85+
nullable: true,
86+
},
87+
email: {
88+
type: 'string',
89+
description: 'Primary email address, or null when the user has no email attribute',
90+
nullable: true,
91+
},
8492
userStatus: {
8593
type: 'string',
86-
description: 'Account status: ENABLED or DISABLED',
87-
optional: true,
94+
description: 'Account status (ENABLED or DISABLED), or null when the Identity Store omits it',
95+
nullable: true,
96+
},
97+
title: {
98+
type: 'string',
99+
description: 'Job title, or null when the Identity Store omits it',
100+
nullable: true,
88101
},
89-
title: { type: 'string', description: 'Job title', optional: true },
90102
externalIds: {
91103
type: 'array',
92104
description: 'External identity provider IDs linked to the user',

0 commit comments

Comments
 (0)