1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { PolicyDocument } from "../IAM/Policy.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface ResourcePolicyProps {
12 /**
13 * Name of the resource policy. The policy name is the identity of the
14 * policy within the account and region (put semantics upsert by name).
15 * If omitted, a unique name is generated. Changing this value replaces
16 * the policy.
17 */
18 policyName?: string;
19 /**
20 * The policy document granting an AWS service principal (e.g.
21 * `route53.amazonaws.com`, `es.amazonaws.com`) permission to write to
22 * CloudWatch Logs, either as a JSON string or a structured document.
23 */
24 policyDocument: PolicyDocument | string;
25}
26
27export interface ResourcePolicy extends Resource<
28 "AWS.Logs.ResourcePolicy",
29 ResourcePolicyProps,
30 {
31 policyName: string;
32 policyDocument: string;
33 },
34 never,
35 Providers
36> {}
37
38/**
39 * An account-scoped CloudWatch Logs resource policy — grants AWS service
40 * principals (Route 53 query logging, API Gateway execution logs, OpenSearch
41 * slow logs, ...) permission to deliver logs into your account.
42 *
43 * :::warning
44 * AWS allows at most **10 resource policies per region per account** and the
45 * quota cannot be raised. Always use a deterministic `policyName` and destroy
46 * policies you no longer need.
47 * :::
48 * ### Granting Log Delivery
49 * **Example:** Allow Route 53 Query Logging
50 * ```typescript
51 * const policy = yield* ResourcePolicy("Route53QueryLogging", {
52 * policyName: "route53-query-logging",
53 * policyDocument: {
54 * Version: "2012-10-17",
55 * Statement: [
56 * {
57 * Effect: "Allow",
58 * Principal: { Service: "route53.amazonaws.com" },
59 * Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
60 * Resource: `arn:aws:logs:us-east-1:${accountId}:log-group:/aws/route53/*`,
61 * },
62 * ],
63 * },
64 * });
65 * ```
66 *
67 * @resource
68 */
69export const ResourcePolicy = Resource<ResourcePolicy>(
70 "AWS.Logs.ResourcePolicy",
71);
72
73export const ResourcePolicyProvider = () =>
74 Provider.effect(
75 ResourcePolicy,
76 Effect.gen(function* () {
77 const toPolicyName = (id: string, props: { policyName?: string } = {}) =>
78 props.policyName
79 ? Effect.succeed(props.policyName)
80 : createPhysicalName({ id, maxLength: 255 });
81
82 const toDocumentString = (document: PolicyDocument | string) =>
83 typeof document === "string" ? document : JSON.stringify(document);
84
85 // The account quota is 10 policies per region, so a single describe
86 // call (limit 50) is always exhaustive — no pagination needed.
87 const describeAll = logs
88 .describeResourcePolicies({ limit: 50 })
89 .pipe(Effect.map((response) => response.resourcePolicies ?? []));
90
91 const observe = Effect.fn(function* (policyName: string) {
92 const policies = yield* describeAll;
93 return policies.find((policy) => policy.policyName === policyName);
94 });
95
96 return {
97 stables: ["policyName"],
98 list: () =>
99 describeAll.pipe(
100 Effect.map((policies) =>
101 policies
102 .filter(
103 (
104 policy,
105 ): policy is logs.ResourcePolicy & { policyName: string } =>
106 policy.policyName != null,
107 )
108 .map((policy) => ({
109 policyName: policy.policyName,
110 policyDocument: policy.policyDocument ?? "",
111 })),
112 ),
113 ),
114 diff: Effect.fn(function* ({ id, olds, news }) {
115 if (!isResolved(news)) return;
116 if (
117 (yield* toPolicyName(id, olds)) !== (yield* toPolicyName(id, news))
118 ) {
119 return { action: "replace" } as const;
120 }
121 }),
122 read: Effect.fn(function* ({ id, olds, output }) {
123 const policyName =
124 output?.policyName ?? (yield* toPolicyName(id, olds ?? {}));
125 const observed = yield* observe(policyName);
126 if (!observed) return undefined;
127 return {
128 policyName,
129 policyDocument: observed.policyDocument ?? "",
130 };
131 }),
132 reconcile: Effect.fn(function* ({ id, news, output, session }) {
133 const policyName =
134 output?.policyName ?? (yield* toPolicyName(id, news));
135 const desiredDocument = toDocumentString(news.policyDocument);
136
137 // Observe — putResourcePolicy upserts by name; skip the put when the
138 // live document already matches the desired one.
139 const observed = yield* observe(policyName);
140 if (observed?.policyDocument !== desiredDocument) {
141 yield* logs
142 .putResourcePolicy({
143 policyName,
144 policyDocument: desiredDocument,
145 })
146 .pipe(
147 Effect.retry({
148 while: (error) =>
149 error._tag === "OperationAbortedException" ||
150 error._tag === "ServiceUnavailableException",
151 schedule: Schedule.exponential(100),
152 times: 8,
153 }),
154 );
155 }
156
157 yield* session.note(policyName);
158
159 return {
160 policyName,
161 policyDocument: desiredDocument,
162 };
163 }),
164 delete: Effect.fn(function* ({ output }) {
165 yield* logs
166 .deleteResourcePolicy({ policyName: output.policyName })
167 .pipe(
168 Effect.retry({
169 while: (error) =>
170 error._tag === "OperationAbortedException" ||
171 error._tag === "ServiceUnavailableException",
172 schedule: Schedule.exponential(100),
173 times: 8,
174 }),
175 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
176 );
177 }),
178 };
179 }),
180 );