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 * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { DeleteNotConfirmed } from "../Errors.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 isUserManagedAclEntity,
13 listAlchemyBuckets,
14 normalizeEntity,
15 normalizeRole,
16} from "./internal.ts";
17
18export type ObjectAccessControlProps = {
19 /**
20 * Name of the bucket that contains the object. Immutable — changing it
21 * replaces the entry.
22 */
23 bucketName: string;
24 /**
25 * Object name this ACL entry is attached to. Immutable — changing it
26 * replaces the entry.
27 */
28 object: string;
29 /**
30 * Principal granted the role. Forms include `user-{email}`,
31 * `group-{email}`, `domain-{domain}`, `allUsers`, and
32 * `allAuthenticatedUsers`. Immutable — changing it replaces the entry.
33 */
34 entity: string;
35 /**
36 * Access permission for the entity (`OWNER` or `READER`).
37 */
38 role: string;
39 /**
40 * Object generation. When omitted, the live generation is used.
41 * Immutable — changing it replaces the entry.
42 */
43 generation?: string;
44};
45
46export type ObjectAccessControl = Resource<
47 "GCP.Storage.ObjectAccessControl",
48 ObjectAccessControlProps,
49 {
50 /** Parent bucket name. */
51 bucketName: string;
52 /** Object name. */
53 object: string;
54 /** Principal granted the role. */
55 entity: string;
56 /** Access permission (`OWNER` or `READER`). */
57 role: string;
58 /** Object generation, if the entry is generation-scoped. */
59 generation: string | undefined;
60 /** Server-assigned ACL id. */
61 id: string | undefined;
62 /** Domain associated with the entity, if any. */
63 domain: string | undefined;
64 /** Email associated with the entity, if any. */
65 email: string | undefined;
66 /** Numeric entity id, if any. */
67 entityId: string | undefined;
68 /** HTTP etag. */
69 etag: string | undefined;
70 /** GCS self-link. */
71 selfLink: string | undefined;
72 },
73 never,
74 Providers
75>;
76
77/**
78 * A Cloud Storage object ACL entry.
79 *
80 * Fine-grained object ACLs are identity `(bucket, object, entity)`. Role
81 * is mutable; changing `bucketName`, `object`, or `entity` replaces the
82 * entry. Entries have no labels field, so `list` / `pnpm nuke:gcp`
83 * discover them by enumerating objects in alchemy-labeled buckets and
84 * skipping project-team and numeric-owner defaults.
85 *
86 * Uniform bucket-level access buckets do not support object ACLs.
87 *
88 * ### Creating an Object ACL
89 * **Example:** Grant a service account reader access
90 * ```typescript
91 * const bucket = yield* GCP.Storage.Bucket("assets", {
92 * forceDestroy: true,
93 * });
94 * const acl = yield* GCP.Storage.ObjectAccessControl("reader", {
95 * bucketName: bucket.bucketName,
96 * object: "hello.txt",
97 * entity: "user-app@project.iam.gserviceaccount.com",
98 * role: "READER",
99 * });
100 * ```
101 *
102 * **Example:** Update the role
103 * ```typescript
104 * const acl = yield* GCP.Storage.ObjectAccessControl("reader", {
105 * bucketName: bucket.bucketName,
106 * object: "hello.txt",
107 * entity: "user-app@project.iam.gserviceaccount.com",
108 * role: "OWNER",
109 * });
110 * ```
111 *
112 * @resource
113 * @category Storage
114 */
115export const ObjectAccessControl = Resource<ObjectAccessControl>(
116 "GCP.Storage.ObjectAccessControl",
117);
118
119export class ObjectAccessControlNotResolved extends Data.TaggedError(
120 "GCP.Storage.ObjectAccessControlNotResolved",
121)<{
122 bucketName: string;
123 object: string;
124 entity: string;
125}> {}
126
127const toAttrs = (
128 acl: storage.ObjectAccessControl,
129 bucketName: string,
130 object: string,
131) => ({
132 bucketName: acl.bucket ?? bucketName,
133 object: acl.object ?? object,
134 entity: acl.entity ?? "",
135 role: acl.role ?? "",
136 generation: acl.generation,
137 id: acl.id,
138 domain: acl.domain,
139 email: acl.email,
140 entityId: acl.entityId,
141 etag: acl.etag,
142 selfLink: acl.selfLink,
143});
144
145const getByEntity = (
146 bucketName: string,
147 object: string,
148 entity: string,
149 generation?: string,
150) =>
151 storage
152 .getObjectAccessControls({
153 bucket: bucketName,
154 object,
155 entity,
156 generation,
157 })
158 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
159
160const listOnObject = (bucketName: string, object: string) =>
161 storage.listObjectAccessControls({ bucket: bucketName, object }).pipe(
162 Effect.map((page) => page.items ?? []),
163 Effect.catchTag("NotFound", () =>
164 Effect.succeed([] as storage.ObjectAccessControl[]),
165 ),
166 );
167
168const listObjectsOnBucket = (bucketName: string) =>
169 storage.listObjects.items({ bucket: bucketName, maxResults: 1000 }).pipe(
170 Stream.runCollect,
171 Effect.map((chunk) => Array.from(chunk)),
172 Effect.catchTag("NotFound", () =>
173 Effect.succeed([] as storage.Storage_Object[]),
174 ),
175 );
176
177const waitUntilGone = (
178 bucketName: string,
179 object: string,
180 entity: string,
181 generation?: string,
182) =>
183 getByEntity(bucketName, object, entity, generation).pipe(
184 Effect.map((existing) =>
185 existing === undefined ? ("gone" as const) : ("found" as const),
186 ),
187 Effect.repeat({
188 schedule: Schedule.spaced("1 second"),
189 until: (status) => status === "gone",
190 times: 30,
191 }),
192 Effect.flatMap((status): Effect.Effect<void, DeleteNotConfirmed> =>
193 status === "gone"
194 ? Effect.void
195 : Effect.fail(
196 new DeleteNotConfirmed({
197 resource: `${bucketName}/o/${object}/acl/${entity}`,
198 }),
199 ),
200 ),
201 );
202
203export const ObjectAccessControlProvider = () =>
204 Provider.succeed(ObjectAccessControl, {
205 stables: ["bucketName", "object", "entity", "generation", "id", "selfLink"],
206
207 diff: Effect.fn(function* ({ news, olds, output }) {
208 if (!isResolved(news)) return undefined;
209 const previousBucket = olds?.bucketName ?? output?.bucketName;
210 const previousObject = olds?.object ?? output?.object;
211 const previousEntity = olds?.entity ?? output?.entity;
212 const previousGeneration = olds?.generation ?? output?.generation;
213 if (previousBucket !== undefined && news.bucketName !== previousBucket) {
214 return { action: "replace" as const, deleteFirst: false };
215 }
216 if (previousObject !== undefined && news.object !== previousObject) {
217 return { action: "replace" as const, deleteFirst: false };
218 }
219 if (
220 previousEntity !== undefined &&
221 normalizeEntity(news.entity) !== normalizeEntity(previousEntity)
222 ) {
223 return { action: "replace" as const, deleteFirst: false };
224 }
225 if (
226 news.generation !== undefined &&
227 previousGeneration !== undefined &&
228 news.generation !== previousGeneration
229 ) {
230 return { action: "replace" as const, deleteFirst: false };
231 }
232 return undefined;
233 }),
234
235 read: Effect.fn(function* ({ olds, output }) {
236 const bucketName = olds?.bucketName ?? output?.bucketName;
237 const object = olds?.object ?? output?.object;
238 const entity = olds?.entity ?? output?.entity;
239 if (!bucketName || !object || !entity) return undefined;
240 const generation = olds?.generation ?? output?.generation;
241 const existing = yield* getByEntity(
242 bucketName,
243 object,
244 entity,
245 generation,
246 );
247 if (existing === undefined) return undefined;
248 return toAttrs(existing, bucketName, object);
249 }),
250
251 list: () =>
252 Effect.gen(function* () {
253 const buckets = yield* listAlchemyBuckets();
254 const pages = yield* Effect.forEach(
255 buckets,
256 (bucket) => {
257 const bucketName = bucket.name;
258 // Legacy ACL APIs reject uniform bucket-level access buckets.
259 if (
260 !bucketName ||
261 bucket.iamConfiguration?.uniformBucketLevelAccess?.enabled ===
262 true
263 ) {
264 return Effect.succeed(
265 [] as Array<ObjectAccessControl["Attributes"]>,
266 );
267 }
268 return listObjectsOnBucket(bucketName).pipe(
269 Effect.flatMap((objects) =>
270 Effect.forEach(
271 objects,
272 (object) => {
273 const objectName = object.name;
274 if (!objectName) {
275 return Effect.succeed(
276 [] as Array<ObjectAccessControl["Attributes"]>,
277 );
278 }
279 return listOnObject(bucketName, objectName).pipe(
280 Effect.map((items) =>
281 items
282 .filter((item) => isUserManagedAclEntity(item.entity))
283 .map((item) => toAttrs(item, bucketName, objectName)),
284 ),
285 );
286 },
287 { concurrency: 8 },
288 ),
289 ),
290 Effect.map((nested) => nested.flat()),
291 );
292 },
293 { concurrency: 4 },
294 );
295 return pages.flat();
296 }),
297
298 reconcile: Effect.fn(function* ({ news, output }) {
299 const bucketName = news.bucketName;
300 const object = news.object;
301 const entity = normalizeEntity(news.entity);
302 const role = normalizeRole(news.role);
303 const generation = news.generation;
304
305 let current =
306 output?.entity !== undefined && output.object !== undefined
307 ? yield* getByEntity(
308 output.bucketName ?? bucketName,
309 output.object,
310 normalizeEntity(output.entity),
311 output.generation,
312 )
313 : undefined;
314 if (current === undefined) {
315 current = yield* getByEntity(bucketName, object, entity, generation);
316 }
317
318 if (current === undefined) {
319 const created = yield* storage
320 .insertObjectAccessControls({
321 bucket: bucketName,
322 object,
323 generation,
324 body: { entity, role },
325 })
326 .pipe(
327 Effect.catchTag("Conflict", () =>
328 getByEntity(bucketName, object, entity, generation),
329 ),
330 );
331 current = created ?? undefined;
332 }
333
334 if (current === undefined || !current.entity) {
335 return yield* new ObjectAccessControlNotResolved({
336 bucketName,
337 object,
338 entity,
339 });
340 }
341
342 if (normalizeRole(current.role ?? "") !== role) {
343 current = yield* storage.patchObjectAccessControls({
344 bucket: bucketName,
345 object,
346 entity,
347 generation,
348 body: { entity, role },
349 });
350 }
351
352 return toAttrs(current, bucketName, object);
353 }),
354
355 delete: Effect.fn(function* ({ output }) {
356 const bucketName = output.bucketName;
357 const object = output.object;
358 const entity = output.entity;
359 if (!bucketName || !object || !entity) return;
360 const removed = yield* storage
361 .deleteObjectAccessControls({
362 bucket: bucketName,
363 object,
364 entity,
365 generation: output.generation,
366 })
367 .pipe(
368 Effect.as(true),
369 Effect.catchTag("NotFound", () => Effect.succeed(true)),
370 // GCS refuses to drop the last OWNER ACL on an object.
371 Effect.catchTag("ObjectOwnerAclRequired", () =>
372 Effect.succeed(false),
373 ),
374 );
375 if (removed) {
376 yield* waitUntilGone(bucketName, object, entity, output.generation);
377 }
378 }),
379 });