1import * as acmpca from "@distilled.cloud/aws/acm-pca";
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";
8
9const DEFAULT_PRINCIPAL = "acm.amazonaws.com";
10const DEFAULT_ACTIONS: acmpca.ActionType[] = [
11 "IssueCertificate",
12 "GetCertificate",
13 "ListPermissions",
14];
15
16export interface PermissionProps {
17 /**
18 * The ARN of the private certificate authority the permission is
19 * granted on. Changing this replaces the permission.
20 */
21 certificateAuthorityArn: string;
22 /**
23 * The Amazon Web Services service principal receiving the permission.
24 * Currently the only supported value is `acm.amazonaws.com`.
25 * Changing this replaces the permission.
26 * @default "acm.amazonaws.com"
27 */
28 principal?: string;
29 /**
30 * The actions the principal may perform against the CA. For the ACM
31 * principal AWS requires all three actions — a subset is rejected with
32 * `ValidationException: Permissions must contain all three actions
33 * [IssueCertificate, GetCertificate, ListPermissions] for ACM to
34 * perform renewals.`
35 * @default ["IssueCertificate", "GetCertificate", "ListPermissions"]
36 */
37 actions?: acmpca.ActionType[];
38 /**
39 * The account that owns the CA (for CAs shared into this account).
40 * Changing this replaces the permission.
41 */
42 sourceAccount?: string;
43}
44
45export interface Permission extends Resource<
46 "AWS.ACMPCA.Permission",
47 PermissionProps,
48 {
49 /** The ARN of the CA the permission is granted on. */
50 certificateAuthorityArn: string;
51 /** The service principal the permission is granted to. */
52 principal: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * A permission on a private CA granted to the Certificate Manager (ACM)
60 * service principal, allowing ACM to automatically issue and renew ACM
61 * certificates signed by the CA.
62 * ### Granting Permissions
63 * **Example:** Allow ACM to auto-renew certificates
64 * ```typescript
65 * import * as ACMPCA from "alchemy/AWS/ACMPCA";
66 *
67 * const permission = yield* ACMPCA.Permission("AcmRenewal", {
68 * certificateAuthorityArn: ca.certificateAuthorityArn,
69 * });
70 * ```
71 *
72 * **Example:** Restrict the granted actions
73 * ```typescript
74 * const permission = yield* ACMPCA.Permission("AcmIssueOnly", {
75 * certificateAuthorityArn: ca.certificateAuthorityArn,
76 * actions: ["IssueCertificate", "GetCertificate"],
77 * });
78 * ```
79 *
80 * @resource
81 */
82export const Permission = Resource<Permission>("AWS.ACMPCA.Permission");
83
84const sortedActions = (actions: readonly acmpca.ActionType[] | undefined) =>
85 JSON.stringify([...(actions ?? [])].sort());
86
87export const PermissionProvider = () =>
88 Provider.effect(
89 Permission,
90 Effect.gen(function* () {
91 // Find the permission entry for a principal on a CA. A missing or
92 // deleted CA yields "no permission" for observation purposes.
93 const findPermission = Effect.fn(function* (
94 certificateAuthorityArn: string,
95 principal: string,
96 ) {
97 return yield* acmpca.listPermissions
98 .items({ CertificateAuthorityArn: certificateAuthorityArn })
99 .pipe(
100 Stream.runCollect,
101 Effect.map((chunk) =>
102 Array.from(chunk).find((p) => p.Principal === principal),
103 ),
104 Effect.catchTag("ResourceNotFoundException", () =>
105 Effect.succeed(undefined),
106 ),
107 );
108 });
109
110 return {
111 stables: ["certificateAuthorityArn", "principal"],
112 list: () =>
113 Effect.gen(function* () {
114 // Permissions hang off CAs: enumerate every non-deleted CA in
115 // the ambient account/region and collect its permissions.
116 const cas = yield* acmpca.listCertificateAuthorities.items({}).pipe(
117 Stream.runCollect,
118 Effect.map((chunk) =>
119 Array.from(chunk)
120 .filter(
121 (ca) => ca.Arn !== undefined && ca.Status !== "DELETED",
122 )
123 .map((ca) => ca.Arn!),
124 ),
125 );
126 const perCa = yield* Effect.forEach(
127 cas,
128 (arn) =>
129 acmpca.listPermissions
130 .items({ CertificateAuthorityArn: arn })
131 .pipe(
132 Stream.runCollect,
133 Effect.map((chunk) =>
134 Array.from(chunk)
135 .filter((p) => p.Principal !== undefined)
136 .map((p) => ({
137 certificateAuthorityArn: arn,
138 principal: p.Principal!,
139 })),
140 ),
141 // CA removed (or state-blocked) between enumeration and
142 // listPermissions — it has no listable permissions.
143 Effect.catchTag(
144 ["ResourceNotFoundException", "InvalidStateException"],
145 () => Effect.succeed([] as Permission["Attributes"][]),
146 ),
147 ),
148 { concurrency: 5 },
149 );
150 return perCa.flat();
151 }),
152 read: Effect.fn(function* ({ olds, output }) {
153 const arn =
154 output?.certificateAuthorityArn ?? olds?.certificateAuthorityArn;
155 if (arn === undefined) return undefined;
156 const principal =
157 output?.principal ?? olds?.principal ?? DEFAULT_PRINCIPAL;
158 const found = yield* findPermission(arn, principal);
159 if (found === undefined) return undefined;
160 return { certificateAuthorityArn: arn, principal };
161 }),
162 diff: Effect.fn(function* ({ news, olds }) {
163 if (!isResolved(news)) return;
164 if (
165 news.certificateAuthorityArn !== olds.certificateAuthorityArn ||
166 (news.principal ?? DEFAULT_PRINCIPAL) !==
167 (olds.principal ?? DEFAULT_PRINCIPAL) ||
168 news.sourceAccount !== olds.sourceAccount
169 ) {
170 return { action: "replace" } as const;
171 }
172 // actions changes converge through the default update path.
173 }),
174 reconcile: Effect.fn(function* ({ news, session }) {
175 const principal = news.principal ?? DEFAULT_PRINCIPAL;
176 const actions = news.actions ?? DEFAULT_ACTIONS;
177
178 // OBSERVE — the permission has no update API, so converge by
179 // comparing the observed action set and recreating on drift.
180 const existing = yield* findPermission(
181 news.certificateAuthorityArn,
182 principal,
183 );
184
185 const converged =
186 existing !== undefined &&
187 sortedActions(existing.Actions) === sortedActions(actions) &&
188 (news.sourceAccount === undefined ||
189 existing.SourceAccount === news.sourceAccount);
190
191 if (!converged) {
192 if (existing !== undefined) {
193 yield* acmpca
194 .deletePermission({
195 CertificateAuthorityArn: news.certificateAuthorityArn,
196 Principal: principal,
197 SourceAccount: news.sourceAccount,
198 })
199 .pipe(
200 Effect.catchTag(
201 "ResourceNotFoundException",
202 () => Effect.void,
203 ),
204 );
205 }
206 yield* acmpca
207 .createPermission({
208 CertificateAuthorityArn: news.certificateAuthorityArn,
209 Principal: principal,
210 SourceAccount: news.sourceAccount,
211 Actions: actions,
212 })
213 .pipe(
214 // A concurrent reconciler won the create race — the
215 // permission exists; treat as converged.
216 Effect.catchTag(
217 "PermissionAlreadyExistsException",
218 () => Effect.void,
219 ),
220 );
221 }
222
223 yield* session.note(
224 `Permission for ${principal} on ${news.certificateAuthorityArn}`,
225 );
226 return {
227 certificateAuthorityArn: news.certificateAuthorityArn,
228 principal,
229 };
230 }),
231 delete: Effect.fn(function* ({ output }) {
232 yield* acmpca
233 .deletePermission({
234 CertificateAuthorityArn: output.certificateAuthorityArn,
235 Principal: output.principal,
236 })
237 .pipe(
238 // CA already gone, or in a state (DELETED) where permissions
239 // are no longer addressable — the permission is gone with it.
240 Effect.catchTag(
241 ["ResourceNotFoundException", "InvalidStateException"],
242 () => Effect.void,
243 ),
244 );
245 }),
246 };
247 }),
248 );