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 DefaultObjectAccessControlProps = {
18 /**
19 * Name of the bucket whose default object ACL this entry belongs to.
20 * Immutable — changing it replaces the entry.
21 */
22 bucketName: string;
23 /**
24 * Principal granted the role on newly created objects. Forms include
25 * `user-{email}`, `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` or `READER`).
31 */
32 role: string;
33};
34
35export type DefaultObjectAccessControl = Resource<
36 "GCP.Storage.DefaultObjectAccessControl",
37 DefaultObjectAccessControlProps,
38 {
39 /** Parent bucket name. */
40 bucketName: string;
41 /** Principal granted the role. */
42 entity: string;
43 /** Access permission (`OWNER` 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 default object ACL entry on a bucket.
64 *
65 * Default object ACLs are copied onto objects created without an explicit
66 * ACL. Identity is `(bucket, entity)`. Role is mutable; changing
67 * `bucketName` or `entity` replaces the entry. Entries have no labels
68 * field, so `list` / `pnpm nuke:gcp` discover them by enumerating
69 * alchemy-labeled buckets and skipping project-team defaults.
70 *
71 * Uniform bucket-level access buckets do not support default object ACLs.
72 *
73 * ### Creating a Default Object ACL
74 * **Example:** Grant a service account reader access on new objects
75 * ```typescript
76 * const bucket = yield* GCP.Storage.Bucket("assets", {
77 * forceDestroy: true,
78 * });
79 * const acl = yield* GCP.Storage.DefaultObjectAccessControl("reader", {
80 * bucketName: bucket.bucketName,
81 * entity: "user-app@project.iam.gserviceaccount.com",
82 * role: "READER",
83 * });
84 * ```
85 *
86 * **Example:** Update the role
87 * ```typescript
88 * const acl = yield* GCP.Storage.DefaultObjectAccessControl("reader", {
89 * bucketName: bucket.bucketName,
90 * entity: "user-app@project.iam.gserviceaccount.com",
91 * role: "OWNER",
92 * });
93 * ```
94 *
95 * @resource
96 * @category Storage
97 */
98export const DefaultObjectAccessControl = Resource<DefaultObjectAccessControl>(
99 "GCP.Storage.DefaultObjectAccessControl",
100);
101
102export class DefaultObjectAccessControlNotResolved extends Data.TaggedError(
103 "GCP.Storage.DefaultObjectAccessControlNotResolved",
104)<{
105 bucketName: string;
106 entity: string;
107}> {}
108
109const toAttrs = (acl: storage.ObjectAccessControl, bucketName: string) => ({
110 bucketName: acl.bucket ?? bucketName,
111 entity: acl.entity ?? "",
112 role: acl.role ?? "",
113 id: acl.id,
114 domain: acl.domain,
115 email: acl.email,
116 entityId: acl.entityId,
117 etag: acl.etag,
118 selfLink: acl.selfLink,
119});
120
121const getByEntity = (bucketName: string, entity: string) =>
122 storage
123 .getDefaultObjectAccessControls({ bucket: bucketName, entity })
124 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
125
126const listOnBucket = (bucketName: string) =>
127 storage.listDefaultObjectAccessControls({ bucket: bucketName }).pipe(
128 Effect.map((page) => page.items ?? []),
129 Effect.catchTag("NotFound", () =>
130 Effect.succeed([] as storage.ObjectAccessControl[]),
131 ),
132 );
133
134const waitUntilGone = (bucketName: string, entity: string) =>
135 getByEntity(bucketName, entity).pipe(
136 Effect.map((existing) =>
137 existing === undefined ? ("gone" as const) : ("found" as const),
138 ),
139 Effect.repeat({
140 schedule: Schedule.spaced("1 second"),
141 until: (status) => status === "gone",
142 times: 30,
143 }),
144 Effect.flatMap((status): Effect.Effect<void, DeleteNotConfirmed> =>
145 status === "gone"
146 ? Effect.void
147 : Effect.fail(
148 new DeleteNotConfirmed({
149 resource: `${bucketName}/defaultObjectAcl/${entity}`,
150 }),
151 ),
152 ),
153 );
154
155export const DefaultObjectAccessControlProvider = () =>
156 Provider.succeed(DefaultObjectAccessControl, {
157 stables: ["bucketName", "entity", "id", "selfLink"],
158
159 diff: Effect.fn(function* ({ news, olds, output }) {
160 if (!isResolved(news)) return undefined;
161 const previousBucket = olds?.bucketName ?? output?.bucketName;
162 const previousEntity = olds?.entity ?? output?.entity;
163 if (previousBucket !== undefined && news.bucketName !== previousBucket) {
164 return { action: "replace" as const, deleteFirst: false };
165 }
166 if (
167 previousEntity !== undefined &&
168 normalizeEntity(news.entity) !== normalizeEntity(previousEntity)
169 ) {
170 return { action: "replace" as const, deleteFirst: false };
171 }
172 return undefined;
173 }),
174
175 read: Effect.fn(function* ({ olds, output }) {
176 const bucketName = olds?.bucketName ?? output?.bucketName;
177 const entity = olds?.entity ?? output?.entity;
178 if (!bucketName || !entity) return undefined;
179 const existing = yield* getByEntity(bucketName, entity);
180 if (existing === undefined) return undefined;
181 return toAttrs(existing, bucketName);
182 }),
183
184 list: () =>
185 Effect.gen(function* () {
186 const buckets = yield* listAlchemyBuckets();
187 const pages = yield* Effect.forEach(
188 buckets,
189 (bucket) => {
190 const bucketName = bucket.name;
191 // Legacy ACL APIs reject uniform bucket-level access buckets.
192 if (
193 !bucketName ||
194 bucket.iamConfiguration?.uniformBucketLevelAccess?.enabled ===
195 true
196 ) {
197 return Effect.succeed(
198 [] as Array<DefaultObjectAccessControl["Attributes"]>,
199 );
200 }
201 return listOnBucket(bucketName).pipe(
202 Effect.map((items) =>
203 items
204 .filter((item) => isUserManagedAclEntity(item.entity))
205 .map((item) => toAttrs(item, bucketName)),
206 ),
207 );
208 },
209 { concurrency: 8 },
210 );
211 return pages.flat();
212 }),
213
214 reconcile: Effect.fn(function* ({ news, output }) {
215 const bucketName = news.bucketName;
216 const entity = normalizeEntity(news.entity);
217 const role = normalizeRole(news.role);
218
219 let current =
220 output?.entity !== undefined
221 ? yield* getByEntity(
222 output.bucketName ?? bucketName,
223 normalizeEntity(output.entity),
224 )
225 : undefined;
226 if (current === undefined) {
227 current = yield* getByEntity(bucketName, entity);
228 }
229
230 if (current === undefined) {
231 const created = yield* storage
232 .insertDefaultObjectAccessControls({
233 bucket: bucketName,
234 body: { entity, role },
235 })
236 .pipe(
237 Effect.catchTag("Conflict", () => getByEntity(bucketName, entity)),
238 );
239 current = created ?? undefined;
240 }
241
242 if (current === undefined || !current.entity) {
243 return yield* new DefaultObjectAccessControlNotResolved({
244 bucketName,
245 entity,
246 });
247 }
248
249 if (normalizeRole(current.role ?? "") !== role) {
250 current = yield* storage.patchDefaultObjectAccessControls({
251 bucket: bucketName,
252 entity,
253 body: { entity, role },
254 });
255 }
256
257 return toAttrs(current, bucketName);
258 }),
259
260 delete: Effect.fn(function* ({ output }) {
261 const bucketName = output.bucketName;
262 const entity = output.entity;
263 if (!bucketName || !entity) return;
264 yield* storage
265 .deleteDefaultObjectAccessControls({ bucket: bucketName, entity })
266 .pipe(Effect.catchTag("NotFound", () => Effect.void));
267 yield* waitUntilGone(bucketName, entity);
268 }),
269 });