@cloud-copilot/iam-utils 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@cloud-copilot/iam-utils",
3
+ "version": "0.0.1",
4
+ "description": "",
5
+ "keywords": [
6
+ "aws",
7
+ "iam",
8
+ "utils"
9
+ ],
10
+ "homepage": "https://github.com/cloud-copilot/iam-utils#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/cloud-copilot/iam-utils/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+ssh://git@github.com/cloud-copilot/iam-utils.git"
17
+ },
18
+ "license": "AGPL-3.0-or-later",
19
+ "author": "David Kerber <dave@cloudcopilot.io>",
20
+ "scripts": {
21
+ "build": "npx tsc -p tsconfig.cjs.json && npx tsc -p tsconfig.esm.json && ./postbuild.sh",
22
+ "clean": "rm -rf dist",
23
+ "test": "npx vitest --run --coverage",
24
+ "release": "npm install && npm run clean && npm run build && npm test && npm run format-check && npm publish",
25
+ "format": "npx prettier --write src/",
26
+ "format-check": "npx prettier --check src/"
27
+ },
28
+ "devDependencies": {
29
+ "@cloud-copilot/prettier-config": "^0.1.1",
30
+ "@semantic-release/changelog": "^6.0.3",
31
+ "@semantic-release/commit-analyzer": "^13.0.1",
32
+ "@semantic-release/git": "^10.0.1",
33
+ "@semantic-release/github": "^11.0.1",
34
+ "@semantic-release/npm": "^12.0.1",
35
+ "@semantic-release/release-notes-generator": "^14.0.3",
36
+ "@types/node": "^22.5.0",
37
+ "@vitest/coverage-v8": "^3.0.7",
38
+ "semantic-release": "^24.2.1",
39
+ "typescript": "^5.5.4",
40
+ "vitest": "^3.0.7"
41
+ },
42
+ "prettier": "@cloud-copilot/prettier-config",
43
+ "release": {
44
+ "branches": [
45
+ "main"
46
+ ],
47
+ "plugins": [
48
+ [
49
+ "@semantic-release/commit-analyzer",
50
+ {
51
+ "releaseRules": [
52
+ {
53
+ "type": "feat",
54
+ "release": "patch"
55
+ },
56
+ {
57
+ "type": "fix",
58
+ "release": "patch"
59
+ },
60
+ {
61
+ "breaking": true,
62
+ "release": "patch"
63
+ },
64
+ {
65
+ "type": "*",
66
+ "release": "patch"
67
+ }
68
+ ]
69
+ }
70
+ ],
71
+ "@semantic-release/release-notes-generator",
72
+ "@semantic-release/changelog",
73
+ [
74
+ "@semantic-release/npm",
75
+ {
76
+ "npmPublish": true
77
+ }
78
+ ],
79
+ [
80
+ "@semantic-release/git",
81
+ {
82
+ "assets": [
83
+ "package.json",
84
+ "package-lock.json",
85
+ "CHANGELOG.md"
86
+ ],
87
+ "message": "chore(release): ${nextRelease.version} [skip ci]"
88
+ }
89
+ ],
90
+ [
91
+ "@semantic-release/github",
92
+ {
93
+ "assets": []
94
+ }
95
+ ]
96
+ ]
97
+ }
98
+ }
package/postbuild.sh ADDED
@@ -0,0 +1,11 @@
1
+ cat >dist/cjs/package.json <<!EOF
2
+ {
3
+ "type": "commonjs"
4
+ }
5
+ !EOF
6
+
7
+ cat >dist/esm/package.json <<!EOF
8
+ {
9
+ "type": "module"
10
+ }
11
+ !EOF
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ArnParts, splitArnParts } from './arn.js'
3
+
4
+ const splitArnPartsTests: {
5
+ name: string
6
+ arn: string
7
+ expected: ArnParts
8
+ only?: boolean
9
+ }[] = [
10
+ {
11
+ name: 'should split a full ARN',
12
+ arn: 'arn:aws:iam::123456789012:user/Development/user1',
13
+ expected: {
14
+ partition: 'aws',
15
+ service: 'iam',
16
+ region: '',
17
+ accountId: '123456789012',
18
+ resource: 'user/Development/user1',
19
+ resourceType: 'user',
20
+ resourcePath: 'Development/user1'
21
+ }
22
+ },
23
+ {
24
+ name: 'should split an S3 bucket ARN',
25
+ arn: 'arn:aws:s3:::my_corporate_bucket',
26
+ expected: {
27
+ partition: 'aws',
28
+ service: 's3',
29
+ region: '',
30
+ accountId: '',
31
+ resource: 'my_corporate_bucket',
32
+ resourceType: '',
33
+ resourcePath: 'my_corporate_bucket'
34
+ }
35
+ },
36
+ {
37
+ name: 'should split an S3 object ARN',
38
+ arn: 'arn:aws:s3:::my_corporate_bucket/my_corporate_object.txt',
39
+ expected: {
40
+ partition: 'aws',
41
+ service: 's3',
42
+ region: '',
43
+ accountId: '',
44
+ resource: 'my_corporate_bucket/my_corporate_object.txt',
45
+ resourceType: '',
46
+ resourcePath: 'my_corporate_bucket/my_corporate_object.txt'
47
+ }
48
+ },
49
+ {
50
+ name: 'should split an SNS topic ARN',
51
+ arn: 'arn:aws:sns:us-east-1:123456789012:MyTopic',
52
+ expected: {
53
+ partition: 'aws',
54
+ service: 'sns',
55
+ region: 'us-east-1',
56
+ accountId: '123456789012',
57
+ resource: 'MyTopic',
58
+ resourceType: '',
59
+ resourcePath: 'MyTopic'
60
+ }
61
+ },
62
+ {
63
+ name: 'should split an SQS queue ARN',
64
+ arn: 'arn:aws:sqs:us-east-1:123456789012:MyQueue',
65
+ expected: {
66
+ partition: 'aws',
67
+ service: 'sqs',
68
+ region: 'us-east-1',
69
+ accountId: '123456789012',
70
+ resource: 'MyQueue',
71
+ resourceType: '',
72
+ resourcePath: 'MyQueue'
73
+ }
74
+ },
75
+ {
76
+ name: 'should split a Lambda function ARN',
77
+ arn: 'arn:aws:lambda:us-west-2:123456789012:function:my-function',
78
+ expected: {
79
+ partition: 'aws',
80
+ service: 'lambda',
81
+ region: 'us-west-2',
82
+ accountId: '123456789012',
83
+ resource: 'function:my-function',
84
+ resourceType: 'function',
85
+ resourcePath: 'my-function'
86
+ }
87
+ },
88
+ {
89
+ name: 'rest api ARN',
90
+ arn: 'arn:aws:apigateway:us-east-1::/restapis/1234567890',
91
+ expected: {
92
+ partition: 'aws',
93
+ service: 'apigateway',
94
+ region: 'us-east-1',
95
+ accountId: '',
96
+ resource: '/restapis/1234567890',
97
+ resourceType: 'restapis',
98
+ resourcePath: '1234567890'
99
+ }
100
+ },
101
+ {
102
+ name: 'should split a glue root catalog ARN',
103
+ arn: 'arn:aws:glue:us-east-1:111111111111:catalog',
104
+ expected: {
105
+ partition: 'aws',
106
+ service: 'glue',
107
+ region: 'us-east-1',
108
+ accountId: '111111111111',
109
+ resource: 'catalog',
110
+ resourceType: 'catalog',
111
+ resourcePath: ''
112
+ }
113
+ }
114
+ ]
115
+
116
+ describe('splitArnParts', () => {
117
+ for (const test of splitArnPartsTests) {
118
+ const { name, arn, expected, only } = test
119
+ const testFn = only ? it.only : it
120
+
121
+ testFn(name, () => {
122
+ const result = splitArnParts(arn)
123
+ expect(result).toEqual(expected)
124
+ })
125
+ }
126
+ })
package/src/arn.ts ADDED
@@ -0,0 +1,81 @@
1
+ export interface ArnParts {
2
+ partition: string | undefined
3
+ service: string | undefined
4
+ region: string | undefined
5
+ accountId: string | undefined
6
+ resource: string | undefined
7
+ resourceType: string | undefined
8
+ resourcePath: string | undefined
9
+ }
10
+
11
+ /**
12
+ * Split an ARN into its parts
13
+ *
14
+ * @param arn the arn to split
15
+ * @returns the parts of the ARN
16
+ */
17
+ export function splitArnParts(arn: string): ArnParts {
18
+ const parts = arn.split(':')
19
+ const partition = parts.at(1)
20
+ const service = parts.at(2)!
21
+ const region = parts.at(3)!
22
+ const accountId = parts.at(4)!
23
+ const resource = parts.slice(5).join(':')
24
+ const [resourceType, resourcePath] = getResourceSegments(service, accountId, region, resource)
25
+
26
+ return {
27
+ partition,
28
+ service,
29
+ region,
30
+ accountId,
31
+ resource,
32
+ resourceType,
33
+ resourcePath
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Get the product/id segments of the resource portion of an ARN.
39
+ * The first segment is the product segment and the second segment is the resource id segment.
40
+ * This could be split by a colon or a slash, so it checks for both. It also checks for S3 buckets/objects.
41
+ *
42
+ * @param resource The resource to get the resource segments. Must be an ARN resource.
43
+ * @returns a tuple with the first segment being the product segment (without the separator) and the second segment being the resource id.
44
+ */
45
+ export function getResourceSegments(
46
+ service: string,
47
+ accountId: string,
48
+ region: string,
49
+ resourceString: string
50
+ ): [string, string] {
51
+ // This is terrible, and I hate it
52
+ if (
53
+ (service === 's3' && accountId === '' && region === '') ||
54
+ service === 'sns' ||
55
+ service === 'sqs'
56
+ ) {
57
+ return ['', resourceString]
58
+ }
59
+
60
+ if (resourceString.startsWith('/')) {
61
+ resourceString = resourceString.slice(1)
62
+ }
63
+
64
+ const slashIndex = resourceString.indexOf('/')
65
+ const colonIndex = resourceString.indexOf(':')
66
+
67
+ let splitIndex = slashIndex
68
+ if (slashIndex != -1 && colonIndex != -1) {
69
+ splitIndex = Math.min(slashIndex, colonIndex) + 1
70
+ } else if (slashIndex == -1 && colonIndex == -1) {
71
+ splitIndex = resourceString.length + 1
72
+ } else if (colonIndex == -1) {
73
+ splitIndex = slashIndex + 1
74
+ } else if (slashIndex == -1) {
75
+ splitIndex = colonIndex + 1
76
+ } else {
77
+ throw new Error(`Unable to split resource ${resourceString}`)
78
+ }
79
+
80
+ return [resourceString.slice(0, splitIndex - 1), resourceString.slice(splitIndex)]
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { getResourceSegments, splitArnParts, type ArnParts } from './arn'
2
+ export {
3
+ convertAssumedRoleArnToRoleArn,
4
+ convertRoleArnToAssumedRoleArn,
5
+ isAssumedRoleArn,
6
+ isIamUserArn
7
+ } from './principals'
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ convertAssumedRoleArnToRoleArn,
4
+ convertRoleArnToAssumedRoleArn,
5
+ isAssumedRoleArn,
6
+ isFederatedUserArn,
7
+ isIamUserArn
8
+ } from './principals.js'
9
+
10
+ describe('convertAssumedRoleArnToRoleArn', () => {
11
+ it('should return the role ARN from an assumed role ARN', () => {
12
+ //Given an assumed role ARN
13
+ const assumedRoleArn = 'arn:aws:sts::123456789012:assumed-role/role-name/session-name'
14
+
15
+ //When we get the role ARN from the assumed role ARN
16
+ const result = convertAssumedRoleArnToRoleArn(assumedRoleArn)
17
+
18
+ //Then it should return the role ARN
19
+ expect(result).toBe('arn:aws:iam::123456789012:role/role-name')
20
+ })
21
+
22
+ it('should return the role ARN from an assumed role ARN with a path', () => {
23
+ //Given an assumed role ARN
24
+ const assumedRoleArn = 'arn:aws:sts::123456789012:assumed-role/admin/global-admin/session-name'
25
+
26
+ //When we get the role ARN from the assumed role ARN
27
+ const result = convertAssumedRoleArnToRoleArn(assumedRoleArn)
28
+
29
+ //Then it should return the role ARN
30
+ expect(result).toBe('arn:aws:iam::123456789012:role/admin/global-admin')
31
+ })
32
+
33
+ it('should work in a different partition', () => {
34
+ //Given an assumed role ARN with a different partition
35
+ const assumedRoleArn = 'arn:aws-cn:sts::123456789012:assumed-role/role-name/session-name'
36
+
37
+ //When we get the role ARN from the assumed role ARN
38
+ const result = convertAssumedRoleArnToRoleArn(assumedRoleArn)
39
+
40
+ //Then it should return the role ARN
41
+ expect(result).toBe('arn:aws-cn:iam::123456789012:role/role-name')
42
+ })
43
+ })
44
+
45
+ describe('convertRoleArnToAssumedRoleArn', () => {
46
+ it('should return the assumed role ARN from a role ARN', () => {
47
+ //Given a role ARN
48
+ const roleArn = 'arn:aws:iam::123456789012:role/role-name'
49
+
50
+ //When we get the assumed role ARN from the role ARN
51
+ const result = convertRoleArnToAssumedRoleArn(roleArn, 'session-name')
52
+
53
+ //Then it should return the assumed role ARN
54
+ expect(result).toBe('arn:aws:sts::123456789012:assumed-role/role-name/session-name')
55
+ })
56
+
57
+ it('should return the assumed role ARN from a role ARN with a path', () => {
58
+ //Given a role ARN
59
+ const roleArn = 'arn:aws:iam::123456789012:role/admin/global-admin'
60
+
61
+ //When we get the assumed role ARN from the role ARN
62
+ const result = convertRoleArnToAssumedRoleArn(roleArn, 'session-name')
63
+
64
+ //Then it should return the assumed role ARN
65
+ expect(result).toBe('arn:aws:sts::123456789012:assumed-role/admin/global-admin/session-name')
66
+ })
67
+
68
+ it('should work with a different partition', () => {
69
+ //Given a role ARN with a different partition
70
+ const roleArn = 'arn:aws-cn:iam::123456789012:role/role-name'
71
+
72
+ //When we get the assumed role ARN from the role ARN
73
+ const result = convertRoleArnToAssumedRoleArn(roleArn, 'session-name')
74
+
75
+ //Then it should return the assumed role ARN
76
+ expect(result).toBe('arn:aws-cn:sts::123456789012:assumed-role/role-name/session-name')
77
+ })
78
+ })
79
+
80
+ describe('isAssumedRoleArn', () => {
81
+ it('should return true for assumed role ARN', () => {
82
+ //Given an assumed role ARN
83
+ const assumedRoleArn = 'arn:aws:sts::123456789012:assumed-role/role-name/session-name'
84
+
85
+ //When we check if it is an assumed role ARN
86
+ const result = isAssumedRoleArn(assumedRoleArn)
87
+
88
+ //Then it should return true
89
+ expect(result).toBe(true)
90
+ })
91
+
92
+ it('should return false for non-assumed role ARN', () => {
93
+ //Given a non-assumed role ARN
94
+ const userArn = 'arn:aws:iam::123456789012:user/user-name'
95
+
96
+ //When we check if it is an assumed role ARN
97
+ const result = isAssumedRoleArn(userArn)
98
+
99
+ //Then it should return false
100
+ expect(result).toBe(false)
101
+ })
102
+
103
+ it('should work for a different partition', () => {
104
+ //Given an assumed role ARN with a different partition
105
+ const assumedRoleArn = 'arn:aws-cn:sts::123456789012:assumed-role/role-name/session-name'
106
+
107
+ //When we check if it is an assumed role ARN
108
+ const result = isAssumedRoleArn(assumedRoleArn)
109
+
110
+ //Then it should return true
111
+ expect(result).toBe(true)
112
+ })
113
+ })
114
+
115
+ describe('isIamUserArn', () => {
116
+ it('should return true for IAM user ARN', () => {
117
+ //Given an IAM user ARN
118
+ const userArn = 'arn:aws:iam::123456789012:user/user-name'
119
+
120
+ //When we check if it is an IAM user ARN
121
+ const result = isIamUserArn(userArn)
122
+
123
+ //Then it should return true
124
+ expect(result).toBe(true)
125
+ })
126
+
127
+ it('should return false for non-IAM user ARN', () => {
128
+ //Given a non-IAM user ARN
129
+ const roleArn = 'arn:aws:sts::123456789012:assumed-role/role-name/session-name'
130
+
131
+ //When we check if it is an IAM user ARN
132
+ const result = isIamUserArn(roleArn)
133
+
134
+ //Then it should return false
135
+ expect(result).toBe(false)
136
+ })
137
+
138
+ it('should work for a different partition', () => {
139
+ //Given an IAM user ARN with a different partition
140
+ const userArn = 'arn:aws-cn:iam::123456789012:user/user-name'
141
+
142
+ //When we check if it is an IAM user ARN
143
+ const result = isIamUserArn(userArn)
144
+
145
+ //Then it should return true
146
+ expect(result).toBe(true)
147
+ })
148
+ })
149
+
150
+ describe('isFederatedUserArn', () => {
151
+ it('should return true for federated user ARN', () => {
152
+ //Given a federated user ARN
153
+ const federatedUserArn = 'arn:aws:sts::123456789012:federated-user/user-name'
154
+
155
+ //When we check if it is a federated user ARN
156
+ const result = isFederatedUserArn(federatedUserArn)
157
+
158
+ //Then it should return true
159
+ expect(result).toBe(true)
160
+ })
161
+
162
+ it('should return false for non-federated user ARN', () => {
163
+ //Given a non-federated user ARN
164
+ const roleArn = 'arn:aws:sts::123456789012:assumed-role/role-name/session-name'
165
+
166
+ //When we check if it is a federated user ARN
167
+ const result = isFederatedUserArn(roleArn)
168
+
169
+ //Then it should return false
170
+ expect(result).toBe(false)
171
+ })
172
+
173
+ it('should work for a different partition', () => {
174
+ //Given a federated user ARN with a different partition
175
+ const federatedUserArn = 'arn:aws-cn:sts::123456789012:federated-user/user-name'
176
+
177
+ //When we check if it is a federated user ARN
178
+ const result = isFederatedUserArn(federatedUserArn)
179
+
180
+ //Then it should return true
181
+ expect(result).toBe(true)
182
+ })
183
+ })
@@ -0,0 +1,62 @@
1
+ import { splitArnParts } from './arn.js'
2
+
3
+ /**
4
+ * Transform an assumed role session ARN into a role ARN
5
+ *
6
+ * @param assumedRoleArn the assumed role session ARN
7
+ * @returns the role ARN for the assumed role session
8
+ */
9
+ export function convertAssumedRoleArnToRoleArn(assumedRoleArn: string): string {
10
+ const arnParts = splitArnParts(assumedRoleArn)
11
+ const rolePathAndName = arnParts.resourcePath?.split('/').slice(0, -1).join('/')
12
+ return `arn:${arnParts.partition}:iam::${arnParts.accountId}:role/${rolePathAndName}`
13
+ }
14
+
15
+ /**
16
+ * Create an assumed role ARN from a role ARN and a session name
17
+ *
18
+ * @param roleArn the role ARN to create an assumed role ARN from
19
+ * @param sessionName the session name to use
20
+ * @returns the assumed role ARN
21
+ */
22
+ export function convertRoleArnToAssumedRoleArn(roleArn: string, sessionName: string): string {
23
+ const arnParts = splitArnParts(roleArn)
24
+ const rolePathAndName = arnParts.resourcePath
25
+ return `arn:${arnParts.partition}:sts::${arnParts.accountId}:assumed-role/${rolePathAndName}/${sessionName}`
26
+ }
27
+
28
+ const assumedRoleArnRegex = /^arn:[a-zA-Z\-]+:sts::\d{12}:assumed-role\/.*$/
29
+
30
+ /**
31
+ * Tests if a principal string is an assumed role ARN
32
+ *
33
+ * @param principal the principal string to test
34
+ * @returns true if the principal is an assumed role ARN, false otherwise
35
+ */
36
+ export function isAssumedRoleArn(principal: string): boolean {
37
+ return assumedRoleArnRegex.test(principal)
38
+ }
39
+
40
+ const userArnRegex = /^arn:[a-zA-Z\-]+:iam::\d{12}:user\/.*$/
41
+
42
+ /**
43
+ * Test if a principal string is an IAM user ARN
44
+ *
45
+ * @param principal the principal string to test
46
+ * @returns true if the principal is an IAM user ARN, false otherwise
47
+ */
48
+ export function isIamUserArn(principal: string): boolean {
49
+ return userArnRegex.test(principal)
50
+ }
51
+
52
+ const federatedUserArnRegex = /^arn:[a-zA-Z\-]+:sts::\d{12}:federated-user\/.*$/
53
+
54
+ /**
55
+ * Test if a principal string is a federated user ARN
56
+ *
57
+ * @param principal the principal string to test
58
+ * @returns true if the principal is a federated user ARN, false otherwise
59
+ */
60
+ export function isFederatedUserArn(principal: string): boolean {
61
+ return federatedUserArnRegex.test(principal)
62
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+
4
+ "include": ["src/**/*"],
5
+ "exclude": ["**/*.test.ts"],
6
+
7
+ "compilerOptions": {
8
+ "rootDir": "src",
9
+ "outDir": "dist/cjs",
10
+ }
11
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+
4
+ "include": ["src/**/*"],
5
+ "exclude": ["**/*.test.ts"],
6
+
7
+ "compilerOptions": {
8
+ "target": "ES2020",
9
+ "module": "ES2020",
10
+ "moduleResolution": "node",
11
+ "rootDir": "src",
12
+ "outDir": "dist/esm"
13
+ }
14
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "es2022",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "sourceMap": true,
8
+ "strict": true,
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "lib": ["es2023", "DOM"],
12
+ "noUnusedLocals": false,
13
+ "noUnusedParameters": false,
14
+ "noImplicitReturns": true,
15
+ "noFallthroughCasesInSwitch": false,
16
+ "experimentalDecorators": true,
17
+ "emitDecoratorMetadata": true,
18
+ "esModuleInterop": false,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "paths": {
21
+ // workaround for: https://github.com/vitest-dev/vitest/issues/4567
22
+ "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
23
+ }
24
+ },
25
+ "exclude": ["tests", "test", "dist", "bin", "**/bin", "**/dist", "node_modules", "cdk.out"],
26
+ }