1import * as xray from "@distilled.cloud/aws/xray";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface ResourcePolicyProps {
12 /**
13 * Name of the resource policy. Must be unique within the account (each
14 * account holds at most 5 X-Ray resource policies).
15 *
16 * Changing the name replaces the policy.
17 * @default ${app}-${stage}-${id}
18 */
19 policyName?: string;
20 /**
21 * The IAM resource-based policy document (JSON, maximum 5 KB) granting
22 * one or more Amazon Web Services services and accounts permission to
23 * access X-Ray — e.g. allowing SNS active tracing to call
24 * `xray:PutTraceSegments`. Updated in place.
25 */
26 policyDocument: string;
27 /**
28 * Skip the check that prevents locking yourself out of the ability to
29 * change the policy in the future.
30 * @default false
31 */
32 bypassPolicyLockoutCheck?: boolean;
33}
34
35export interface ResourcePolicy extends Resource<
36 "AWS.XRay.ResourcePolicy",
37 ResourcePolicyProps,
38 {
39 /**
40 * Name of the resource policy.
41 */
42 policyName: string;
43 /**
44 * Revision id of the policy document, changed on every update.
45 */
46 policyRevisionId: string | undefined;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * An X-Ray resource policy — an account-level, resource-based IAM policy
54 * that grants other Amazon Web Services services and accounts access to
55 * X-Ray, e.g. allowing SNS active tracing to send trace segments.
56 *
57 * Resource policies are not taggable; ownership is keyed by the
58 * deterministic policy name.
59 * ### Creating Resource Policies
60 * **Example:** Allow SNS active tracing to send trace data
61 * ```typescript
62 * import * as XRay from "alchemy/AWS/XRay";
63 *
64 * const policy = yield* XRay.ResourcePolicy("SnsActiveTracing", {
65 * policyDocument: JSON.stringify({
66 * Version: "2012-10-17",
67 * Statement: [
68 * {
69 * Effect: "Allow",
70 * Principal: { Service: "sns.amazonaws.com" },
71 * Action: ["xray:PutTraceSegments", "xray:GetSamplingRules"],
72 * Resource: "*",
73 * },
74 * ],
75 * }),
76 * });
77 * ```
78 *
79 * @resource
80 */
81export const ResourcePolicy = Resource<ResourcePolicy>(
82 "AWS.XRay.ResourcePolicy",
83);
84
85/** Order-insensitive canonical form of a JSON policy document. */
86const canonicalJson = (document: string): string => {
87 const sort = (value: unknown): unknown =>
88 Array.isArray(value)
89 ? value.map(sort)
90 : value !== null && typeof value === "object"
91 ? Object.fromEntries(
92 Object.entries(value as Record<string, unknown>)
93 .sort(([a], [b]) => a.localeCompare(b))
94 .map(([k, v]) => [k, sort(v)]),
95 )
96 : value;
97 try {
98 return JSON.stringify(sort(JSON.parse(document)));
99 } catch {
100 return document;
101 }
102};
103
104export const ResourcePolicyProvider = () =>
105 Provider.effect(
106 ResourcePolicy,
107 Effect.gen(function* () {
108 const createPolicyName = Effect.fn(function* (
109 id: string,
110 props: Pick<ResourcePolicyProps, "policyName">,
111 ) {
112 // X-Ray policy names are limited to 128 characters.
113 return (
114 props.policyName ??
115 (yield* createPhysicalName({ id, maxLength: 128 }))
116 );
117 });
118
119 // X-Ray has no GetResourcePolicy — observe by enumerating the (at
120 // most 5) policies in the account and matching on the policy name.
121 const observePolicy = (policyName: string) =>
122 xray.listResourcePolicies.items({}).pipe(
123 Stream.filter((policy) => policy.PolicyName === policyName),
124 Stream.runHead,
125 Effect.map(Option.getOrUndefined),
126 );
127
128 return ResourcePolicy.Provider.of({
129 stables: ["policyName"],
130 list: () =>
131 Effect.gen(function* () {
132 const policies = yield* xray.listResourcePolicies
133 .items({})
134 .pipe(Stream.runCollect);
135 return Array.from(policies).flatMap((policy) =>
136 policy.PolicyName
137 ? [
138 {
139 policyName: policy.PolicyName,
140 policyRevisionId: policy.PolicyRevisionId,
141 },
142 ]
143 : [],
144 );
145 }),
146 read: Effect.fn(function* ({ id, olds, output }) {
147 const policyName =
148 output?.policyName ?? (yield* createPolicyName(id, olds ?? {}));
149 const found = yield* observePolicy(policyName);
150 if (found === undefined) return undefined;
151 // Resource policies are not taggable — ownership is keyed by the
152 // deterministic policy name.
153 return { policyName, policyRevisionId: found.PolicyRevisionId };
154 }),
155 diff: Effect.fn(function* ({ id, news, olds }) {
156 if (!isResolved(news)) return undefined;
157 const oldName = yield* createPolicyName(id, olds ?? {});
158 const newName = yield* createPolicyName(id, news ?? {});
159 if (oldName !== newName) {
160 return { action: "replace" } as const;
161 }
162 // fall through: engine default update logic for the document
163 }),
164 reconcile: Effect.fn(function* ({ id, news, output, session }) {
165 const policyName =
166 output?.policyName ?? (yield* createPolicyName(id, news));
167
168 // 1. OBSERVE — the live policy list is authoritative.
169 const existing = yield* observePolicy(policyName);
170
171 // 2/3. ENSURE + SYNC — `putResourcePolicy` is a true upsert; call
172 // it only when the canonical document drifts. The observed
173 // revision id guards against concurrent writers; a revision race
174 // surfaces as the typed InvalidPolicyRevisionIdException, which we
175 // resolve by re-observing and applying once more.
176 const putPolicy = (revisionId: string | undefined) =>
177 xray
178 .putResourcePolicy({
179 PolicyName: policyName,
180 PolicyDocument: news.policyDocument,
181 PolicyRevisionId: revisionId,
182 BypassPolicyLockoutCheck: news.bypassPolicyLockoutCheck,
183 })
184 .pipe(Effect.map((r) => r.ResourcePolicy));
185
186 const policy =
187 existing?.PolicyDocument !== undefined &&
188 canonicalJson(existing.PolicyDocument) ===
189 canonicalJson(news.policyDocument)
190 ? existing
191 : yield* putPolicy(existing?.PolicyRevisionId).pipe(
192 Effect.catchTag("InvalidPolicyRevisionIdException", () =>
193 observePolicy(policyName).pipe(
194 Effect.flatMap((observed) =>
195 putPolicy(observed?.PolicyRevisionId),
196 ),
197 ),
198 ),
199 );
200
201 yield* session.note(policyName);
202 return { policyName, policyRevisionId: policy?.PolicyRevisionId };
203 }),
204 delete: Effect.fn(function* ({ output }) {
205 // DeleteResourcePolicy without a revision id skips the revision
206 // check and is idempotent — deleting a missing policy succeeds.
207 yield* xray.deleteResourcePolicy({
208 PolicyName: output.policyName,
209 });
210 }),
211 });
212 }),
213 );