1import * as s3control from "@distilled.cloud/aws/s3-control";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { AWSEnvironment, type AccountID } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14import type { RegionID } from "../Region.ts";
15
16/**
17 * Block-public-access settings for an access point. Unlike buckets, AWS
18 * defaults every flag to `true` for access points when the configuration is
19 * omitted at creation time.
20 */
21export interface AccessPointPublicAccessBlock {
22 /**
23 * Block new public ACLs and uploading public objects.
24 * @default true
25 */
26 blockPublicAcls?: boolean;
27 /**
28 * Ignore all public ACLs on the access point.
29 * @default true
30 */
31 ignorePublicAcls?: boolean;
32 /**
33 * Block new access point policies that grant public access.
34 * @default true
35 */
36 blockPublicPolicy?: boolean;
37 /**
38 * Restrict access granted by public policies to AWS principals.
39 * @default true
40 */
41 restrictPublicBuckets?: boolean;
42}
43
44export interface AccessPointProps {
45 /**
46 * Name of the access point (3-50 characters, lowercase letters, numbers
47 * and hyphens). If omitted, a unique name is generated from the app,
48 * stage and logical ID.
49 *
50 * Changing the name replaces the access point.
51 * @default ${app}-${stage}-${id}
52 */
53 accessPointName?: string;
54 /**
55 * Name of the S3 bucket the access point is attached to.
56 *
57 * Changing the bucket replaces the access point.
58 */
59 bucket: string;
60 /**
61 * AWS account ID that owns the bucket, when the bucket is in a
62 * different account than the access point.
63 *
64 * Changing the bucket account replaces the access point.
65 */
66 bucketAccountId?: string;
67 /**
68 * Restrict the access point to a VPC. When set, only requests from the
69 * given VPC can reach the access point (`NetworkOrigin: VPC`); when
70 * omitted the access point accepts requests from the internet
71 * (`NetworkOrigin: Internet`).
72 *
73 * Changing the VPC configuration replaces the access point.
74 */
75 vpcConfiguration?: {
76 /** ID of the VPC the access point is restricted to. */
77 vpcId: string;
78 };
79 /**
80 * Block-public-access settings. AWS defaults every flag to `true` for
81 * access points when omitted.
82 *
83 * Changing these settings replaces the access point (they are
84 * create-only on access points).
85 */
86 publicAccessBlock?: AccessPointPublicAccessBlock;
87 /**
88 * Tags to apply to the access point. Merged with internal Alchemy tags.
89 */
90 tags?: Record<string, string>;
91}
92
93export interface AccessPoint extends Resource<
94 "AWS.S3Control.AccessPoint",
95 AccessPointProps,
96 {
97 /**
98 * Name of the access point.
99 */
100 accessPointName: string;
101 /**
102 * ARN of the access point.
103 */
104 accessPointArn: string;
105 /**
106 * The S3-assigned alias of the access point. The alias can be used
107 * anywhere a bucket name is accepted (e.g. `GetObject`).
108 */
109 alias: string | undefined;
110 /**
111 * Name of the bucket the access point is attached to.
112 */
113 bucket: string;
114 /**
115 * Whether the access point allows access from the public internet
116 * (`Internet`) or only from a VPC (`VPC`).
117 */
118 networkOrigin: s3control.NetworkOrigin;
119 /**
120 * AWS region of the access point.
121 */
122 region: RegionID;
123 /**
124 * AWS account ID that owns the access point.
125 */
126 accountId: AccountID;
127 },
128 never,
129 Providers
130> {}
131
132/**
133 * An Amazon S3 Access Point — a named network endpoint attached to a bucket
134 * with its own policy, public-access-block settings, and optional VPC
135 * restriction. Use access points to manage shared-dataset access at scale
136 * instead of maintaining one giant bucket policy.
137 * ### Creating Access Points
138 * **Example:** Internet access point on a bucket
139 * ```typescript
140 * import * as S3 from "alchemy/AWS/S3";
141 * import * as S3Control from "alchemy/AWS/S3Control";
142 *
143 * const bucket = yield* S3.Bucket("data", {});
144 * const accessPoint = yield* S3Control.AccessPoint("data-ap", {
145 * bucket: bucket.bucketName,
146 * });
147 * ```
148 *
149 * **Example:** VPC-only access point
150 * ```typescript
151 * const accessPoint = yield* S3Control.AccessPoint("internal-ap", {
152 * bucket: bucket.bucketName,
153 * vpcConfiguration: { vpcId: vpc.vpcId },
154 * });
155 * ```
156 *
157 * **Example:** Access point with explicit public-access-block
158 * ```typescript
159 * const accessPoint = yield* S3Control.AccessPoint("locked-ap", {
160 * bucket: bucket.bucketName,
161 * publicAccessBlock: {
162 * blockPublicAcls: true,
163 * ignorePublicAcls: true,
164 * blockPublicPolicy: true,
165 * restrictPublicBuckets: true,
166 * },
167 * tags: { team: "data" },
168 * });
169 * ```
170 *
171 * ### Granting Access
172 * **Example:** Attach a policy to the access point
173 * ```typescript
174 * yield* S3Control.AccessPointPolicy("data-ap-policy", {
175 * accessPointName: accessPoint.accessPointName,
176 * policy: {
177 * Version: "2012-10-17",
178 * Statement: [
179 * {
180 * Effect: "Allow",
181 * Principal: { AWS: `arn:aws:iam::${accountId}:role/reader` },
182 * Action: ["s3:GetObject"],
183 * Resource: [Output.interpolate`${accessPoint.accessPointArn}/object/*`],
184 * },
185 * ],
186 * },
187 * });
188 * ```
189 *
190 * @resource
191 */
192export const AccessPoint = Resource<AccessPoint>("AWS.S3Control.AccessPoint");
193
194/**
195 * Retry while the freshly-created access point has not propagated yet.
196 * `TagResource`/`ListTagsForResource` (and occasionally `GetAccessPoint`)
197 * return `NoSuchAccessPoint` for a few seconds after `CreateAccessPoint`
198 * succeeds.
199 *
200 * Explicitly typed at module scope — inlining `Effect.retry` in a lifecycle
201 * op leaks `Retry.Return`'s conditional type into declaration emit, widening
202 * the provider layer to `unknown` and poisoning `AWS.providers()`.
203 */
204const retryWhileAccessPointPropagates = <A, E extends { _tag: string }, R>(
205 self: Effect.Effect<A, E, R>,
206): Effect.Effect<A, E, R> =>
207 Effect.retry(self, {
208 while: (e) => e._tag === "NoSuchAccessPoint",
209 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(10)]),
210 });
211
212/**
213 * Marker error for the delete verification loop: the access point is still
214 * observable after `DeleteAccessPoint` reported success (or a spurious
215 * `NoSuchAccessPoint`). The same control-plane propagation lag that makes
216 * `GetAccessPoint`/`TagResource` 404 for a few seconds after create can make
217 * `DeleteAccessPoint` 404 for a freshly-created access point WITHOUT deleting
218 * it — swallowing that 404 as "already gone" orphans the access point (and
219 * poisons the owning bucket's delete with `BucketHasAccessPointsAttached`).
220 */
221class AccessPointNotYetDeleted extends Data.TaggedError(
222 "AccessPointNotYetDeleted",
223)<{ readonly name: string }> {}
224
225/**
226 * Retry while the access point is still observable after a delete attempt.
227 * Explicitly typed at module scope for the same declaration-emit reason as
228 * {@link retryWhileAccessPointPropagates}.
229 */
230const retryWhileAccessPointNotYetDeleted = <A, E extends { _tag: string }, R>(
231 self: Effect.Effect<A, E, R>,
232): Effect.Effect<A, E, R> =>
233 Effect.retry(self, {
234 while: (e) => e._tag === "AccessPointNotYetDeleted",
235 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(6)]),
236 });
237
238export const AccessPointProvider = () =>
239 Provider.effect(
240 AccessPoint,
241 Effect.gen(function* () {
242 const createName = Effect.fn(function* (
243 id: string,
244 props: Pick<AccessPointProps, "accessPointName">,
245 ) {
246 // Access point names: 3-50 chars, lowercase letters/numbers/hyphens.
247 return (
248 props.accessPointName ??
249 (yield* createPhysicalName({ id, maxLength: 50, lowercase: true }))
250 );
251 });
252
253 const accessPointArn = (
254 region: string,
255 accountId: string,
256 name: string,
257 ) => `arn:aws:s3:${region}:${accountId}:accesspoint/${name}`;
258
259 const observeAccessPoint = (accountId: string, name: string) =>
260 s3control
261 .getAccessPoint({ AccountId: accountId, Name: name })
262 .pipe(
263 Effect.catchTag("NoSuchAccessPoint", () =>
264 Effect.succeed(undefined),
265 ),
266 );
267
268 const observedTags = (accountId: string, arn: string) =>
269 s3control
270 .listTagsForResource({ AccountId: accountId, ResourceArn: arn })
271 .pipe(
272 Effect.map((r) =>
273 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
274 ),
275 Effect.catchTag("NoSuchAccessPoint", () =>
276 Effect.succeed({} as Record<string, string>),
277 ),
278 );
279
280 const toAttrs = (
281 name: string,
282 live: s3control.GetAccessPointResult,
283 region: RegionID,
284 accountId: AccountID,
285 ) => ({
286 accessPointName: name,
287 accessPointArn:
288 live.AccessPointArn ?? accessPointArn(region, accountId, name),
289 alias: live.Alias,
290 bucket: live.Bucket ?? "",
291 networkOrigin: live.NetworkOrigin ?? "Internet",
292 region,
293 accountId,
294 });
295
296 // Public-access-block flags default to `true` on access points.
297 const canonPab = (pab: AccessPointPublicAccessBlock | undefined) =>
298 JSON.stringify({
299 a: pab?.blockPublicAcls ?? true,
300 i: pab?.ignorePublicAcls ?? true,
301 p: pab?.blockPublicPolicy ?? true,
302 r: pab?.restrictPublicBuckets ?? true,
303 });
304
305 return AccessPoint.Provider.of({
306 stables: [
307 "accessPointName",
308 "accessPointArn",
309 "alias",
310 "bucket",
311 "networkOrigin",
312 "region",
313 "accountId",
314 ],
315 list: () =>
316 Effect.gen(function* () {
317 const { accountId, region } = yield* AWSEnvironment.current;
318 const pages = yield* s3control.listAccessPoints
319 .pages({ AccountId: accountId })
320 .pipe(Stream.runCollect);
321 return Array.from(pages).flatMap((page) =>
322 (page.AccessPointList ?? []).map((ap) => ({
323 accessPointName: ap.Name,
324 accessPointArn:
325 ap.AccessPointArn ??
326 accessPointArn(region, accountId, ap.Name),
327 alias: ap.Alias,
328 bucket: ap.Bucket,
329 networkOrigin: ap.NetworkOrigin,
330 region,
331 accountId,
332 })),
333 );
334 }),
335 read: Effect.fn(function* ({ id, olds, output }) {
336 const { accountId, region } = yield* AWSEnvironment.current;
337 const name =
338 output?.accessPointName ?? (yield* createName(id, olds ?? {}));
339 const live = yield* observeAccessPoint(accountId, name);
340 if (live === undefined) return undefined;
341 const attrs = toAttrs(name, live, region, accountId);
342 const tags = yield* observedTags(accountId, attrs.accessPointArn);
343 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
344 }),
345 diff: Effect.fn(function* ({ id, news, olds }) {
346 if (!isResolved(news)) return undefined;
347 const oldProps = olds ?? ({} as AccessPointProps);
348 const oldName = yield* createName(id, oldProps);
349 const newName = yield* createName(id, news);
350 if (
351 oldName !== newName ||
352 oldProps.bucket !== news.bucket ||
353 (oldProps.bucketAccountId ?? undefined) !==
354 (news.bucketAccountId ?? undefined) ||
355 (oldProps.vpcConfiguration?.vpcId ?? undefined) !==
356 (news.vpcConfiguration?.vpcId ?? undefined) ||
357 canonPab(oldProps.publicAccessBlock) !==
358 canonPab(news.publicAccessBlock)
359 ) {
360 // Everything except tags is create-only on an access point.
361 return { action: "replace" } as const;
362 }
363 // fall through: engine default update path (tags)
364 }),
365 reconcile: Effect.fn(function* ({ id, news, output, session }) {
366 const { accountId, region } = yield* AWSEnvironment.current;
367 const name = output?.accessPointName ?? (yield* createName(id, news));
368
369 // 1. OBSERVE — cloud state is authoritative.
370 let live = yield* observeAccessPoint(accountId, name);
371
372 // 2. ENSURE — create when missing; a concurrent create surfaces as
373 // the typed AccessPointAlreadyOwnedByYou tag (treated as a race)
374 // and a freshly-created access point can 404 for a few seconds.
375 if (live === undefined) {
376 yield* s3control
377 .createAccessPoint({
378 AccountId: accountId,
379 Name: name,
380 Bucket: news.bucket,
381 BucketAccountId: news.bucketAccountId,
382 VpcConfiguration: news.vpcConfiguration
383 ? { VpcId: news.vpcConfiguration.vpcId }
384 : undefined,
385 PublicAccessBlockConfiguration: news.publicAccessBlock
386 ? {
387 BlockPublicAcls: news.publicAccessBlock.blockPublicAcls,
388 IgnorePublicAcls: news.publicAccessBlock.ignorePublicAcls,
389 BlockPublicPolicy:
390 news.publicAccessBlock.blockPublicPolicy,
391 RestrictPublicBuckets:
392 news.publicAccessBlock.restrictPublicBuckets,
393 }
394 : undefined,
395 })
396 .pipe(
397 Effect.catchTag(
398 "AccessPointAlreadyOwnedByYou",
399 () => Effect.void,
400 ),
401 );
402 live = yield* retryWhileAccessPointPropagates(
403 s3control.getAccessPoint({ AccountId: accountId, Name: name }),
404 );
405 }
406
407 const attrs = toAttrs(name, live, region, accountId);
408
409 // 3. SYNC TAGS — diff against OBSERVED cloud tags so adoption
410 // converges. Tagging a fresh access point can race propagation.
411 const internalTags = yield* createInternalTags(id);
412 const desiredTags = { ...news.tags, ...internalTags };
413 const currentTags = yield* observedTags(
414 accountId,
415 attrs.accessPointArn,
416 );
417 const { upsert, removed } = diffTags(currentTags, desiredTags);
418 if (upsert.length > 0) {
419 yield* retryWhileAccessPointPropagates(
420 s3control.tagResource({
421 AccountId: accountId,
422 ResourceArn: attrs.accessPointArn,
423 Tags: upsert,
424 }),
425 );
426 }
427 if (removed.length > 0) {
428 yield* s3control.untagResource({
429 AccountId: accountId,
430 ResourceArn: attrs.accessPointArn,
431 TagKeys: removed,
432 });
433 }
434
435 yield* session.note(name);
436 return attrs;
437 }),
438 delete: Effect.fn(function* ({ output }) {
439 const { accountId } = yield* AWSEnvironment.current;
440 const name = output.accessPointName;
441 const deleteOnce = s3control
442 .deleteAccessPoint({ AccountId: accountId, Name: name })
443 .pipe(
444 // Idempotent delete — already gone is success.
445 Effect.catchTag("NoSuchAccessPoint", () => Effect.void),
446 );
447 yield* deleteOnce;
448 // Verify the access point is actually gone. `DeleteAccessPoint`
449 // can spuriously return `NoSuchAccessPoint` for a freshly-created
450 // access point (control-plane propagation lag) without deleting
451 // it; treating that as success orphans the access point. While
452 // the access point is still observable, delete again — bounded,
453 // then fail loudly rather than silently leak.
454 yield* retryWhileAccessPointNotYetDeleted(
455 observeAccessPoint(accountId, name).pipe(
456 Effect.flatMap((live) =>
457 live === undefined
458 ? Effect.void
459 : deleteOnce.pipe(
460 Effect.flatMap(() =>
461 Effect.fail(new AccessPointNotYetDeleted({ name })),
462 ),
463 ),
464 ),
465 ),
466 );
467 }),
468 });
469 }),
470 );