1import * as osis from "@distilled.cloud/aws/osis";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7
8export interface ResourcePolicyProps {
9 /**
10 * ARN of the OSIS resource (pipeline) the policy is attached to. A
11 * resource has at most one resource-based policy. Changing the resource
12 * replaces the policy.
13 */
14 resourceArn: string;
15 /**
16 * The IAM resource-based policy document (JSON) that grants other
17 * principals or accounts access to the resource (e.g. `osis:Ingest`,
18 * `osis:CreatePipelineEndpoint`). Updated in place.
19 */
20 policy: string;
21}
22
23export interface ResourcePolicy extends Resource<
24 "AWS.OSIS.ResourcePolicy",
25 ResourcePolicyProps,
26 {
27 /**
28 * ARN of the resource the policy is attached to.
29 */
30 resourceArn: string;
31 /**
32 * The attached policy document (JSON).
33 */
34 policy: string;
35 },
36 never,
37 Providers
38> {}
39
40/**
41 * The resource-based policy of an Amazon OpenSearch Ingestion (OSIS)
42 * pipeline — grants cross-account principals access to the pipeline, e.g.
43 * `osis:Ingest` for cross-account ingestion or
44 * `osis:CreatePipelineEndpoint` so another account can attach a VPC
45 * endpoint. A resource has at most one.
46 *
47 * ### Creating a Resource Policy
48 * **Example:** Allow Another Account to Ingest
49 * ```typescript
50 * const policy = yield* OSIS.ResourcePolicy("CrossAccountIngest", {
51 * resourceArn: pipeline.pipelineArn,
52 * policy: Output.interpolate`{
53 * "Version": "2012-10-17",
54 * "Statement": [
55 * {
56 * "Effect": "Allow",
57 * "Principal": { "AWS": "arn:aws:iam::123456789012:root" },
58 * "Action": ["osis:Ingest"],
59 * "Resource": "${pipeline.pipelineArn}"
60 * }
61 * ]
62 * }`,
63 * });
64 * ```
65 *
66 * @resource
67 */
68export const ResourcePolicy = Resource<ResourcePolicy>(
69 "AWS.OSIS.ResourcePolicy",
70);
71
72/** Order-insensitive canonical form of a JSON policy document. */
73const canonicalJson = (document: string): string => {
74 const sort = (value: unknown): unknown =>
75 Array.isArray(value)
76 ? value.map(sort)
77 : value !== null && typeof value === "object"
78 ? Object.fromEntries(
79 Object.entries(value as Record<string, unknown>)
80 .sort(([a], [b]) => a.localeCompare(b))
81 .map(([k, v]) => [k, sort(v)]),
82 )
83 : value;
84 try {
85 return JSON.stringify(sort(JSON.parse(document)));
86 } catch {
87 return document;
88 }
89};
90
91export const ResourcePolicyProvider = () =>
92 Provider.effect(
93 ResourcePolicy,
94 Effect.gen(function* () {
95 /**
96 * Read the attached policy. OSIS reports "no policy" two ways: a typed
97 * `ResourceNotFoundException`, or (observed live) a success response
98 * whose `Policy` is the empty document `"{}"` — normalize both to
99 * `undefined`.
100 */
101 const getPolicy = Effect.fn(function* (resourceArn: string) {
102 const policy = yield* osis
103 .getResourcePolicy({ ResourceArn: resourceArn })
104 .pipe(
105 Effect.map((response) => response.Policy),
106 Effect.catchTag("ResourceNotFoundException", () =>
107 Effect.succeed(undefined),
108 ),
109 );
110 return policy === undefined || canonicalJson(policy) === "{}"
111 ? undefined
112 : policy;
113 });
114
115 return {
116 stables: ["resourceArn"],
117
118 diff: Effect.fn(function* ({ olds, news }) {
119 if (!isResolved(news)) return undefined;
120 if (olds?.resourceArn !== news.resourceArn) {
121 return { action: "replace" } as const;
122 }
123 }),
124
125 read: Effect.fn(function* ({ olds, output }) {
126 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
127 if (!resourceArn) return undefined;
128 const policy = yield* getPolicy(resourceArn);
129 if (policy === undefined) return undefined;
130 // Resource policies are not taggable — ownership is implied by
131 // the owned parent pipeline.
132 return { resourceArn, policy };
133 }),
134
135 reconcile: Effect.fn(function* ({ news, session }) {
136 const resourceArn = news!.resourceArn;
137
138 // 1. Observe — the live policy is authoritative.
139 const observed = yield* getPolicy(resourceArn);
140
141 // 2/3. Ensure + sync — `putResourcePolicy` is a true upsert;
142 // call it only when the canonical document drifts.
143 if (
144 observed === undefined ||
145 canonicalJson(observed) !== canonicalJson(news!.policy)
146 ) {
147 yield* osis.putResourcePolicy({
148 ResourceArn: resourceArn,
149 Policy: news!.policy,
150 });
151 }
152
153 yield* session.note(resourceArn);
154 return { resourceArn, policy: news!.policy };
155 }),
156
157 delete: Effect.fn(function* ({ output }) {
158 yield* osis
159 .deleteResourcePolicy({ ResourceArn: output.resourceArn })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
162 );
163 }),
164
165 // Singleton sub-resource keyed by its parent resource ARN.
166 list: () => Effect.succeed([]),
167 };
168 }),
169 );