1import * as ssoAdmin from "@distilled.cloud/aws/sso-admin";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { normalizeDurationInput } from "../../Util/Duration.ts";
9import type { Providers } from "../Providers.ts";
10import { resolveInstance, retryIdentityCenter } from "./common.ts";
11
12export interface PermissionSetProps {
13 /**
14 * Explicit IAM Identity Center instance ARN.
15 * If omitted, Alchemy adopts the only visible instance.
16 */
17 instanceArn?: string;
18 /**
19 * Permission set name.
20 */
21 name: string;
22 /**
23 * Optional human-readable description.
24 */
25 description?: string;
26 /**
27 * Optional session duration, e.g. `"8 hours"` or `Duration.hours(8)`.
28 * Sent to Identity Center as an ISO-8601 string such as `PT8H` (a bare
29 * number is milliseconds).
30 */
31 sessionDuration?: Duration.Input;
32 /**
33 * Optional relay state passed to supported applications.
34 */
35 relayState?: string;
36}
37
38export interface PermissionSet extends Resource<
39 "AWS.IdentityCenter.PermissionSet",
40 PermissionSetProps,
41 {
42 /** The Identity Center instance the permission set lives in. */
43 instanceArn: string;
44 /** The ARN of the permission set. */
45 permissionSetArn: string;
46 /** The name of the permission set. */
47 name: string;
48 /** The description of the permission set. */
49 description: string | undefined;
50 /** The session duration in ISO-8601 format (e.g. `PT8H`). */
51 sessionDuration: string | undefined;
52 /** The relay state URL users land on after federating, if set. */
53 relayState: string | undefined;
54 /** When the permission set was created. */
55 createdDate: Date | undefined;
56 },
57 never,
58 Providers
59> {}
60
61/**
62 * An IAM Identity Center permission set.
63 * ### Creating Permission Sets
64 * **Example:** Administrator Access
65 * ```typescript
66 * const admin = yield* PermissionSet("AdministratorAccess", {
67 * name: "AdministratorAccess",
68 * description: "Administrator access for platform engineers",
69 * sessionDuration: "8 hours",
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const PermissionSet = Resource<PermissionSet>(
76 "AWS.IdentityCenter.PermissionSet",
77);
78
79export const PermissionSetProvider = () =>
80 Provider.effect(
81 PermissionSet,
82 Effect.gen(function* () {
83 return {
84 stables: ["permissionSetArn", "instanceArn"],
85 list: () =>
86 Effect.gen(function* () {
87 // Permission sets live on an Identity Center instance. Resolve
88 // the single enabled SSO instance, enumerate every permission
89 // set ARN via `listPermissionSets` (exhaustively paginated by
90 // the distilled `.items` stream), then hydrate each into the
91 // exact `read` shape via `describePermissionSet` (bounded
92 // concurrency, typed per-item not-found handled inside
93 // `readPermissionSetByArn`).
94 const instance = yield* resolveInstance();
95 const arns = yield* ssoAdmin.listPermissionSets
96 .items({
97 InstanceArn: instance.InstanceArn!,
98 MaxResults: 100,
99 })
100 .pipe(Stream.runCollect);
101 const rows = yield* Effect.forEach(
102 arns,
103 (permissionSetArn) =>
104 readPermissionSetByArn({
105 instanceArn: instance.InstanceArn!,
106 permissionSetArn,
107 }),
108 { concurrency: 10 },
109 );
110 return rows.filter(
111 (row): row is PermissionSet["Attributes"] => row !== undefined,
112 );
113 }),
114 diff: Effect.fn(function* ({ olds, news }) {
115 if (!isResolved(news)) return;
116 if (
117 olds?.instanceArn !== news.instanceArn ||
118 olds?.name !== news.name
119 ) {
120 return { action: "replace" } as const;
121 }
122 }),
123 read: Effect.fn(function* ({ olds, output }) {
124 if (output?.permissionSetArn && output.instanceArn) {
125 return yield* readPermissionSetByArn({
126 instanceArn: output.instanceArn,
127 permissionSetArn: output.permissionSetArn,
128 });
129 }
130
131 if (!olds) {
132 return undefined;
133 }
134
135 return yield* readPermissionSetByName(olds);
136 }),
137 reconcile: Effect.fn(function* ({ news, output, session }) {
138 const instance = yield* resolveInstance(
139 output?.instanceArn ?? news.instanceArn,
140 );
141 const desiredSessionDuration =
142 news.sessionDuration !== undefined
143 ? toIsoSessionDuration(news.sessionDuration)
144 : undefined;
145
146 // Observe — find the permission set by ARN (when we already
147 // have one) or by name on the resolved instance.
148 let existing =
149 (output?.permissionSetArn
150 ? yield* readPermissionSetByArn({
151 instanceArn: instance.InstanceArn!,
152 permissionSetArn: output.permissionSetArn,
153 })
154 : undefined) ??
155 (yield* readPermissionSetByName({
156 ...news,
157 instanceArn: instance.InstanceArn,
158 }));
159
160 // Ensure — create the permission set if missing.
161 if (!existing) {
162 const response = yield* retryIdentityCenter(
163 ssoAdmin.createPermissionSet({
164 InstanceArn: instance.InstanceArn!,
165 Name: news.name,
166 Description: news.description,
167 SessionDuration: desiredSessionDuration,
168 RelayState: news.relayState,
169 }),
170 );
171
172 const createdArn = response.PermissionSet?.PermissionSetArn;
173 existing =
174 (createdArn
175 ? yield* readPermissionSetByArn({
176 instanceArn: instance.InstanceArn!,
177 permissionSetArn: createdArn,
178 })
179 : undefined) ??
180 (yield* readPermissionSetByName({
181 ...news,
182 instanceArn: instance.InstanceArn,
183 }));
184
185 if (!existing) {
186 return yield* Effect.fail(
187 new Error(
188 `permission set '${news.name}' not found after create`,
189 ),
190 );
191 }
192
193 yield* session.note(existing.permissionSetArn);
194 return existing;
195 }
196
197 // Sync mutable attributes — `updatePermissionSet` overwrites
198 // description, sessionDuration, relayState. Diff against
199 // observed cloud state so adoption converges; only call when
200 // there's a real delta.
201 if (
202 (existing.description ?? undefined) !== news.description ||
203 (existing.sessionDuration ?? undefined) !==
204 desiredSessionDuration ||
205 (existing.relayState ?? undefined) !== news.relayState
206 ) {
207 yield* retryIdentityCenter(
208 ssoAdmin.updatePermissionSet({
209 InstanceArn: existing.instanceArn,
210 PermissionSetArn: existing.permissionSetArn,
211 Description: news.description,
212 SessionDuration: desiredSessionDuration,
213 RelayState: news.relayState,
214 }),
215 );
216
217 const updated = yield* readPermissionSetByArn({
218 instanceArn: existing.instanceArn,
219 permissionSetArn: existing.permissionSetArn,
220 });
221 if (!updated) {
222 return yield* Effect.fail(
223 new Error(
224 `permission set '${existing.permissionSetArn}' not found after update`,
225 ),
226 );
227 }
228 yield* session.note(updated.permissionSetArn);
229 return updated;
230 }
231
232 yield* session.note(existing.permissionSetArn);
233 return existing;
234 }),
235 delete: Effect.fn(function* ({ output }) {
236 yield* retryIdentityCenter(
237 ssoAdmin
238 .deletePermissionSet({
239 InstanceArn: output.instanceArn,
240 PermissionSetArn: output.permissionSetArn,
241 })
242 .pipe(
243 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
244 ),
245 );
246 }),
247 };
248 }),
249 );
250
251/**
252 * Format a {@link Duration.Input} as the canonical ISO-8601 duration string
253 * (`PT8H`, `PT1H30M`, …) the `SessionDuration` wire field expects. ISO-8601
254 * is semantically part of the AWS field, so only the normalization (state
255 * round-trip re-hydration) comes from the central Duration util.
256 */
257const toIsoSessionDuration = (input: Duration.Input): string => {
258 const totalSeconds = Math.round(
259 Duration.toSeconds(normalizeDurationInput(input)),
260 );
261 const hours = Math.floor(totalSeconds / 3600);
262 const minutes = Math.floor((totalSeconds % 3600) / 60);
263 const seconds = totalSeconds % 60;
264 const parts = [
265 hours > 0 ? `${hours}H` : "",
266 minutes > 0 ? `${minutes}M` : "",
267 seconds > 0 || totalSeconds === 0 ? `${seconds}S` : "",
268 ].join("");
269 return `PT${parts}`;
270};
271
272const readPermissionSetByArn = Effect.fn(function* ({
273 instanceArn,
274 permissionSetArn,
275}: {
276 instanceArn: string;
277 permissionSetArn: string;
278}) {
279 const response = yield* retryIdentityCenter(
280 ssoAdmin
281 .describePermissionSet({
282 InstanceArn: instanceArn,
283 PermissionSetArn: permissionSetArn,
284 })
285 .pipe(
286 Effect.catchTag("ResourceNotFoundException", () =>
287 Effect.succeed(undefined),
288 ),
289 ),
290 );
291
292 const permissionSet = response?.PermissionSet;
293 if (!permissionSet?.PermissionSetArn || !permissionSet.Name) {
294 return undefined;
295 }
296
297 return {
298 instanceArn,
299 permissionSetArn: permissionSet.PermissionSetArn,
300 name: permissionSet.Name,
301 description: permissionSet.Description,
302 sessionDuration: permissionSet.SessionDuration,
303 relayState: permissionSet.RelayState,
304 createdDate: permissionSet.CreatedDate,
305 } satisfies PermissionSet["Attributes"];
306});
307
308const readPermissionSetByName = Effect.fn(function* ({
309 instanceArn,
310 name,
311}: Pick<PermissionSetProps, "instanceArn" | "name">) {
312 const instance = yield* resolveInstance(instanceArn);
313 const arns = yield* ssoAdmin.listPermissionSets
314 .items({
315 InstanceArn: instance.InstanceArn!,
316 MaxResults: 100,
317 })
318 .pipe(Stream.runCollect);
319
320 for (const permissionSetArn of arns) {
321 const permissionSet = yield* readPermissionSetByArn({
322 instanceArn: instance.InstanceArn!,
323 permissionSetArn,
324 });
325 if (permissionSet?.name === name) {
326 return permissionSet;
327 }
328 }
329
330 return undefined;
331});