1import * as firebaseappcheck from "@distilled.cloud/gcp/firebaseappcheck_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 createOwnership,
11 DEFAULT_SERVICE_ID,
12 dummyTargetResource,
13 findOwnedResourcePolicy,
14 getResourcePolicy,
15 hasDummyAlchemyTarget,
16 listOwnedResourcePolicies,
17 listResourcePolicies,
18 normalizeEnforcement,
19 parseResourcePolicyName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 retryTransient,
23 sameText,
24 serviceParent,
25} from "./internal.ts";
26
27export type ServicesResourcePolicyProps = {
28 /**
29 * App Check service id. Currently only `oauth2.googleapis.com`
30 * (Google Identity for iOS) supports resource policies. Immutable —
31 * changing it replaces the policy.
32 * @default "oauth2.googleapis.com"
33 */
34 serviceId?: string;
35 /**
36 * Service-specific resource this policy applies to. For iOS OAuth
37 * clients: `//oauth2.googleapis.com/projects/{project}/oauthClients/{clientId}`.
38 * The target may be missing at create time. Resource policies have no
39 * labels or description field, so when this is omitted Alchemy targets
40 * a dummy `alc-{alchemy-id}` OAuth client so `list` / nuke can find
41 * the policy. Immutable — changing it replaces the policy.
42 */
43 targetResource?: string;
44 /**
45 * App Check enforcement mode. Overrides the service-level setting for
46 * this resource.
47 * @default "UNENFORCED"
48 */
49 enforcementMode?:
50 | firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicyEnforcementModeEnum
51 | (string & {});
52};
53
54export type ServicesResourcePolicy = Resource<
55 "GCP.FirebaseAppCheck.ServicesResourcePolicy",
56 ServicesResourcePolicyProps,
57 {
58 /** Full resource name. */
59 name: string;
60 /** Server-assigned resource policy id. */
61 resourcePolicyId: string;
62 /** Parent service resource. */
63 parent: string;
64 /** Service id (`oauth2.googleapis.com`). */
65 serviceId: string;
66 /** Project id. */
67 project: string;
68 /** Target resource this policy applies to. */
69 targetResource: string;
70 /** Enforcement mode. */
71 enforcementMode: string | undefined;
72 /** Server-assigned checksum for optimistic concurrency. */
73 etag: string | undefined;
74 /** RFC3339 last-update timestamp. */
75 updateTime: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * An App Check enforcement policy for a single resource of a Google
83 * service that App Check supports. Currently only Google Identity for
84 * iOS (`oauth2.googleapis.com`) accepts resource policies. The policy
85 * overrides the service-level enforcement mode.
86 *
87 * Resource policies have no labels field. When `targetResource` is
88 * omitted, Alchemy points the policy at a dummy OAuth client whose id
89 * starts with `alc-` so `list` / nuke can identify it. `serviceId` and
90 * `targetResource` are identity — changing either replaces the policy.
91 * Enforcement mode updates in place.
92 *
93 * ### Creating a Resource Policy
94 * **Example:** Dummy target for tests
95 * ```typescript
96 * const policy = yield* GCP.FirebaseAppCheck.ServicesResourcePolicy(
97 * "IosOauth",
98 * { enforcementMode: "UNENFORCED" },
99 * );
100 * ```
101 *
102 * **Example:** Real iOS OAuth client
103 * ```typescript
104 * const policy = yield* GCP.FirebaseAppCheck.ServicesResourcePolicy(
105 * "IosOauth",
106 * {
107 * targetResource:
108 * "//oauth2.googleapis.com/projects/123/oauthClients/abc.apps.googleusercontent.com",
109 * enforcementMode: "ENFORCED",
110 * },
111 * );
112 * ```
113 *
114 * ### Updating a Resource Policy
115 * **Example:** Switch to monitoring-only
116 * ```typescript
117 * const policy = yield* GCP.FirebaseAppCheck.ServicesResourcePolicy(
118 * "IosOauth",
119 * {
120 * enforcementMode: "UNENFORCED",
121 * },
122 * );
123 * ```
124 *
125 * @resource
126 * @category FirebaseAppCheck
127 */
128export const ServicesResourcePolicy = Resource<ServicesResourcePolicy>(
129 "GCP.FirebaseAppCheck.ServicesResourcePolicy",
130);
131
132const getByName = getResourcePolicy;
133
134const toAttrs = (
135 policy: firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicy,
136 project: string,
137): ServicesResourcePolicy["Attributes"] => {
138 const name = policy.name ?? "";
139 const parsed = parseResourcePolicyName(name);
140 return {
141 name,
142 resourcePolicyId: parsed.resourcePolicyId,
143 parent: parsed.parent,
144 serviceId: parsed.serviceId,
145 project: parsed.project || project,
146 targetResource: policy.targetResource ?? "",
147 enforcementMode: policy.enforcementMode,
148 etag: policy.etag,
149 updateTime: policy.updateTime,
150 };
151};
152
153export const ServicesResourcePolicyProvider = () =>
154 Provider.succeed(ServicesResourcePolicy, {
155 stables: [
156 "name",
157 "resourcePolicyId",
158 "parent",
159 "serviceId",
160 "project",
161 "targetResource",
162 ],
163
164 diff: Effect.fn(function* ({ news, olds, output }) {
165 if (!isResolved(news)) return undefined;
166 const previousService =
167 olds?.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE_ID;
168 const nextService = news.serviceId ?? previousService;
169 const previousTarget = olds?.targetResource ?? output?.targetResource;
170 return replaceOnIdentity({
171 previous: previousService,
172 next: nextService,
173 extra:
174 news.targetResource !== undefined &&
175 previousTarget !== undefined &&
176 news.targetResource !== previousTarget,
177 deleteFirst: true,
178 });
179 }),
180
181 read: Effect.fn(function* ({ id, olds, output }) {
182 const env = yield* GcpEnvironment.current;
183 const ownership = yield* createOwnership(id);
184 let existing = yield* getByName(output?.name ?? "");
185 if (existing === undefined) {
186 const serviceId =
187 olds?.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE_ID;
188 existing = findOwnedResourcePolicy(
189 yield* listResourcePolicies(env.project, serviceId),
190 ownership,
191 output?.name,
192 );
193 }
194 if (existing === undefined) return undefined;
195 const attrs = toAttrs(existing, env.project);
196 const owned =
197 output !== undefined || hasDummyAlchemyTarget(existing.targetResource);
198 return owned ? attrs : Unowned(attrs);
199 }),
200
201 list: () =>
202 Effect.gen(function* () {
203 const env = yield* GcpEnvironment.current;
204 const policies = yield* listOwnedResourcePolicies(env.project);
205 return policies.map((policy) => toAttrs(policy, env.project));
206 }),
207
208 reconcile: Effect.fn(function* ({ id, news, output }) {
209 const env = yield* GcpEnvironment.current;
210 const serviceId =
211 news.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE_ID;
212 const parent = serviceParent(env.project, serviceId);
213 const ownership = yield* createOwnership(id);
214 const targetResource =
215 news.targetResource ??
216 output?.targetResource ??
217 dummyTargetResource(env.project, ownership);
218 const enforcementMode = normalizeEnforcement(news.enforcementMode);
219
220 let current = yield* getByName(output?.name ?? "");
221 if (current === undefined) {
222 current = findOwnedResourcePolicy(
223 yield* listResourcePolicies(env.project, serviceId),
224 ownership,
225 output?.name,
226 );
227 }
228
229 if (current === undefined) {
230 const created = yield* retryTransient(
231 firebaseappcheck.createProjectsServicesResourcePolicies({
232 parent,
233 body: {
234 targetResource,
235 enforcementMode:
236 enforcementMode as firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicyEnforcementModeEnum,
237 },
238 }),
239 ).pipe(
240 Effect.catchTag("Conflict", () =>
241 listResourcePolicies(env.project, serviceId).pipe(
242 Effect.map((policies) =>
243 findOwnedResourcePolicy(policies, ownership, output?.name),
244 ),
245 ),
246 ),
247 );
248 current = created ?? undefined;
249 }
250
251 if (current === undefined) {
252 return yield* new ResourceNotResolved({
253 name: output?.name ?? `${parent}/resourcePolicies`,
254 });
255 }
256
257 if (!sameText(current.enforcementMode, enforcementMode)) {
258 current = yield* retryTransient(
259 firebaseappcheck.patchProjectsServicesResourcePolicies({
260 name: current.name ?? "",
261 updateMask: "enforcement_mode",
262 body: {
263 enforcementMode:
264 enforcementMode as firebaseappcheck.GoogleFirebaseAppcheckV1ResourcePolicyEnforcementModeEnum,
265 etag: current.etag,
266 },
267 }),
268 );
269 }
270
271 return toAttrs(current, env.project);
272 }),
273
274 delete: Effect.fn(function* ({ output }) {
275 if (!output.name) return;
276 yield* retryTransient(
277 firebaseappcheck.deleteProjectsServicesResourcePolicies({
278 name: output.name,
279 etag: output.etag,
280 }),
281 ).pipe(
282 Effect.catchTag("Conflict", () =>
283 firebaseappcheck.deleteProjectsServicesResourcePolicies({
284 name: output.name,
285 }),
286 ),
287 Effect.catchTag("NotFound", () => Effect.void),
288 );
289 }),
290 });