Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,22 @@ Current gaps:

</details>

<details>
<summary><strong>Identity</strong></summary>

AWS only, through the generic identity service category.

- List and inspect IAM users.
- Create and delete IAM users.
- IAM user paths are supported during creation.

Current gaps:

- Roles, groups, policies, access keys, and other advanced IAM workflows are not exposed yet.
- No Azure or GCP identity adapter yet.

</details>

<details>
<summary><strong>Serverless</strong></summary>

Expand Down
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dependencies": {
"@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-eks": "^3.1076.0",
"@aws-sdk/client-iam": "^3.1076.0",
"@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1076.0",
Expand Down
126 changes: 126 additions & 0 deletions packages/api/src/adapter-aws/AwsIamAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {describe, expect, test} from 'bun:test'
import {
CreateUserCommand,
DeleteUserCommand,
GetUserCommand,
ListUsersCommand,
type IAMClient,
} from '@aws-sdk/client-iam'
import {AwsIamAdapter} from './AwsIamAdapter'

function fakeClient(handler: (command: unknown) => unknown): IAMClient {
return {
send: async (command: unknown) => handler(command),
} as unknown as IAMClient
}

const alice = {
UserName: 'alice',
UserId: 'AIDAALICE',
Arn: 'arn:aws:iam::000000000000:user/team/alice',
Path: '/team/',
CreateDate: new Date('2026-01-02T03:04:05.000Z'),
}

describe('AwsIamAdapter', () => {
test('lists every page and maps IAM users to normalized resources', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
if (!(command instanceof ListUsersCommand)) throw new Error('Unexpected command')
if (!command.input.Marker) return {Users: [alice], IsTruncated: true, Marker: 'next'}
return {Users: [{...alice, UserName: 'bob', UserId: 'AIDABOB'}], IsTruncated: false}
}))

const result = await adapter.list()

expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
id: 'alice',
name: 'alice',
cloud: 'aws',
service: 'identity',
type: 'iam-user',
region: null,
createdAt: '2026-01-02T03:04:05.000Z',
})
expect(result[0].metadata).toMatchObject({
identityService: 'iam',
userId: 'AIDAALICE',
path: '/team/',
})
})

test('filters users by search term', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => ({
Users: [alice, {...alice, UserName: 'bob'}],
IsTruncated: false,
})))

const result = await adapter.list({search: 'ALI'})

expect(result.map((resource) => resource.name)).toEqual(['alice'])
})

test('gets and maps one IAM user', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(GetUserCommand)
return {User: alice}
}))

const result = await adapter.get('alice')

expect(result?.id).toBe('alice')
expect(result?.metadata.arn).toBe(alice.Arn)
})

test('returns null when IAM reports a missing user', async () => {
const adapter = new AwsIamAdapter(fakeClient(() => {
throw Object.assign(new Error('missing'), {name: 'NoSuchEntityException'})
}))

await expect(adapter.get('missing')).resolves.toBeNull()
})

test('creates an IAM user with an optional path', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(CreateUserCommand)
expect((command as CreateUserCommand).input).toEqual({UserName: 'alice', Path: '/team/'})
return {User: alice}
}))

const result = await adapter.create({values: {userName: 'alice', path: '/team/'}})

expect(result.id).toBe('alice')
})

test('rejects invalid user names before calling IAM', async () => {
let called = false
const adapter = new AwsIamAdapter(fakeClient(() => {
called = true
return {}
}))

await expect(adapter.create({values: {userName: 'not valid'}})).rejects.toThrow('Use 1-64')
expect(called).toBeFalse()
})

test('deletes the requested IAM user', async () => {
const adapter = new AwsIamAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteUserCommand)
expect((command as DeleteUserCommand).input.UserName).toBe('alice')
return {}
}))

await adapter.delete('alice')
})

test('returns the AWS IAM schema', () => {
const adapter = new AwsIamAdapter(fakeClient(() => ({})))

expect(adapter.schema()).toMatchObject({
cloud: 'aws',
service: 'identity',
displayName: 'AWS IAM users',
actions: ['list', 'create', 'delete', 'inspect'],
})
})
})
106 changes: 106 additions & 0 deletions packages/api/src/adapter-aws/AwsIamAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
CreateUserCommand,
DeleteUserCommand,
GetUserCommand,
ListUsersCommand,
type IAMClient,
type User,
} from '@aws-sdk/client-iam'
import {iam as defaultIam} from '../aws'
import {awsIamSchema} from '../cloud-spi/iamSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'

export class AwsIamAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'identity' as const

constructor(private readonly iam: IAMClient = defaultIam) {}

schema(): ServiceSchema {
return awsIamSchema()
}

async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const users: User[] = []
let marker: string | undefined

do {
const res = await this.iam.send(new ListUsersCommand({Marker: marker}))
users.push(...(res.Users ?? []))
marker = res.IsTruncated ? res.Marker : undefined
} while (marker)

return filterBySearch(users.map(toResource), query.search)
}

async get(id: string): Promise<CloudResource | null> {
try {
const res = await this.iam.send(new GetUserCommand({UserName: id}))
return res.User ? toResource(res.User) : null
} catch (error) {
if (isNotFound(error)) return null
throw error
}
}

async create(input: CreateResourceInput): Promise<CloudResource> {
const userName = stringValue(input.values.userName)
const path = stringValue(input.values.path)
if (!userName) throw new Error('userName is required')
if (!/^[A-Za-z0-9_+=,.@-]{1,64}$/.test(userName)) {
throw new Error('Use 1-64 letters, numbers, and +=,.@_- characters.')
}
if (path && (path.length > 512 || !/^\/(?:[!-~]+\/)?$/.test(path))) {
throw new Error('Path must begin and end with / and contain only printable ASCII characters.')
}

const res = await this.iam.send(new CreateUserCommand({
UserName: userName,
Path: path || undefined,
}))
if (!res.User) throw new Error('AWS IAM did not return the created user')
return toResource(res.User)
}

async delete(id: string): Promise<void> {
await this.iam.send(new DeleteUserCommand({UserName: id}))
}
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 delete fails silently for users with attached resources

AWS IAM DeleteUser raises DeleteConflictException if the user still has group memberships, access keys, signing certificates, MFA devices, or inline/attached policies. The adapter issues DeleteUserCommand with no pre-flight cleanup and no dedicated error handling. normalizeRuntimeError won't match the exception and maps it to a generic 502 response, so the UI shows "Runtime request failed" rather than anything actionable. Even in a local emulator context, users imported from fixtures or created outside this UI will often have access keys, making delete consistently unusable without a clearer error path.

}

function toResource(user: User): CloudResource {
const userName = user.UserName ?? ''
return {
id: userName,
name: userName,
cloud: 'aws',
service: 'identity',
type: 'iam-user',
region: null,
createdAt: user.CreateDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
identityService: 'iam',
userId: user.UserId,
arn: user.Arn,
path: user.Path,
permissionsBoundary: user.PermissionsBoundary,
tags: user.Tags,
},
}
}

function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}

function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized))
}

function isNotFound(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const candidate = error as {name?: string; $metadata?: {httpStatusCode?: number}}
return candidate.name === 'NoSuchEntityException' || candidate.$metadata?.httpStatusCode === 404
}
4 changes: 4 additions & 0 deletions packages/api/src/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { EKSClient } from "@aws-sdk/client-eks";
import { EC2Client } from "@aws-sdk/client-ec2";
import { RDSClient } from "@aws-sdk/client-rds";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { IAMClient } from "@aws-sdk/client-iam";

const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1";
Expand Down Expand Up @@ -39,6 +40,7 @@ export type AwsClients = {
ec2: EC2Client;
rds: RDSClient;
secretsManager: SecretsManagerClient;
iam: IAMClient;
};

export type AwsClientName = keyof AwsClients;
Expand All @@ -58,6 +60,7 @@ function buildClients(accountId: string): AwsClients {
ec2: new EC2Client(base),
rds: new RDSClient(base),
secretsManager: new SecretsManagerClient(base),
iam: new IAMClient(base),
};
}

Expand Down Expand Up @@ -89,3 +92,4 @@ export const eks = awsClients.eks;
export const ec2 = awsClients.ec2;
export const rds = awsClients.rds;
export const secretsManager = awsClients.secretsManager;
export const iam = awsClients.iam;
55 changes: 55 additions & 0 deletions packages/api/src/cloud-spi/iamSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {CapabilitySchema, CloudProvider, ResourceActionName, ServiceSchema} from './types'

const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect user', enabled: true, status: 'available', runtimeRequired: false},
]

export function awsIamSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'identity',
displayName: 'AWS IAM users',
fields: [
{
name: 'userName',
label: 'User Name',
type: 'text',
required: true,
description: '1-64 letters, numbers, and +=,.@_- characters.',
validation: {
pattern: '^[A-Za-z0-9_+=,.@-]+$',
minLength: 1,
maxLength: 64,
message: 'Use 1-64 letters, numbers, and +=,.@_- characters.',
},
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
{
name: 'path',
label: 'Path',
type: 'text',
required: false,
description: 'Optional IAM path, beginning and ending with /. Defaults to /.',
validation: {
pattern: '^/(?:[!-~]+/)?$',
maxLength: 512,
message: 'Path must begin and end with / and contain only printable ASCII characters.',
},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'User Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
],
}
}

export function iamSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsIamSchema() : null
}
4 changes: 2 additions & 2 deletions packages/api/src/cloud-spi/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type CloudProvider = 'aws' | 'azure' | 'gcp'

export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking'
export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' | 'identity'

export type CloudAvailability = 'available' | 'coming_soon'

Expand Down Expand Up @@ -83,7 +83,7 @@ export interface CloudResource {
name: string
cloud: CloudProvider
service: CloudServiceType
type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'iam-user'
region: string | null
createdAt: string | null
status?: string | null
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/cloudProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter'
import {CloudProxyService} from './service/CloudProxyService'
import {AzureServerlessAdapter} from './adapter-azure/AzureServerlessAdapter'
import {AwsServerlessAdapter} from './adapter-aws/AwsServerlessAdapter'
import {AwsIamAdapter} from './adapter-aws/AwsIamAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
Expand All @@ -33,6 +34,7 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe
new AwsComputeAdapter(ec2Service),
new AwsNetworkingAdapter(ec2Service),
new AwsServerlessAdapter(clients.lambda),
new AwsIamAdapter(clients.iam),
new AzureStorageAdapter(),
new AzureDatabaseAdapter(),
new GcpStorageAdapter(),
Expand Down
Loading