1import * as rum from "@distilled.cloud/aws/rum";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface ResourcePolicyProps {
10 /**
11 * Name of the CloudWatch RUM app monitor the policy is attached to. An app
12 * monitor has at most one resource-based policy. Changing the app monitor
13 * replaces the policy.
14 */
15 appMonitorName: string;
16 /**
17 * The IAM resource-based policy document (JSON) that controls who may send
18 * events to the app monitor. RUM only accepts `rum:PutRumEvents` as the
19 * statement action. Updated in place. Maximum size is 4 KB.
20 */
21 policyDocument: string;
22}
23
24export interface ResourcePolicy extends Resource<
25 "AWS.RUM.ResourcePolicy",
26 ResourcePolicyProps,
27 {
28 /**
29 * Name of the app monitor the policy is attached to.
30 */
31 appMonitorName: string;
32 /**
33 * Revision id of the policy document, changed on every update.
34 */
35 policyRevisionId: string | undefined;
36 },
37 never,
38 Providers
39> {}
40
41/**
42 * The resource-based policy of a CloudWatch RUM app monitor — controls which
43 * principals may send events to (`rum:PutRumEvents`) or read data from the
44 * app monitor. An app monitor has at most one.
45 *
46 * ### Creating a Resource Policy
47 * **Example:** Allow the Account to Send RUM Events
48 * ```typescript
49 * const monitor = yield* RUM.AppMonitor("SiteMonitor", {
50 * domain: "example.com",
51 * });
52 * const policy = yield* RUM.ResourcePolicy("SitePolicy", {
53 * appMonitorName: monitor.appMonitorName,
54 * policyDocument: monitor.appMonitorArn.map((arn) =>
55 * JSON.stringify({
56 * Version: "2012-10-17",
57 * Statement: [
58 * {
59 * Effect: "Allow",
60 * Principal: { AWS: "arn:aws:iam::123456789012:root" },
61 * Action: ["rum:PutRumEvents"],
62 * Resource: arn,
63 * },
64 * ],
65 * }),
66 * ),
67 * });
68 * ```
69 *
70 * @resource
71 */
72export const ResourcePolicy = Resource<ResourcePolicy>(
73 "AWS.RUM.ResourcePolicy",
74);
75
76/** Order-insensitive canonical form of a JSON policy document. */
77const canonicalJson = (document: string): string => {
78 const sort = (value: unknown): unknown =>
79 Array.isArray(value)
80 ? value.map(sort)
81 : value !== null && typeof value === "object"
82 ? Object.fromEntries(
83 Object.entries(value as Record<string, unknown>)
84 .sort(([a], [b]) => a.localeCompare(b))
85 .map(([k, v]) => [k, sort(v)]),
86 )
87 : value;
88 try {
89 return JSON.stringify(sort(JSON.parse(document)));
90 } catch {
91 return document;
92 }
93};
94
95export const ResourcePolicyProvider = () =>
96 Provider.effect(
97 ResourcePolicy,
98 Effect.gen(function* () {
99 /** Read the live policy; typed not-found (policy or monitor) → undefined. */
100 const describe = Effect.fn(function* (appMonitorName: string) {
101 return yield* rum
102 .getResourcePolicy({ Name: appMonitorName })
103 .pipe(
104 Effect.catchTag(
105 ["PolicyNotFoundException", "ResourceNotFoundException"],
106 () => Effect.succeed(undefined),
107 ),
108 );
109 });
110
111 return {
112 stables: ["appMonitorName"],
113
114 diff: Effect.fn(function* ({ olds, news }) {
115 if (!isResolved(news)) return undefined;
116 if (olds?.appMonitorName !== news.appMonitorName) {
117 return { action: "replace" } as const;
118 }
119 }),
120
121 read: Effect.fn(function* ({ olds, output }) {
122 const appMonitorName = output?.appMonitorName ?? olds?.appMonitorName;
123 if (!appMonitorName) return undefined;
124 const policy = yield* describe(appMonitorName);
125 if (policy === undefined) return undefined;
126 // Resource policies are not taggable — ownership is implied by the
127 // owned parent app monitor.
128 return { appMonitorName, policyRevisionId: policy.PolicyRevisionId };
129 }),
130
131 reconcile: Effect.fn(function* ({ news, session }) {
132 const appMonitorName = news!.appMonitorName;
133
134 // 1. Observe — the live policy is authoritative.
135 const existing = yield* describe(appMonitorName);
136
137 // 2/3. Ensure + sync — `putResourcePolicy` is a true upsert; call
138 // it only when the canonical document drifts (the observed
139 // revision id guards against concurrent writers).
140 const policy =
141 existing?.PolicyDocument !== undefined &&
142 canonicalJson(existing.PolicyDocument) ===
143 canonicalJson(news!.policyDocument)
144 ? existing
145 : yield* rum.putResourcePolicy({
146 Name: appMonitorName,
147 PolicyDocument: news!.policyDocument,
148 PolicyRevisionId: existing?.PolicyRevisionId,
149 });
150
151 yield* session.note(appMonitorName);
152 return {
153 appMonitorName,
154 policyRevisionId: policy.PolicyRevisionId,
155 };
156 }),
157
158 delete: Effect.fn(function* ({ output }) {
159 yield* rum.deleteResourcePolicy({ Name: output.appMonitorName }).pipe(
160 // idempotent — the policy (or its whole monitor) may be gone
161 Effect.catchTag(
162 ["PolicyNotFoundException", "ResourceNotFoundException"],
163 () => Effect.void,
164 ),
165 Effect.retry({
166 while: (e) => e._tag === "ConflictException",
167 schedule: Schedule.max([
168 Schedule.fixed("3 seconds"),
169 Schedule.recurs(8),
170 ]),
171 }),
172 );
173 }),
174
175 // Singleton sub-resource keyed by its parent app monitor.
176 list: () => Effect.succeed([]),
177 };
178 }),
179 );