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 { PolicyDocument } from "../IAM/Policy.ts";
8import {
9 normalizePolicyDocument,
10 stringifyPolicyDocument,
11} from "../IAM/Policy.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface CertificateAuthorityPolicyProps {
15 /**
16 * The ARN of the private CA the resource-based policy is attached to.
17 * Changing this replaces the policy.
18 */
19 certificateAuthorityArn: string;
20 /**
21 * The resource-based permission policy, either as a structured
22 * {@link PolicyDocument} or a raw JSON string (escape hatch). The policy
23 * grants cross-account access on the CA to an Amazon Web Services
24 * account, organization, or organizational unit — a policy that would
25 * lock the CA owner out is rejected by AWS with the typed
26 * `LockoutPreventedException`.
27 */
28 policy: PolicyDocument | string;
29}
30
31export interface CertificateAuthorityPolicy extends Resource<
32 "AWS.ACMPCA.CertificateAuthorityPolicy",
33 CertificateAuthorityPolicyProps,
34 {
35 /** The ARN of the CA the policy is attached to. */
36 certificateAuthorityArn: string;
37 /** The attached policy document as a JSON string. */
38 policy: string;
39 },
40 never,
41 Providers
42> {}
43
44/**
45 * A resource-based policy attached to a private CA, granting cross-account
46 * access — e.g. allowing a Certificate Manager (ACM) user in another
47 * account to issue and renew certificates signed by this CA. This is the
48 * policy Amazon Web Services Resource Access Manager (RAM) manages when a
49 * CA is shared; attach it directly for fine-grained control.
50 *
51 * ### Attaching a CA Policy
52 * **Example:** Allow Another Account to Issue Certificates
53 * ```typescript
54 * import * as ACMPCA from "alchemy/AWS/ACMPCA";
55 *
56 * const policy = yield* ACMPCA.CertificateAuthorityPolicy("CrossAccount", {
57 * certificateAuthorityArn: ca.certificateAuthorityArn,
58 * policy: {
59 * Version: "2012-10-17",
60 * Statement: [
61 * {
62 * Effect: "Allow",
63 * Principal: { AWS: "arn:aws:iam::123456789012:root" },
64 * Action: [
65 * "acm-pca:DescribeCertificateAuthority",
66 * "acm-pca:GetCertificate",
67 * "acm-pca:GetCertificateAuthorityCertificate",
68 * "acm-pca:ListPermissions",
69 * "acm-pca:IssueCertificate",
70 * "acm-pca:RevokeCertificate",
71 * ],
72 * Resource: ca.certificateAuthorityArn,
73 * },
74 * ],
75 * },
76 * });
77 * ```
78 *
79 * @resource
80 */
81export const CertificateAuthorityPolicy = Resource<CertificateAuthorityPolicy>(
82 "AWS.ACMPCA.CertificateAuthorityPolicy",
83);
84
85export const CertificateAuthorityPolicyProvider = () =>
86 Provider.effect(
87 CertificateAuthorityPolicy,
88 Effect.gen(function* () {
89 // A CA without a policy and a missing/deleted CA both answer
90 // getPolicy with ResourceNotFoundException — either way there is no
91 // policy to observe.
92 const observe = (certificateAuthorityArn: string) =>
93 acmpca.getPolicy({ ResourceArn: certificateAuthorityArn }).pipe(
94 Effect.map((response) => response.Policy),
95 Effect.catchTag(
96 ["ResourceNotFoundException", "InvalidStateException"],
97 () => Effect.succeed(undefined),
98 ),
99 );
100
101 const toDesired = (policy: PolicyDocument | string) =>
102 typeof policy === "string" ? policy : stringifyPolicyDocument(policy);
103
104 return {
105 stables: ["certificateAuthorityArn"],
106 diff: Effect.fn(function* ({ news, olds }) {
107 if (!isResolved(news)) return;
108 if (news.certificateAuthorityArn !== olds.certificateAuthorityArn) {
109 return { action: "replace" } as const;
110 }
111 }),
112 read: Effect.fn(function* ({ olds, output }) {
113 const certificateAuthorityArn =
114 output?.certificateAuthorityArn ?? olds?.certificateAuthorityArn;
115 if (certificateAuthorityArn === undefined) return undefined;
116 const observed = yield* observe(certificateAuthorityArn);
117 if (observed === undefined) return undefined;
118 return { certificateAuthorityArn, policy: observed };
119 }),
120 list: () =>
121 Effect.gen(function* () {
122 // Policies hang off CAs: enumerate every non-deleted CA and
123 // collect the ones that actually carry a policy.
124 const cas = yield* acmpca.listCertificateAuthorities.items({}).pipe(
125 Stream.runCollect,
126 Effect.map((chunk) =>
127 Array.from(chunk)
128 .filter(
129 (ca) => ca.Arn !== undefined && ca.Status !== "DELETED",
130 )
131 .map((ca) => ca.Arn!),
132 ),
133 );
134 const policies = yield* Effect.forEach(
135 cas,
136 (arn) =>
137 observe(arn).pipe(
138 Effect.map((policy) =>
139 policy === undefined
140 ? []
141 : [{ certificateAuthorityArn: arn, policy }],
142 ),
143 ),
144 { concurrency: 5 },
145 );
146 return policies.flat();
147 }),
148 reconcile: Effect.fn(function* ({ news, session }) {
149 const certificateAuthorityArn = news.certificateAuthorityArn;
150 const desired = toDesired(news.policy);
151
152 // OBSERVE — putPolicy is an upsert; compare the canonicalized
153 // live document against the canonicalized desired one so a
154 // re-deploy of an equivalent policy (key order, whitespace) skips
155 // the API call entirely.
156 const observed = yield* observe(certificateAuthorityArn);
157 if (
158 observed === undefined ||
159 normalizePolicyDocument(observed) !==
160 normalizePolicyDocument(desired)
161 ) {
162 yield* acmpca.putPolicy({
163 ResourceArn: certificateAuthorityArn,
164 Policy: desired,
165 });
166 }
167
168 yield* session.note(certificateAuthorityArn);
169 return { certificateAuthorityArn, policy: desired };
170 }),
171 delete: Effect.fn(function* ({ output }) {
172 yield* acmpca
173 .deletePolicy({ ResourceArn: output.certificateAuthorityArn })
174 .pipe(
175 // CA already gone, no policy attached, or CA in a state
176 // (DELETED) where the policy is no longer addressable — the
177 // policy is gone with it.
178 Effect.catchTag(
179 ["ResourceNotFoundException", "InvalidStateException"],
180 () => Effect.void,
181 ),
182 );
183 }),
184 };
185 }),
186 );