1import * as storage from "@distilled.cloud/gcp/storage_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { DeleteNotConfirmed } from "../Errors.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 isUserManagedAclEntity,
12 listAlchemyBuckets,
13 normalizeEntity,
14 normalizeRole,
15} from "./internal.ts";
16
17export type BucketAccessControlProps = {
18 /**
19 * Name of the bucket this ACL entry is attached to. Immutable —
20 * changing it replaces the entry.
21 */
22 bucketName: string;
23 /**
24 * Principal granted the role. Forms include `user-{email}`,
25 * `group-{email}`, `domain-{domain}`, `allUsers`, and
26 * `allAuthenticatedUsers`. Immutable — changing it replaces the entry.
27 */
28 entity: string;
29 /**
30 * Access permission for the entity (`OWNER`, `WRITER`, or `READER`).
31 */
32 role: string;
33};
34
35export type BucketAccessControl = Resource<
36 "GCP.Storage.BucketAccessControl",
37 BucketAccessControlProps,
38 {
39 /** Parent bucket name. */
40 bucketName: string;
41 /** Principal granted the role. */
42 entity: string;
43 /** Access permission (`OWNER`, `WRITER`, or `READER`). */
44 role: string;
45 /** Server-assigned ACL id. */
46 id: string | undefined;
47 /** Domain associated with the entity, if any. */
48 domain: string | undefined;
49 /** Email associated with the entity, if any. */
50 email: string | undefined;
51 /** Numeric entity id, if any. */
52 entityId: string | undefined;
53 /** HTTP etag. */
54 etag: string | undefined;
55 /** GCS self-link. */
56 selfLink: string | undefined;
57 },
58 never,
59 Providers
60>;
61
62/**
63 * A Cloud Storage bucket ACL entry.
64 *
65 * Fine-grained ACLs are identity `(bucket, entity)`. Role is mutable;
66 * changing `bucketName` or `entity` replaces the entry. Entries have no
67 * labels field, so `list` / `pnpm nuke:gcp` discover them by enumerating
68 * alchemy-labeled buckets and skipping project-team defaults.
69 *
70 * Uniform bucket-level access buckets do not support bucket ACLs.
71 *
72 * ### Creating a Bucket ACL
73 * **Example:** Grant a service account writer access
74 * ```typescript
75 * const bucket = yield* GCP.Storage.Bucket("assets", {
76 * forceDestroy: true,
77 * });
78 * const acl = yield* GCP.Storage.BucketAccessControl("writer", {
79 * bucketName: bucket.bucketName,
80 * entity: "user-app@project.iam.gserviceaccount.com",
81 * role: "WRITER",
82 * });
83 * ```
84 *
85 * **Example:** Update the role
86 * ```typescript
87 * const acl = yield* GCP.Storage.BucketAccessControl("writer", {
88 * bucketName: bucket.bucketName,
89 * entity: "user-app@project.iam.gserviceaccount.com",
90 * role: "READER",
91 * });
92 * ```
93 *
94 * @resource
95 * @category Storage
96 */
97export const BucketAccessControl = Resource<BucketAccessControl>(
98 "GCP.Storage.BucketAccessControl",
99);
100
101export class BucketAccessControlNotResolved extends Data.TaggedError(
102 "GCP.Storage.BucketAccessControlNotResolved",
103)<{
104 bucketName: string;
105 entity: string;
106}> {}
107
108const toAttrs = (acl: storage.BucketAccessControl, bucketName: string) => ({
109 bucketName: acl.bucket ?? bucketName,
110 entity: acl.entity ?? "",
111 role: acl.role ?? "",
112 id: acl.id,
113 domain: acl.domain,
114 email: acl.email,
115 entityId: acl.entityId,
116 etag: acl.etag,
117 selfLink: acl.selfLink,
118});
119
120const getByEntity = (bucketName: string, entity: string) =>
121 storage
122 .getBucketAccessControls({ bucket: bucketName, entity })
123 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
124
125const listOnBucket = (bucketName: string) =>
126 storage.listBucketAccessControls({ bucket: bucketName }).pipe(
127 Effect.map((page) => page.items ?? []),
128 Effect.catchTag("NotFound", () =>
129 Effect.succeed([] as storage.BucketAccessControl[]),
130 ),
131 );
132
133const waitUntilGone = (bucketName: string, entity: string) =>
134 getByEntity(bucketName, entity).pipe(
135 Effect.map((existing) =>
136 existing === undefined ? ("gone" as const) : ("found" as const),
137 ),
138 Effect.repeat({
139 schedule: Schedule.spaced("1 second"),
140 until: (status) => status === "gone",
141 times: 30,
142 }),
143 Effect.flatMap((status): Effect.Effect<void, DeleteNotConfirmed> =>
144 status === "gone"
145 ? Effect.void
146 : Effect.fail(
147 new DeleteNotConfirmed({ resource: `${bucketName}/acl/${entity}` }),
148 ),
149 ),
150 );
151
152export const BucketAccessControlProvider = () =>
153 Provider.succeed(BucketAccessControl, {
154 stables: ["bucketName", "entity", "id", "selfLink"],
155
156 diff: Effect.fn(function* ({ news, olds, output }) {
157 if (!isResolved(news)) return undefined;
158 const previousBucket = olds?.bucketName ?? output?.bucketName;
159 const previousEntity = olds?.entity ?? output?.entity;
160 if (previousBucket !== undefined && news.bucketName !== previousBucket) {
161 return { action: "replace" as const, deleteFirst: false };
162 }
163 if (
164 previousEntity !== undefined &&
165 normalizeEntity(news.entity) !== normalizeEntity(previousEntity)
166 ) {
167 return { action: "replace" as const, deleteFirst: false };
168 }
169 return undefined;
170 }),
171
172 read: Effect.fn(function* ({ olds, output }) {
173 const bucketName = olds?.bucketName ?? output?.bucketName;
174 const entity = olds?.entity ?? output?.entity;
175 if (!bucketName || !entity) return undefined;
176 const existing = yield* getByEntity(bucketName, entity);
177 if (existing === undefined) return undefined;
178 return toAttrs(existing, bucketName);
179 }),
180
181 list: () =>
182 Effect.gen(function* () {
183 const buckets = yield* listAlchemyBuckets();
184 const pages = yield* Effect.forEach(
185 buckets,
186 (bucket) => {
187 const bucketName = bucket.name;
188 // Legacy ACL APIs reject uniform bucket-level access buckets.
189 if (
190 !bucketName ||
191 bucket.iamConfiguration?.uniformBucketLevelAccess?.enabled ===
192 true
193 ) {
194 return Effect.succeed(
195 [] as Array<BucketAccessControl["Attributes"]>,
196 );
197 }
198 return listOnBucket(bucketName).pipe(
199 Effect.map((items) =>
200 items
201 .filter((item) => isUserManagedAclEntity(item.entity))
202 .map((item) => toAttrs(item, bucketName)),
203 ),
204 );
205 },
206 { concurrency: 8 },
207 );
208 return pages.flat();
209 }),
210
211 reconcile: Effect.fn(function* ({ news, output }) {
212 const bucketName = news.bucketName;
213 const entity = normalizeEntity(news.entity);
214 const role = normalizeRole(news.role);
215
216 let current =
217 output?.entity !== undefined
218 ? yield* getByEntity(
219 output.bucketName ?? bucketName,
220 normalizeEntity(output.entity),
221 )
222 : undefined;
223 if (current === undefined) {
224 current = yield* getByEntity(bucketName, entity);
225 }
226
227 if (current === undefined) {
228 const created = yield* storage
229 .insertBucketAccessControls({
230 bucket: bucketName,
231 body: { entity, role },
232 })
233 .pipe(
234 Effect.catchTag("Conflict", () => getByEntity(bucketName, entity)),
235 );
236 current = created ?? undefined;
237 }
238
239 if (current === undefined || !current.entity) {
240 return yield* new BucketAccessControlNotResolved({
241 bucketName,
242 entity,
243 });
244 }
245
246 if (normalizeRole(current.role ?? "") !== role) {
247 current = yield* storage.patchBucketAccessControls({
248 bucket: bucketName,
249 entity,
250 body: { entity, role },
251 });
252 }
253
254 return toAttrs(current, bucketName);
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 const bucketName = output.bucketName;
259 const entity = output.entity;
260 if (!bucketName || !entity) return;
261 yield* storage
262 .deleteBucketAccessControls({ bucket: bucketName, entity })
263 .pipe(Effect.catchTag("NotFound", () => Effect.void));
264 yield* waitUntilGone(bucketName, entity);
265 }),
266 });