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";
14import type { SecurityPolicyDocument } from "./SecurityPolicy.ts";
15
16export interface AccessPolicyProps {
17 /**
18 * Name of the data access policy (3-32 characters, lowercase). Changing the
19 * name replaces the policy.
20 * @default a generated physical name
21 */
22 policyName?: string;
23 /**
24 * The data access policy document — a JSON array of rules granting principals
25 * (IAM roles/users) collection- and index-level permissions such as
26 * `aoss:CreateIndex`, `aoss:ReadDocument`, `aoss:WriteDocument`. Supply an
27 * array/object or a pre-serialized string.
28 */
29 policy: SecurityPolicyDocument;
30 /**
31 * A human-readable description of the policy.
32 */
33 description?: string;
34}
35
36export interface AccessPolicy extends Resource<
37 "AWS.OpenSearchServerless.AccessPolicy",
38 AccessPolicyProps,
39 {
40 /**
41 * Name of the access policy.
42 */
43 policyName: string;
44 /**
45 * Policy type (`data`).
46 */
47 type: string;
48 /**
49 * Version of the policy, used for optimistic-concurrency updates.
50 */
51 policyVersion: string;
52 /**
53 * Description of the access policy.
54 */
55 description?: string;
56 },
57 {},
58 Providers
59> {}
60
61/**
62 * An Amazon OpenSearch Serverless data access policy. Data access policies
63 * grant IAM principals fine-grained permissions on collections and their
64 * indexes (create/read/write/delete documents, create indexes, etc.) — they
65 * are the data-plane authorization layer that complements the network and
66 * encryption {@link SecurityPolicy | security policies}.
67 *
68 * A Bedrock Knowledge Base backed by an OpenSearch Serverless collection
69 * requires a data access policy granting the Knowledge Base's service role
70 * `aoss:APIAccessAll` on the collection and its indexes.
71 *
72 * ### Creating Access Policies
73 * **Example:** Grant a Role Full Data Access
74 * ```typescript
75 * import * as AWS from "alchemy/AWS";
76 *
77 * const access = yield* AWS.OpenSearchServerless.AccessPolicy("Access", {
78 * policyName: "my-collection-access",
79 * policy: [
80 * {
81 * Rules: [
82 * {
83 * ResourceType: "collection",
84 * Resource: ["collection/my-collection"],
85 * Permission: ["aoss:*"],
86 * },
87 * {
88 * ResourceType: "index",
89 * Resource: ["index/my-collection/*"],
90 * Permission: ["aoss:*"],
91 * },
92 * ],
93 * Principal: ["arn:aws:iam::123456789012:role/my-role"],
94 * },
95 * ],
96 * });
97 * ```
98 *
99 * @resource
100 */
101export const AccessPolicy = Resource<AccessPolicy>(
102 "AWS.OpenSearchServerless.AccessPolicy",
103);
104
105// OpenSearch Serverless only defines the "data" access-policy type.
106const ACCESS_POLICY_TYPE = "data";
107
108export const AccessPolicyProvider = () =>
109 Provider.effect(
110 AccessPolicy,
111 Effect.gen(function* () {
112 const createName = Effect.fn(function* (
113 id: string,
114 props: { policyName?: string | undefined },
115 ) {
116 return (
117 props.policyName ??
118 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
119 );
120 });
121
122 const toAttributes = (detail: aoss.AccessPolicyDetail) => ({
123 policyName: detail.name!,
124 type: detail.type!,
125 policyVersion: detail.policyVersion!,
126 description: detail.description,
127 });
128
129 return AccessPolicy.Provider.of({
130 stables: ["policyName", "type"],
131
132 list: () =>
133 Effect.gen(function* () {
134 const pages = yield* aoss.listAccessPolicies
135 .pages({ type: ACCESS_POLICY_TYPE })
136 .pipe(Stream.runCollect);
137 return Array.from(pages)
138 .flatMap((page) => page.accessPolicySummaries ?? [])
139 .filter(
140 (s) =>
141 s.name !== undefined &&
142 s.type !== undefined &&
143 s.policyVersion !== undefined,
144 )
145 .map((s) => ({
146 policyName: s.name!,
147 type: s.type!,
148 policyVersion: s.policyVersion!,
149 description: s.description,
150 }));
151 }),
152
153 read: Effect.fn(function* ({ id, olds, output }) {
154 const name =
155 output?.policyName ?? (yield* createName(id, olds ?? {}));
156 const found = yield* aoss
157 .getAccessPolicy({ type: ACCESS_POLICY_TYPE, name })
158 .pipe(
159 Effect.catchTag("ResourceNotFoundException", () =>
160 Effect.succeed(undefined),
161 ),
162 );
163 if (found?.accessPolicyDetail === undefined) {
164 return undefined;
165 }
166 // Access policies carry no tags, so an existing same-name policy is
167 // adopted.
168 return toAttributes(found.accessPolicyDetail);
169 }),
170
171 diff: Effect.fn(function* ({ id, news, olds }) {
172 if (!isResolved(news)) return undefined;
173 const oldName = yield* createName(id, olds);
174 const newName = yield* createName(id, news);
175 if (oldName !== newName) {
176 return { action: "replace" } as const;
177 }
178 // description/policy fall through to the default update path
179 }),
180
181 reconcile: Effect.fn(function* ({ id, news, output, session }) {
182 const name = output?.policyName ?? (yield* createName(id, news));
183 const policy = stringifyPolicy(news.policy);
184
185 // 1. OBSERVE
186 let detail = yield* aoss
187 .getAccessPolicy({ type: ACCESS_POLICY_TYPE, name })
188 .pipe(
189 Effect.map((r) => r.accessPolicyDetail),
190 Effect.catchTag("ResourceNotFoundException", () =>
191 Effect.succeed(undefined),
192 ),
193 );
194
195 // 2. ENSURE
196 if (detail === undefined) {
197 detail = yield* aoss
198 .createAccessPolicy({
199 type: ACCESS_POLICY_TYPE,
200 name,
201 policy,
202 description: news.description,
203 })
204 .pipe(
205 Effect.map((r) => r.accessPolicyDetail),
206 Effect.catchTag("ConflictException", () =>
207 aoss
208 .getAccessPolicy({ type: ACCESS_POLICY_TYPE, name })
209 .pipe(Effect.map((r) => r.accessPolicyDetail)),
210 ),
211 );
212 } else {
213 // 3. SYNC
214 const policyDrift =
215 canonicalizePolicy(detail.policy) !== canonicalizePolicy(policy);
216 const descriptionDrift =
217 news.description !== undefined &&
218 news.description !== detail.description;
219 if (policyDrift || descriptionDrift) {
220 detail = yield* aoss
221 .updateAccessPolicy({
222 type: ACCESS_POLICY_TYPE,
223 name,
224 policyVersion: detail.policyVersion!,
225 policy: policyDrift ? policy : undefined,
226 description: descriptionDrift ? news.description : undefined,
227 })
228 .pipe(Effect.map((r) => r.accessPolicyDetail));
229 }
230 }
231
232 if (detail?.name === undefined) {
233 return yield* Effect.fail(
234 new aoss.ResourceNotFoundException({
235 message: `access policy ${name} not visible after reconcile`,
236 }),
237 );
238 }
239 yield* session.note(name);
240 return toAttributes(detail);
241 }),
242
243 delete: Effect.fn(function* ({ output }) {
244 yield* retryWhileConflict(
245 aoss.deleteAccessPolicy({
246 type: ACCESS_POLICY_TYPE,
247 name: output.policyName,
248 }),
249 ).pipe(
250 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
251 );
252 }),
253 });
254 }),
255 );