1import * as s3control from "@distilled.cloud/aws/s3-control";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { AWSEnvironment } from "../Environment.ts";
7import {
8 normalizePolicyDocument,
9 type PolicyDocument,
10 stringifyPolicyDocument,
11} from "../IAM/Policy.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface AccessPointPolicyProps {
15 /**
16 * Name of the access point the policy is attached to.
17 *
18 * Changing the access point replaces the policy.
19 */
20 accessPointName: string;
21 /**
22 * The resource policy granting access through the access point, as a
23 * typed {@link PolicyDocument} or a raw JSON string (escape hatch /
24 * adoption). Object-level actions target `${accessPointArn}/object/${key}`.
25 */
26 policy: PolicyDocument | string;
27}
28
29export interface AccessPointPolicy extends Resource<
30 "AWS.S3Control.AccessPointPolicy",
31 AccessPointPolicyProps,
32 {
33 /**
34 * Name of the access point the policy is attached to.
35 */
36 accessPointName: string;
37 },
38 never,
39 Providers
40> {}
41
42/**
43 * The resource policy of an S3 Access Point. Grants principals access to
44 * objects through the access point — the delegated replacement for a giant
45 * shared bucket policy.
46 *
47 * Note that the underlying bucket must delegate access control to the access
48 * point (or the principals must also be allowed by the bucket policy).
49 * ### Attaching a Policy
50 * **Example:** Allow a role to read objects through the access point
51 * ```typescript
52 * import * as S3Control from "alchemy/AWS/S3Control";
53 *
54 * const accessPoint = yield* S3Control.AccessPoint("data-ap", {
55 * bucket: bucket.bucketName,
56 * });
57 *
58 * yield* S3Control.AccessPointPolicy("data-ap-policy", {
59 * accessPointName: accessPoint.accessPointName,
60 * policy: {
61 * Version: "2012-10-17",
62 * Statement: [
63 * {
64 * Effect: "Allow",
65 * Principal: { AWS: `arn:aws:iam::${accountId}:role/reader` },
66 * Action: ["s3:GetObject"],
67 * Resource: [Output.interpolate`${accessPoint.accessPointArn}/object/*`],
68 * },
69 * ],
70 * },
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const AccessPointPolicy = Resource<AccessPointPolicy>(
77 "AWS.S3Control.AccessPointPolicy",
78);
79
80export const AccessPointPolicyProvider = () =>
81 Provider.effect(
82 AccessPointPolicy,
83 Effect.gen(function* () {
84 const observePolicy = (accountId: string, name: string) =>
85 s3control
86 .getAccessPointPolicy({ AccountId: accountId, Name: name })
87 .pipe(
88 Effect.map((r) => r.Policy),
89 Effect.catchTag(
90 ["NoSuchAccessPointPolicy", "NoSuchAccessPoint"],
91 () => Effect.succeed(undefined),
92 ),
93 );
94
95 const desiredPolicy = (props: AccessPointPolicyProps) =>
96 typeof props.policy === "string"
97 ? props.policy
98 : stringifyPolicyDocument(props.policy);
99
100 return AccessPointPolicy.Provider.of({
101 stables: ["accessPointName"],
102 // Sub-resource keyed by its access point — there is no account-wide
103 // enumeration of access point policies, and the policy is deleted
104 // with its access point, so nuke discovers it via the parent.
105 list: () => Effect.succeed([]),
106 read: Effect.fn(function* ({ olds, output }) {
107 const { accountId } = yield* AWSEnvironment.current;
108 const name = output?.accessPointName ?? olds?.accessPointName;
109 if (name === undefined) return undefined;
110 const policy = yield* observePolicy(accountId, name);
111 if (policy === undefined) return undefined;
112 return { accessPointName: name };
113 }),
114 diff: Effect.fn(function* ({ news, olds }) {
115 if (!isResolved(news)) return undefined;
116 if (
117 olds !== undefined &&
118 olds.accessPointName !== news.accessPointName
119 ) {
120 return { action: "replace" } as const;
121 }
122 // fall through: engine default update path (policy content)
123 }),
124 reconcile: Effect.fn(function* ({ news, session }) {
125 const { accountId } = yield* AWSEnvironment.current;
126 const name = news.accessPointName;
127
128 // 1. OBSERVE — read the policy currently attached in the cloud.
129 const observed = yield* observePolicy(accountId, name);
130
131 // 2. SYNC — apply only when the desired document differs after
132 // canonicalization (key order / whitespace insensitive), so a
133 // re-deploy of an unchanged document is a no-op.
134 const desired = desiredPolicy(news);
135 if (
136 observed === undefined ||
137 normalizePolicyDocument(observed) !==
138 normalizePolicyDocument(desired)
139 ) {
140 yield* s3control.putAccessPointPolicy({
141 AccountId: accountId,
142 Name: name,
143 Policy: desired,
144 });
145 }
146
147 yield* session.note(name);
148 return { accessPointName: name };
149 }),
150 delete: Effect.fn(function* ({ output }) {
151 const { accountId } = yield* AWSEnvironment.current;
152 yield* s3control
153 .deleteAccessPointPolicy({
154 AccountId: accountId,
155 Name: output.accessPointName,
156 })
157 .pipe(
158 // Idempotent delete — a policy (or its whole access point)
159 // that is already gone is success.
160 Effect.catchTag(
161 ["NoSuchAccessPointPolicy", "NoSuchAccessPoint"],
162 () => Effect.void,
163 ),
164 );
165 }),
166 });
167 }),
168 );