1import * as servicecatalog from "@distilled.cloud/aws/service-catalog";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import { awaitVisible } from "./internal.ts";
9
10export type PrincipalType = "IAM" | "IAM_PATTERN";
11
12export interface PrincipalPortfolioAssociationProps {
13 /**
14 * ID of the portfolio to grant access to. Changing it replaces the
15 * association.
16 */
17 portfolioId: string;
18 /**
19 * ARN of the IAM principal (user, group, or role) — or an ARN pattern
20 * with wildcards when `principalType` is `IAM_PATTERN`. Changing it
21 * replaces the association.
22 */
23 principalArn: string;
24 /**
25 * Whether `principalArn` is a concrete IAM ARN (`IAM`) or a wildcard
26 * pattern (`IAM_PATTERN`). Changing it replaces the association.
27 * @default "IAM"
28 */
29 principalType?: PrincipalType;
30}
31
32export interface PrincipalPortfolioAssociation extends Resource<
33 "AWS.ServiceCatalog.PrincipalPortfolioAssociation",
34 PrincipalPortfolioAssociationProps,
35 {
36 /** ID of the portfolio. */
37 portfolioId: string;
38 /** ARN (or pattern) of the associated principal. */
39 principalArn: string;
40 /** The principal type of the association. */
41 principalType: PrincipalType;
42 },
43 never,
44 Providers
45> {}
46
47/**
48 * Grants an IAM principal (user, group, or role) access to a Service
49 * Catalog portfolio, allowing it to browse and launch the portfolio's
50 * products.
51 *
52 * ### Granting Access
53 * **Example:** Associate an IAM role
54 * ```typescript
55 * import * as ServiceCatalog from "alchemy/AWS/ServiceCatalog";
56 *
57 * yield* ServiceCatalog.PrincipalPortfolioAssociation("DevAccess", {
58 * portfolioId: portfolio.portfolioId,
59 * principalArn: role.roleArn,
60 * });
61 * ```
62 *
63 * **Example:** Associate a wildcard principal pattern
64 * ```typescript
65 * yield* ServiceCatalog.PrincipalPortfolioAssociation("AllDevRoles", {
66 * portfolioId: portfolio.portfolioId,
67 * principalArn: "arn:aws:iam:::role/dev-*",
68 * principalType: "IAM_PATTERN",
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const PrincipalPortfolioAssociation =
75 Resource<PrincipalPortfolioAssociation>(
76 "AWS.ServiceCatalog.PrincipalPortfolioAssociation",
77 );
78
79export const PrincipalPortfolioAssociationProvider = () =>
80 Provider.effect(
81 PrincipalPortfolioAssociation,
82 Effect.gen(function* () {
83 // Enumerate the portfolio's principals and check for ours. A missing
84 // portfolio means the association is gone too.
85 const isAssociated = Effect.fn(function* (
86 portfolioId: string,
87 principalArn: string,
88 ) {
89 return yield* servicecatalog.listPrincipalsForPortfolio
90 .pages({ PortfolioId: portfolioId })
91 .pipe(
92 Stream.runCollect,
93 Effect.map((chunk) =>
94 Array.from(chunk)
95 .flatMap((page) => page.Principals ?? [])
96 .some((p) => p.PrincipalARN === principalArn),
97 ),
98 Effect.catchTag("ResourceNotFoundException", () =>
99 Effect.succeed(false),
100 ),
101 );
102 });
103
104 return PrincipalPortfolioAssociation.Provider.of({
105 stables: ["portfolioId", "principalArn", "principalType"],
106 // Service Catalog has no account-wide principal-association API. Walk
107 // every portfolio so unsafe nuke can remove principals before their
108 // parent portfolios. AWS may replace the ARN of a deleted IAM role
109 // with its stable principal ID (ARO...); preserve that value because
110 // the disassociate API accepts it.
111 list: () =>
112 Effect.gen(function* () {
113 const portfolioIds = yield* servicecatalog.listPortfolios
114 .pages({})
115 .pipe(
116 Stream.runCollect,
117 Effect.map((pages) =>
118 Array.from(pages)
119 .flatMap((page) => page.PortfolioDetails ?? [])
120 .flatMap((portfolio) =>
121 portfolio.Id === undefined ? [] : [portfolio.Id],
122 ),
123 ),
124 );
125
126 const associations = yield* Effect.forEach(
127 portfolioIds,
128 (portfolioId) =>
129 servicecatalog.listPrincipalsForPortfolio
130 .pages({ PortfolioId: portfolioId })
131 .pipe(
132 Stream.runCollect,
133 Effect.map((pages) =>
134 Array.from(pages)
135 .flatMap((page) => page.Principals ?? [])
136 .flatMap((principal) => {
137 if (principal.PrincipalARN === undefined) return [];
138 const principalType =
139 principal.PrincipalType === "IAM"
140 ? ("IAM" as const)
141 : principal.PrincipalType === "IAM_PATTERN"
142 ? ("IAM_PATTERN" as const)
143 : undefined;
144 if (principalType === undefined) return [];
145 return [
146 {
147 portfolioId,
148 principalArn: principal.PrincipalARN,
149 principalType,
150 },
151 ];
152 }),
153 ),
154 // A portfolio can disappear between enumeration and
155 // association hydration.
156 Effect.catchTag("ResourceNotFoundException", () =>
157 Effect.succeed([]),
158 ),
159 ),
160 { concurrency: 5 },
161 );
162 return associations.flat();
163 }),
164 // Existence-only resource — every property is part of its identity.
165 diff: Effect.fn(function* ({ olds, news }) {
166 if (!isResolved(news)) return undefined;
167 if (
168 olds.portfolioId !== news.portfolioId ||
169 olds.principalArn !== news.principalArn ||
170 (olds.principalType ?? "IAM") !== (news.principalType ?? "IAM")
171 ) {
172 return { action: "replace" } as const;
173 }
174 }),
175 read: Effect.fn(function* ({ olds, output }) {
176 const portfolioId = output?.portfolioId ?? olds?.portfolioId;
177 const principalArn = output?.principalArn ?? olds?.principalArn;
178 if (portfolioId === undefined || principalArn === undefined) {
179 return undefined;
180 }
181 return (yield* isAssociated(portfolioId, principalArn))
182 ? {
183 portfolioId,
184 principalArn,
185 principalType:
186 output?.principalType ?? olds?.principalType ?? "IAM",
187 }
188 : undefined;
189 }),
190 // Existence-only: observe → if missing, associate. No sync step.
191 reconcile: Effect.fn(function* ({ news, session }) {
192 const principalType = news.principalType ?? "IAM";
193 if (!(yield* isAssociated(news.portfolioId, news.principalArn))) {
194 yield* servicecatalog.associatePrincipalWithPortfolio({
195 PortfolioId: news.portfolioId,
196 PrincipalARN: news.principalArn,
197 PrincipalType: principalType,
198 });
199 // The principal list is eventually consistent — wait (bounded)
200 // until the association is visible so a subsequent read converges.
201 yield* awaitVisible(
202 isAssociated(news.portfolioId, news.principalArn),
203 );
204 }
205 yield* session.note(`${news.portfolioId}/${news.principalArn}`);
206 return {
207 portfolioId: news.portfolioId,
208 principalArn: news.principalArn,
209 principalType,
210 };
211 }),
212 delete: Effect.fn(function* ({ output }) {
213 if (yield* isAssociated(output.portfolioId, output.principalArn)) {
214 yield* servicecatalog
215 .disassociatePrincipalFromPortfolio({
216 PortfolioId: output.portfolioId,
217 PrincipalARN: output.principalArn,
218 PrincipalType: output.principalType,
219 })
220 .pipe(
221 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
222 );
223 }
224 }),
225 });
226 }),
227 );