1import * as aoss from "@distilled.cloud/aws/opensearchserverless";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 canonicalizePolicy,
11 retryWhileConflict,
12 stringifyPolicy,
13} from "./internal.ts";
14
15/** The kind of security policy — encryption at rest or network access. */
16export type SecurityPolicyType = "encryption" | "network";
17
18/** A security policy document — a JSON object/array or a pre-serialized string. */
19export type SecurityPolicyDocument =
20 | string
21 | Record<string, unknown>
22 | readonly unknown[];
23
24export interface SecurityPolicyProps {
25 /**
26 * Name of the security policy (3-32 characters, lowercase). Must be unique
27 * within its {@link SecurityPolicyProps.type | type}. Changing the name
28 * replaces the policy.
29 * @default a generated physical name
30 */
31 policyName?: string;
32 /**
33 * The policy kind:
34 * - `encryption` — encryption-at-rest policy (a single JSON object with
35 * `Rules` and either `AWSOwnedKey` or `KmsARN`). Every collection MUST be
36 * matched by exactly one encryption policy before it can be created.
37 * - `network` — network-access policy (a JSON array of rules controlling
38 * public/VPC access to the collection and Dashboards endpoints).
39 *
40 * Changing the type replaces the policy.
41 */
42 type: SecurityPolicyType;
43 /**
44 * The policy document. Supply a JSON object (encryption) or array (network),
45 * or a pre-serialized string when you need to interpolate an `Output`-derived
46 * collection name into it.
47 */
48 policy: SecurityPolicyDocument;
49 /**
50 * A human-readable description of the policy.
51 */
52 description?: string;
53}
54
55export interface SecurityPolicy extends Resource<
56 "AWS.OpenSearchServerless.SecurityPolicy",
57 SecurityPolicyProps,
58 {
59 /**
60 * Name of the security policy.
61 */
62 policyName: string;
63 /**
64 * Policy type (`encryption` or `network`).
65 */
66 type: string;
67 /**
68 * Version of the policy, used for optimistic-concurrency updates.
69 */
70 policyVersion: string;
71 /**
72 * Description of the security policy.
73 */
74 description?: string;
75 },
76 {},
77 Providers
78> {}
79
80/**
81 * An Amazon OpenSearch Serverless security policy. Security policies govern
82 * encryption at rest (`encryption`) and network access (`network`) for one or
83 * more collections, matched by a resource pattern such as
84 * `collection/my-collection`.
85 *
86 * An **encryption** policy is a prerequisite for every collection — a
87 * collection whose name is not covered by an encryption policy fails to
88 * create. A **network** policy controls whether the collection's data and
89 * OpenSearch Dashboards endpoints are reachable from public networks or only
90 * from specific VPC endpoints.
91 *
92 * ### Encryption Policies
93 * **Example:** AWS-Owned-Key Encryption Policy
94 * ```typescript
95 * import * as AWS from "alchemy/AWS";
96 *
97 * const encryption = yield* AWS.OpenSearchServerless.SecurityPolicy("Encryption", {
98 * policyName: "my-collection-enc",
99 * type: "encryption",
100 * policy: {
101 * Rules: [{ ResourceType: "collection", Resource: ["collection/my-collection"] }],
102 * AWSOwnedKey: true,
103 * },
104 * });
105 * ```
106 *
107 * ### Network Policies
108 * **Example:** Public Network Access Policy
109 * ```typescript
110 * const network = yield* AWS.OpenSearchServerless.SecurityPolicy("Network", {
111 * policyName: "my-collection-net",
112 * type: "network",
113 * policy: [
114 * {
115 * Rules: [
116 * { ResourceType: "collection", Resource: ["collection/my-collection"] },
117 * { ResourceType: "dashboard", Resource: ["collection/my-collection"] },
118 * ],
119 * AllowFromPublic: true,
120 * },
121 * ],
122 * });
123 * ```
124 *
125 * @resource
126 */
127export const SecurityPolicy = Resource<SecurityPolicy>(
128 "AWS.OpenSearchServerless.SecurityPolicy",
129);
130
131export const SecurityPolicyProvider = () =>
132 Provider.effect(
133 SecurityPolicy,
134 Effect.gen(function* () {
135 const createName = Effect.fn(function* (
136 id: string,
137 props: { policyName?: string | undefined },
138 ) {
139 return (
140 props.policyName ??
141 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
142 );
143 });
144
145 const toAttributes = (detail: aoss.SecurityPolicyDetail) => ({
146 policyName: detail.name!,
147 type: detail.type!,
148 policyVersion: detail.policyVersion!,
149 description: detail.description,
150 });
151
152 return SecurityPolicy.Provider.of({
153 stables: ["policyName", "type"],
154
155 list: () =>
156 Effect.gen(function* () {
157 const types: SecurityPolicyType[] = ["encryption", "network"];
158 const results: {
159 policyName: string;
160 type: string;
161 policyVersion: string;
162 description?: string;
163 }[] = [];
164 for (const type of types) {
165 const pages = yield* aoss.listSecurityPolicies
166 .pages({ type })
167 .pipe(Stream.runCollect);
168 for (const page of pages) {
169 for (const s of page.securityPolicySummaries ?? []) {
170 if (
171 s.name !== undefined &&
172 s.type !== undefined &&
173 s.policyVersion !== undefined
174 ) {
175 results.push({
176 policyName: s.name,
177 type: s.type,
178 policyVersion: s.policyVersion,
179 description: s.description,
180 });
181 }
182 }
183 }
184 }
185 return results;
186 }),
187
188 read: Effect.fn(function* ({ id, olds, output }) {
189 const type = output?.type ?? olds?.type;
190 if (type === undefined) {
191 return undefined;
192 }
193 const name =
194 output?.policyName ?? (yield* createName(id, olds ?? {}));
195 const found = yield* aoss
196 .getSecurityPolicy({ type, name })
197 .pipe(
198 Effect.catchTag("ResourceNotFoundException", () =>
199 Effect.succeed(undefined),
200 ),
201 );
202 if (found?.securityPolicyDetail === undefined) {
203 return undefined;
204 }
205 // Security policies carry no tags, so ownership cannot be gated —
206 // an existing same-name policy is adopted.
207 return toAttributes(found.securityPolicyDetail);
208 }),
209
210 diff: Effect.fn(function* ({ id, news, olds }) {
211 if (!isResolved(news)) return undefined;
212 if (olds.type !== news.type) {
213 return { action: "replace" } as const;
214 }
215 const oldName = yield* createName(id, olds);
216 const newName = yield* createName(id, news);
217 if (oldName !== newName) {
218 return { action: "replace" } as const;
219 }
220 // description/policy fall through to the default update path
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output, session }) {
224 const type = news.type;
225 const name = output?.policyName ?? (yield* createName(id, news));
226 const policy = stringifyPolicy(news.policy);
227
228 // 1. OBSERVE
229 let detail = yield* aoss.getSecurityPolicy({ type, name }).pipe(
230 Effect.map((r) => r.securityPolicyDetail),
231 Effect.catchTag("ResourceNotFoundException", () =>
232 Effect.succeed(undefined),
233 ),
234 );
235
236 // 2. ENSURE — create if missing; tolerate a concurrent create race
237 if (detail === undefined) {
238 detail = yield* aoss
239 .createSecurityPolicy({
240 type,
241 name,
242 policy,
243 description: news.description,
244 })
245 .pipe(
246 Effect.map((r) => r.securityPolicyDetail),
247 Effect.catchTag("ConflictException", () =>
248 aoss
249 .getSecurityPolicy({ type, name })
250 .pipe(Effect.map((r) => r.securityPolicyDetail)),
251 ),
252 );
253 } else {
254 // 3. SYNC — update policy/description when observed drifts from desired
255 const policyDrift =
256 canonicalizePolicy(detail.policy) !== canonicalizePolicy(policy);
257 const descriptionDrift =
258 news.description !== undefined &&
259 news.description !== detail.description;
260 if (policyDrift || descriptionDrift) {
261 detail = yield* aoss
262 .updateSecurityPolicy({
263 type,
264 name,
265 policyVersion: detail.policyVersion!,
266 policy: policyDrift ? policy : undefined,
267 description: descriptionDrift ? news.description : undefined,
268 })
269 .pipe(Effect.map((r) => r.securityPolicyDetail));
270 }
271 }
272
273 if (detail?.name === undefined) {
274 return yield* Effect.fail(
275 new aoss.ResourceNotFoundException({
276 message: `security policy ${type}/${name} not visible after reconcile`,
277 }),
278 );
279 }
280 yield* session.note(`${type}/${name}`);
281 return toAttributes(detail);
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 yield* retryWhileConflict(
286 aoss.deleteSecurityPolicy({
287 type: output.type,
288 name: output.policyName,
289 }),
290 ).pipe(
291 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
292 );
293 }),
294 });
295 }),
296 );