1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as bigquery from "@distilled.cloud/gcp/bigquery_v2";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8
9/**
10 * Grants on services without a per-resource `setIamPolicy`: BigQuery
11 * datasets (the dataset access list) and Firestore databases (a project
12 * grant under an IAM Condition naming the database). Image-only hosts, so
13 * no container build is needed.
14 */
15
16const { test } = Test.make({ providers: GCP.providers() });
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23const HELLO_IMAGE = "us-docker.pkg.dev/cloudrun/container/hello";
24
25const Warehouse = GCP.BigQuery.Dataset("ScopedWarehouse", {
26 location: "US-CENTRAL1",
27 forceDestroy: true,
28});
29
30const Docs = GCP.Firestore.Database("ScopedDocs", {
31 location: "us-central1",
32 type: "FIRESTORE_NATIVE",
33});
34
35class ScopedService extends GCP.Function<ScopedService>()(
36 "ScopedService",
37 {
38 location: "us-central1",
39 template: { containers: [{ image: HELLO_IMAGE }] },
40 },
41 Effect.gen(function* () {
42 yield* GCP.BigQuery.Query(Warehouse);
43 yield* GCP.Firestore.ReadWriteDatabase(Docs);
44 return {};
45 }).pipe(
46 Effect.provide(GCP.BigQuery.QueryHttp),
47 Effect.provide(GCP.Firestore.ReadWriteDatabaseHttp),
48 ),
49) {}
50
51const rolesOf = (
52 bindings: ReadonlyArray<{
53 role?: string;
54 members?: ReadonlyArray<string>;
55 condition?: unknown;
56 }>,
57 member: string,
58) =>
59 bindings
60 .filter((binding) => (binding.members ?? []).includes(member))
61 .map((binding) => ({
62 role: binding.role,
63 condition: (binding.condition as { expression?: string } | undefined)
64 ?.expression,
65 }));
66
67test.provider(
68 "dataset grants use the access list; Firestore grants are conditioned on the database",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 const out = yield* stack.deploy(
74 Effect.gen(function* () {
75 const service = yield* ScopedService;
76 const dataset = yield* Warehouse;
77 const docs = yield* Docs;
78 return {
79 database: docs.name,
80 project: service.project,
81 serviceAccount: service.serviceAccount,
82 datasetId: dataset.datasetId,
83 };
84 }),
85 );
86 const email = out.serviceAccount!;
87
88 const projectPolicy = yield* resourcemanager.getIamPolicyProjects({
89 resource: `projects/${out.project}`,
90 body: { options: { requestedPolicyVersion: 3 } },
91 });
92 // bigquery.jobs.create is project-only; data access is not.
93 expect(
94 rolesOf(projectPolicy.bindings ?? [], `serviceAccount:${email}`),
95 ).toEqual(
96 expect.arrayContaining([
97 { role: "roles/bigquery.jobUser", condition: undefined },
98 // Firestore has no per-database policy: a project grant scoped
99 // to this database by an IAM Condition.
100 {
101 role: "roles/datastore.user",
102 condition: `resource.name == "${out.database}" || resource.name.startsWith("${out.database}/")`,
103 },
104 ]),
105 );
106 expect(
107 rolesOf(projectPolicy.bindings ?? [], `serviceAccount:${email}`),
108 ).toHaveLength(2);
109
110 const dataset = yield* bigquery.getDatasets({
111 projectId: out.project,
112 datasetId: out.datasetId,
113 });
114 expect(
115 (dataset.access ?? []).filter((item) => item.userByEmail === email),
116 // BigQuery stores roles/bigquery.dataViewer as the legacy READER.
117 ).toEqual([{ role: "READER", userByEmail: email }]);
118
119 yield* stack.destroy();
120 }).pipe(logLevel),
121 { tags: ["provider:gcp", "provider:gcp:run", "live"], timeout: 240_000 },
122);