@flowcore/cli-plugin-iam 1.0.0 → 1.2.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.
@@ -0,0 +1,82 @@
1
+ import type { Logger } from "@flowcore/cli-plugin-config";
2
+ import { z } from "zod";
3
+ import type { Api as IamApi } from "../utils/clients/iam/Api.js";
4
+ export declare const roleBindingDto: z.ZodObject<z.objectUtil.extendShape<{
5
+ apiVersion: z.ZodString;
6
+ kind: z.ZodString;
7
+ metadata: z.ZodObject<{
8
+ name: z.ZodString;
9
+ tenant: z.ZodString;
10
+ }, "strip", z.ZodTypeAny, {
11
+ name: string;
12
+ tenant: string;
13
+ }, {
14
+ name: string;
15
+ tenant: string;
16
+ }>;
17
+ }, {
18
+ spec: z.ZodObject<{
19
+ role: z.ZodString;
20
+ subjects: z.ZodArray<z.ZodObject<{
21
+ id: z.ZodString;
22
+ type: z.ZodEnum<["user", "key"]>;
23
+ }, "strip", z.ZodTypeAny, {
24
+ id: string;
25
+ type: "key" | "user";
26
+ }, {
27
+ id: string;
28
+ type: "key" | "user";
29
+ }>, "many">;
30
+ }, "strip", z.ZodTypeAny, {
31
+ subjects: {
32
+ id: string;
33
+ type: "key" | "user";
34
+ }[];
35
+ role: string;
36
+ }, {
37
+ subjects: {
38
+ id: string;
39
+ type: "key" | "user";
40
+ }[];
41
+ role: string;
42
+ }>;
43
+ }>, "strip", z.ZodTypeAny, {
44
+ kind: string;
45
+ apiVersion: string;
46
+ metadata: {
47
+ name: string;
48
+ tenant: string;
49
+ };
50
+ spec: {
51
+ subjects: {
52
+ id: string;
53
+ type: "key" | "user";
54
+ }[];
55
+ role: string;
56
+ };
57
+ }, {
58
+ kind: string;
59
+ apiVersion: string;
60
+ metadata: {
61
+ name: string;
62
+ tenant: string;
63
+ };
64
+ spec: {
65
+ subjects: {
66
+ id: string;
67
+ type: "key" | "user";
68
+ }[];
69
+ role: string;
70
+ };
71
+ }>;
72
+ export type RoleBinding = z.infer<typeof roleBindingDto>;
73
+ export type ApiRoleResource = Awaited<ReturnType<IamApi["getApiV1RoleAssociationsOrganizationByOrganizationId"]>>["data"][0];
74
+ export type ApiRoleAssociationResource = Awaited<ReturnType<IamApi["getApiV1RoleAssociationsByRoleId"]>>["data"];
75
+ export declare class RoleBindingService {
76
+ private readonly iamClient;
77
+ private readonly logger;
78
+ private readonly getToken;
79
+ constructor(iamClient: IamApi, logger: Logger, getToken: () => Promise<string>);
80
+ createNewRoleBinding(organizationId: string, roleBinding: unknown, skipConfirmation: boolean): Promise<boolean>;
81
+ deleteRoleBinding(organizationId: string, roleBinding: unknown): Promise<boolean>;
82
+ }
@@ -0,0 +1,203 @@
1
+ import { baseResourceDto } from "@flowcore/cli-plugin-core";
2
+ import enquirer from "enquirer";
3
+ import { diffString } from "json-diff";
4
+ import { z } from "zod";
5
+ export const roleBindingDto = baseResourceDto.extend({
6
+ spec: z.object({
7
+ role: z.string(),
8
+ subjects: z.array(z.object({
9
+ id: z.string(),
10
+ type: z.enum(["user", "key"]),
11
+ })),
12
+ }),
13
+ });
14
+ export class RoleBindingService {
15
+ iamClient;
16
+ logger;
17
+ getToken;
18
+ constructor(iamClient, logger, getToken) {
19
+ this.iamClient = iamClient;
20
+ this.logger = logger;
21
+ this.getToken = getToken;
22
+ }
23
+ async createNewRoleBinding(organizationId, roleBinding, skipConfirmation) {
24
+ const parsedRoleBinding = roleBindingDto.parse(roleBinding);
25
+ try {
26
+ const roles = await this.iamClient.getApiV1RoleAssociationsOrganizationByOrganizationId(organizationId, {
27
+ headers: {
28
+ Authorization: `Bearer ${await this.getToken()}`,
29
+ },
30
+ });
31
+ const existingRole = roles.data.find((r) => r.name === parsedRoleBinding.spec.role);
32
+ if (!existingRole) {
33
+ this.logger.error(`Role ${parsedRoleBinding.spec.role} not found`);
34
+ return false;
35
+ }
36
+ const associations = await this.iamClient.getApiV1RoleAssociationsByRoleId(existingRole.id, {
37
+ headers: {
38
+ Authorization: `Bearer ${await this.getToken()}`,
39
+ },
40
+ });
41
+ const usersToDelete = [];
42
+ for (const userAssociation of associations.data.users.filter((u) => u.roleId !== existingRole.id)) {
43
+ if (!parsedRoleBinding.spec.subjects.find((s) => s.type === "user" && s.id === userAssociation.userId)) {
44
+ usersToDelete.push(userAssociation.userId);
45
+ }
46
+ }
47
+ const keysToDelete = [];
48
+ for (const keyAssociation of associations.data.keys.filter((k) => k.roleId !== existingRole.id)) {
49
+ if (!parsedRoleBinding.spec.subjects.find((s) => s.type === "key" && s.id === keyAssociation.keyId)) {
50
+ keysToDelete.push(keyAssociation.keyId);
51
+ }
52
+ }
53
+ if (!skipConfirmation) {
54
+ const diffObject = {
55
+ keys: associations.data.keys.filter((k) => !keysToDelete.includes(k.keyId)),
56
+ users: associations.data.users.filter((u) => !usersToDelete.includes(u.userId)),
57
+ };
58
+ this.logger.info("Modifying the following:");
59
+ this.logger.info(`${diffString(associations.data.users.map((u) => u.userId), diffObject.users, { color: true, full: true })}`);
60
+ this.logger.info(`${diffString(associations.data.keys.map((k) => k.keyId), diffObject.keys, { color: true, full: true })}`);
61
+ const { confirm } = await enquirer.prompt([
62
+ {
63
+ message: `Are you sure you want to remove ${usersToDelete.length} users and ${keysToDelete.length} keys from role ${existingRole.name}?`,
64
+ name: "confirm",
65
+ type: "confirm",
66
+ },
67
+ ]);
68
+ if (!confirm) {
69
+ return false;
70
+ }
71
+ }
72
+ let changed = keysToDelete.length > 0 || usersToDelete.length > 0;
73
+ for (const userId of usersToDelete) {
74
+ const result = await this.iamClient.deleteApiV1RoleAssociationsUserByUserId(userId, { roleId: existingRole.id }, {
75
+ headers: {
76
+ Authorization: `Bearer ${await this.getToken()}`,
77
+ },
78
+ });
79
+ if (result.status !== 200) {
80
+ this.logger.error(`Failed to delete user ${userId} from role ${existingRole.id}: ${result.statusText}`);
81
+ }
82
+ }
83
+ for (const keyId of keysToDelete) {
84
+ const result = await this.iamClient.deleteApiV1RoleAssociationsKeyByKeyId(keyId, { roleId: existingRole.id }, {
85
+ headers: {
86
+ Authorization: `Bearer ${await this.getToken()}`,
87
+ },
88
+ });
89
+ if (result.status !== 200) {
90
+ this.logger.error(`Failed to delete key ${keyId} from role ${existingRole.id}: ${result.statusText}`);
91
+ }
92
+ }
93
+ for (const subject of parsedRoleBinding.spec.subjects) {
94
+ switch (subject.type) {
95
+ case "user": {
96
+ const existingAssociation = associations.data.users.find((a) => a.userId === subject.id);
97
+ if (existingAssociation) {
98
+ this.logger.debug(`User ${subject.id} already bound to role ${existingRole.id}`);
99
+ continue;
100
+ }
101
+ const result = await this.iamClient.postApiV1RoleAssociationsUserByUserId(subject.id, { roleId: existingRole.id }, {
102
+ headers: {
103
+ Authorization: `Bearer ${await this.getToken()}`,
104
+ },
105
+ });
106
+ if (result.status !== 200) {
107
+ this.logger.error(`Failed to bind user ${subject.id} to role ${existingRole.id}: ${result.statusText}`);
108
+ }
109
+ changed = true;
110
+ break;
111
+ }
112
+ case "key": {
113
+ const existingAssociation = associations.data.keys.find((a) => a.keyId === subject.id);
114
+ if (existingAssociation) {
115
+ this.logger.debug(`Key ${subject.id} already bound to role ${existingRole.id}`);
116
+ continue;
117
+ }
118
+ const result = await this.iamClient.postApiV1RoleAssociationsKeyByKeyId(subject.id, { roleId: existingRole.id }, {
119
+ headers: {
120
+ Authorization: `Bearer ${await this.getToken()}`,
121
+ },
122
+ });
123
+ if (result.status !== 200) {
124
+ this.logger.error(`Failed to bind key ${subject.id} to role ${existingRole.id}: ${result.statusText}`);
125
+ }
126
+ changed = true;
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ return changed;
132
+ }
133
+ catch (error) {
134
+ if (typeof error === "object" && error !== null && "error" in error) {
135
+ const err = error;
136
+ this.logger.fatal(`Failed to create role binding with error(${err.error.status} - ${err.error.code}): ${err.error.message}`);
137
+ }
138
+ else {
139
+ this.logger.fatal(`Failed to create role binding with unknown error: ${error}`);
140
+ }
141
+ }
142
+ }
143
+ async deleteRoleBinding(organizationId, roleBinding) {
144
+ const parsedRoleBinding = roleBindingDto.parse(roleBinding);
145
+ try {
146
+ const roles = await this.iamClient.getApiV1RoleAssociationsOrganizationByOrganizationId(organizationId, {
147
+ headers: {
148
+ Authorization: `Bearer ${await this.getToken()}`,
149
+ },
150
+ });
151
+ const targetRole = roles.data.find((r) => r.name === parsedRoleBinding.spec.role);
152
+ if (!targetRole) {
153
+ this.logger.fatal(`Role ${parsedRoleBinding.spec.role} not found`);
154
+ }
155
+ const associations = await this.iamClient.getApiV1RoleAssociationsByRoleId(targetRole.id, {
156
+ headers: {
157
+ Authorization: `Bearer ${await this.getToken()}`,
158
+ },
159
+ });
160
+ for (const subject of parsedRoleBinding.spec.subjects) {
161
+ switch (subject.type) {
162
+ case "user": {
163
+ const existingAssociation = associations.data.users.find((a) => a.userId === subject.id);
164
+ if (!existingAssociation) {
165
+ this.logger.error(`User ${subject.id} not bound to role ${targetRole.id}`);
166
+ continue;
167
+ }
168
+ const result = await this.iamClient.deleteApiV1RoleAssociationsUserByUserId(subject.id, { roleId: targetRole.id }, {
169
+ headers: {
170
+ Authorization: `Bearer ${await this.getToken()}`,
171
+ },
172
+ });
173
+ if (result.status !== 200) {
174
+ this.logger.error(`Failed to delete role binding for user ${subject.id}: ${result.statusText}`);
175
+ }
176
+ break;
177
+ }
178
+ case "key": {
179
+ const result = await this.iamClient.deleteApiV1RoleAssociationsKeyByKeyId(subject.id, { roleId: targetRole.id }, {
180
+ headers: {
181
+ Authorization: `Bearer ${await this.getToken()}`,
182
+ },
183
+ });
184
+ if (result.status !== 200) {
185
+ this.logger.error(`Failed to delete role binding for key ${subject.id}: ${result.statusText}`);
186
+ }
187
+ break;
188
+ }
189
+ }
190
+ }
191
+ return true;
192
+ }
193
+ catch (error) {
194
+ if (typeof error === "object" && error !== null && "error" in error) {
195
+ const err = error;
196
+ this.logger.fatal(`Failed to delete role with error(${err.error.status} - ${err.error.code}): ${err.error.message}`);
197
+ }
198
+ else {
199
+ this.logger.fatal(`Failed to delete role with unknown error: ${error}`);
200
+ }
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,65 @@
1
+ import type { Logger } from "@flowcore/cli-plugin-config";
2
+ import { z } from "zod";
3
+ import type { Api as IamApi } from "../utils/clients/iam/Api.js";
4
+ export declare const roleDto: z.ZodObject<z.objectUtil.extendShape<{
5
+ apiVersion: z.ZodString;
6
+ kind: z.ZodString;
7
+ metadata: z.ZodObject<{
8
+ name: z.ZodString;
9
+ tenant: z.ZodString;
10
+ }, "strip", z.ZodTypeAny, {
11
+ name: string;
12
+ tenant: string;
13
+ }, {
14
+ name: string;
15
+ tenant: string;
16
+ }>;
17
+ }, {
18
+ spec: z.ZodObject<{
19
+ description: z.ZodOptional<z.ZodString>;
20
+ flowcoreManaged: z.ZodOptional<z.ZodBoolean>;
21
+ policies: z.ZodArray<z.ZodString, "many">;
22
+ }, "strip", z.ZodTypeAny, {
23
+ policies: string[];
24
+ description?: string | undefined;
25
+ flowcoreManaged?: boolean | undefined;
26
+ }, {
27
+ policies: string[];
28
+ description?: string | undefined;
29
+ flowcoreManaged?: boolean | undefined;
30
+ }>;
31
+ }>, "strip", z.ZodTypeAny, {
32
+ kind: string;
33
+ apiVersion: string;
34
+ metadata: {
35
+ name: string;
36
+ tenant: string;
37
+ };
38
+ spec: {
39
+ policies: string[];
40
+ description?: string | undefined;
41
+ flowcoreManaged?: boolean | undefined;
42
+ };
43
+ }, {
44
+ kind: string;
45
+ apiVersion: string;
46
+ metadata: {
47
+ name: string;
48
+ tenant: string;
49
+ };
50
+ spec: {
51
+ policies: string[];
52
+ description?: string | undefined;
53
+ flowcoreManaged?: boolean | undefined;
54
+ };
55
+ }>;
56
+ export type Role = z.infer<typeof roleDto>;
57
+ export type ApiRoleResource = Awaited<ReturnType<IamApi["getApiV1RoleAssociationsOrganizationByOrganizationId"]>>["data"][0];
58
+ export declare class RoleService {
59
+ private readonly iamClient;
60
+ private readonly logger;
61
+ private readonly getToken;
62
+ constructor(iamClient: IamApi, logger: Logger, getToken: () => Promise<string>);
63
+ createNewRole(organizationId: string, role: unknown, skipConfirmation: boolean): Promise<boolean>;
64
+ deleteRole(organizationId: string, role: unknown): Promise<boolean>;
65
+ }
@@ -0,0 +1,191 @@
1
+ import { baseResourceDto } from "@flowcore/cli-plugin-core";
2
+ import { diff } from "@opentf/obj-diff";
3
+ import enquirer from "enquirer";
4
+ import { diffString } from "json-diff";
5
+ import { inspect } from "node:util";
6
+ import { z } from "zod";
7
+ export const roleDto = baseResourceDto.extend({
8
+ spec: z.object({
9
+ description: z.string().optional(),
10
+ flowcoreManaged: z.boolean().optional(),
11
+ policies: z.array(z.string()),
12
+ }),
13
+ });
14
+ export class RoleService {
15
+ iamClient;
16
+ logger;
17
+ getToken;
18
+ constructor(iamClient, logger, getToken) {
19
+ this.iamClient = iamClient;
20
+ this.logger = logger;
21
+ this.getToken = getToken;
22
+ }
23
+ async createNewRole(organizationId, role, skipConfirmation) {
24
+ const parsedRole = roleDto.parse(role);
25
+ try {
26
+ const roles = await this.iamClient.getApiV1RoleAssociationsOrganizationByOrganizationId(organizationId, {
27
+ headers: {
28
+ Authorization: `Bearer ${await this.getToken()}`,
29
+ },
30
+ });
31
+ const existingRole = roles.data.find((r) => r.name === parsedRole.metadata.name);
32
+ const policies = await this.iamClient.getApiV1PolicyAssociationsOrganizationByOrganizationId(organizationId, {
33
+ headers: {
34
+ Authorization: `Bearer ${await this.getToken()}`,
35
+ },
36
+ });
37
+ const linkedPolicies = policies.data.filter((p) => parsedRole.spec.policies.includes(p.name));
38
+ if (!existingRole) {
39
+ const result = await this.iamClient.postApiV1Roles({
40
+ description: parsedRole.spec.description ?? undefined,
41
+ name: parsedRole.metadata.name,
42
+ organizationId,
43
+ }, {
44
+ headers: {
45
+ Authorization: `Bearer ${await this.getToken()}`,
46
+ },
47
+ });
48
+ if (result.status !== 200) {
49
+ this.logger.fatal(`Failed to create role: ${result.statusText}`);
50
+ }
51
+ const role = result.data;
52
+ for (const linkedPolicy of linkedPolicies) {
53
+ const binding = await this.iamClient.postApiV1PolicyAssociationsRoleByRoleId(role.id, {
54
+ policyId: linkedPolicy.id,
55
+ }, {
56
+ headers: {
57
+ Authorization: `Bearer ${await this.getToken()}`,
58
+ },
59
+ });
60
+ if (binding.status !== 200) {
61
+ this.logger.fatal(`Failed to bind policy ${linkedPolicy.id} to role ${role.id}: ${binding.statusText}`);
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+ const alreadyLinkedPolicies = await this.iamClient.getApiV1PolicyAssociationsRoleByRoleId(existingRole.id, {
67
+ headers: {
68
+ Authorization: `Bearer ${await this.getToken()}`,
69
+ },
70
+ });
71
+ this.logger.debug(`Found ${inspect(alreadyLinkedPolicies.data)} policies linked to role ${existingRole.id}`);
72
+ const newRole = {
73
+ description: parsedRole.spec.description ?? "",
74
+ name: parsedRole.metadata.name,
75
+ policies: parsedRole.spec.policies.sort(),
76
+ };
77
+ const existingRoleData = {
78
+ description: existingRole.description ?? "",
79
+ name: existingRole.name,
80
+ policies: alreadyLinkedPolicies.data.map((p) => p.name).sort(),
81
+ };
82
+ const diffResult = diff(existingRoleData, newRole);
83
+ if (diffResult.length === 0) {
84
+ return false;
85
+ }
86
+ if (!skipConfirmation) {
87
+ this.logger.info("Role has changed, do you want to apply these changes?");
88
+ this.logger.info(diffString(existingRoleData, newRole, {
89
+ color: true,
90
+ full: true,
91
+ }));
92
+ const { confirm } = await enquirer.prompt({
93
+ message: "Are you sure you want to update the role?",
94
+ name: "confirm",
95
+ type: "confirm",
96
+ });
97
+ if (!confirm) {
98
+ return false;
99
+ }
100
+ }
101
+ const result = await this.iamClient.patchApiV1RolesById(existingRole.id, {
102
+ description: newRole.description,
103
+ id: existingRole.id,
104
+ name: newRole.name,
105
+ organizationId,
106
+ }, {
107
+ headers: {
108
+ Authorization: `Bearer ${await this.getToken()}`,
109
+ },
110
+ });
111
+ if (result.status !== 200) {
112
+ this.logger.fatal(`Failed to update role: ${result.statusText}`);
113
+ }
114
+ const toAdd = newRole.policies.filter((p) => !existingRoleData.policies.includes(p));
115
+ const toRemove = existingRoleData.policies.filter((p) => !newRole.policies.includes(p));
116
+ for (const policy of toAdd) {
117
+ const policyId = policies.data.find((p) => p.name === policy)?.id;
118
+ if (!policyId) {
119
+ this.logger.fatal(`Policy ${policy} not found`);
120
+ }
121
+ const binding = await this.iamClient.postApiV1PolicyAssociationsRoleByRoleId(existingRole.id, { policyId }, {
122
+ headers: {
123
+ Authorization: `Bearer ${await this.getToken()}`,
124
+ },
125
+ });
126
+ if (binding.status !== 200) {
127
+ this.logger.error(`Failed to bind policy ${policyId} to role ${existingRole.id}: ${binding.statusText}`);
128
+ }
129
+ this.logger.debug(`Created binding for policy ${policyId} to role ${existingRole.id}`);
130
+ }
131
+ for (const policy of toRemove) {
132
+ console.log(policy, alreadyLinkedPolicies.data);
133
+ const policyId = alreadyLinkedPolicies.data.find((p) => p.name === policy)?.id;
134
+ if (!policyId) {
135
+ this.logger.fatal(`Policy ${policy} not found`);
136
+ }
137
+ const binding = await this.iamClient.deleteApiV1PolicyAssociationsRoleByRoleId(existingRole.id, { policyId }, {
138
+ headers: {
139
+ Authorization: `Bearer ${await this.getToken()}`,
140
+ },
141
+ });
142
+ if (binding.status !== 200) {
143
+ this.logger.error(`Failed to unbind policy ${policyId} from role ${existingRole.id}: ${binding.statusText}`);
144
+ }
145
+ this.logger.debug(`Removed binding for policy ${policyId} from role ${existingRole.id}`);
146
+ }
147
+ return true;
148
+ }
149
+ catch (error) {
150
+ if (typeof error === "object" && error !== null && "error" in error) {
151
+ const err = error;
152
+ this.logger.fatal(`Failed to create role with error(${err.error.status} - ${err.error.code}): ${err.error.message}`);
153
+ }
154
+ else {
155
+ this.logger.fatal(`Failed to create role with unknown error: ${error}`);
156
+ }
157
+ }
158
+ }
159
+ async deleteRole(organizationId, role) {
160
+ const parsedRole = roleDto.parse(role);
161
+ try {
162
+ const roles = await this.iamClient.getApiV1RoleAssociationsOrganizationByOrganizationId(organizationId, {
163
+ headers: {
164
+ Authorization: `Bearer ${await this.getToken()}`,
165
+ },
166
+ });
167
+ const existingRole = roles.data.find((r) => r.name === parsedRole.metadata.name);
168
+ if (!existingRole) {
169
+ return false;
170
+ }
171
+ const result = await this.iamClient.deleteApiV1RolesById(existingRole.id, {
172
+ headers: {
173
+ Authorization: `Bearer ${await this.getToken()}`,
174
+ },
175
+ });
176
+ if (result.status !== 200) {
177
+ this.logger.fatal(`Failed to delete role: ${result.statusText}`);
178
+ }
179
+ return true;
180
+ }
181
+ catch (error) {
182
+ if (typeof error === "object" && error !== null && "error" in error) {
183
+ const err = error;
184
+ this.logger.fatal(`Failed to delete role with error(${err.error.status} - ${err.error.code}): ${err.error.message}`);
185
+ }
186
+ else {
187
+ this.logger.fatal(`Failed to delete role with unknown error: ${error}`);
188
+ }
189
+ }
190
+ }
191
+ }