1import * as Grafana from "@/AWS/Grafana";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "workspace-handler.ts");
12
13export class GrafanaWorkspaceTestFunction extends Lambda.Function<Lambda.Function>()(
14 "GrafanaWorkspaceTestFunction",
15) {}
16
17/** Unwrap a possibly-redacted secret returned by the distilled client. */
18const unwrap = (value: string | Redacted.Redacted<string>): string =>
19 typeof value === "string" ? value : Redacted.value(value);
20
21const errorTagged = <A, E extends { _tag: string }, R>(
22 effect: Effect.Effect<A, E, R>,
23): Effect.Effect<A | { errorTag: string }, never, R> =>
24 effect.pipe(
25 Effect.map((a): A | { errorTag: string } => a),
26 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
27 );
28
29/**
30 * GATED fixture (AWS_TEST_GRAFANA=1): creates a SAML-authenticated Grafana
31 * workspace (no IAM Identity Center requirement) and binds every
32 * workspace-scoped capability. License and mutation-heavy capabilities
33 * (AssociateLicense enables billed Enterprise; UpdateWorkspaceAuthentication
34 * and UpdateWorkspaceConfiguration mutate long-lived workspace state;
35 * UpdatePermissions requires SSO users) are bound — proving init + IAM
36 * deploy — but not exercised against live state.
37 */
38export default GrafanaWorkspaceTestFunction.make(
39 {
40 main,
41 functionUrl: true,
42 timeout: Duration.seconds(60),
43 },
44 Effect.gen(function* () {
45 const workspace = yield* Grafana.Workspace("BindingsWorkspace", {
46 description: "grafana bindings fixture",
47 accountAccessType: "CURRENT_ACCOUNT",
48 authenticationProviders: ["SAML"],
49 permissionType: "SERVICE_MANAGED",
50 dataSources: ["CLOUDWATCH"],
51 tags: { fixture: "grafana-bindings" },
52 });
53
54 const describeAuth =
55 yield* Grafana.DescribeWorkspaceAuthentication(workspace);
56 const updateAuth = yield* Grafana.UpdateWorkspaceAuthentication(workspace);
57 const describeConfig =
58 yield* Grafana.DescribeWorkspaceConfiguration(workspace);
59 const updateConfig = yield* Grafana.UpdateWorkspaceConfiguration(workspace);
60 const associateLicense = yield* Grafana.AssociateLicense(workspace);
61 const disassociateLicense = yield* Grafana.DisassociateLicense(workspace);
62 const listPermissions = yield* Grafana.ListPermissions(workspace);
63 const updatePermissions = yield* Grafana.UpdatePermissions(workspace);
64 const createServiceAccount =
65 yield* Grafana.CreateWorkspaceServiceAccount(workspace);
66 const deleteServiceAccount =
67 yield* Grafana.DeleteWorkspaceServiceAccount(workspace);
68 const listServiceAccounts =
69 yield* Grafana.ListWorkspaceServiceAccounts(workspace);
70 const createToken =
71 yield* Grafana.CreateWorkspaceServiceAccountToken(workspace);
72 const deleteToken =
73 yield* Grafana.DeleteWorkspaceServiceAccountToken(workspace);
74 const listTokens =
75 yield* Grafana.ListWorkspaceServiceAccountTokens(workspace);
76 const listVersions = yield* Grafana.ListVersions();
77
78 const bound = {
79 describeAuth,
80 updateAuth,
81 describeConfig,
82 updateConfig,
83 associateLicense,
84 disassociateLicense,
85 listPermissions,
86 updatePermissions,
87 createServiceAccount,
88 deleteServiceAccount,
89 listServiceAccounts,
90 createToken,
91 deleteToken,
92 listTokens,
93 listVersions,
94 };
95
96 return {
97 fetch: Effect.gen(function* () {
98 const request = yield* HttpServerRequest;
99 const url = new URL(request.originalUrl);
100 const pathname = url.pathname;
101
102 if (request.method === "GET" && pathname === "/bindings") {
103 return yield* HttpServerResponse.json({
104 bound: Object.keys(bound),
105 });
106 }
107
108 // Injected workspace id: the auth description round-trips.
109 if (request.method === "GET" && pathname === "/auth") {
110 const { authentication } = yield* describeAuth();
111 return yield* HttpServerResponse.json({
112 providers: authentication.providers,
113 samlStatus: authentication.saml?.status,
114 });
115 }
116
117 if (request.method === "GET" && pathname === "/config") {
118 const { configuration, grafanaVersion } = yield* describeConfig();
119 return yield* HttpServerResponse.json({
120 hasConfiguration: configuration.length > 0,
121 grafanaVersion,
122 });
123 }
124
125 // A SAML-only workspace may reject SSO-permission listing with a
126 // typed error — either outcome proves the binding + IAM wiring.
127 if (request.method === "GET" && pathname === "/permissions") {
128 const result = yield* errorTagged(
129 listPermissions().pipe(
130 Effect.map((r) => ({ count: r.permissions.length })),
131 ),
132 );
133 return yield* HttpServerResponse.json(result);
134 }
135
136 if (request.method === "GET" && pathname === "/service-accounts") {
137 const { serviceAccounts } = yield* listServiceAccounts();
138 return yield* HttpServerResponse.json({
139 count: serviceAccounts.length,
140 });
141 }
142
143 // Upgradable versions for the bound workspace (account-level cap).
144 if (request.method === "GET" && pathname === "/versions") {
145 const { grafanaVersions } = yield* listVersions();
146 return yield* HttpServerResponse.json({
147 count: (grafanaVersions ?? []).length,
148 });
149 }
150
151 // Full service-account + token lifecycle: create → mint (Duration
152 // timeToLive → wire secondsToLive; Redacted key) → list → revoke →
153 // delete. Exercises five bindings in one deterministic round-trip.
154 if (
155 request.method === "POST" &&
156 pathname === "/service-account-roundtrip"
157 ) {
158 const account = yield* createServiceAccount({
159 name: "bindings-roundtrip",
160 grafanaRole: "EDITOR",
161 });
162
163 const minted = yield* createToken({
164 name: "bindings-roundtrip-token",
165 serviceAccountId: account.id,
166 timeToLive: "5 minutes",
167 });
168 const key = unwrap(minted.serviceAccountToken.key);
169
170 const { serviceAccountTokens } = yield* listTokens({
171 serviceAccountId: account.id,
172 });
173
174 yield* deleteToken({
175 serviceAccountId: account.id,
176 tokenId: minted.serviceAccountToken.id,
177 });
178 yield* deleteServiceAccount({ serviceAccountId: account.id });
179
180 return yield* HttpServerResponse.json({
181 serviceAccountId: account.id,
182 grafanaRole: account.grafanaRole,
183 keyPrefix: key.slice(0, 5),
184 keyLength: key.length,
185 tokenCount: serviceAccountTokens.length,
186 });
187 }
188
189 return yield* HttpServerResponse.json(
190 { error: "Not found", method: request.method, pathname },
191 { status: 404 },
192 );
193 }).pipe(Effect.orDie),
194 };
195 }).pipe(
196 Effect.provide(
197 Layer.mergeAll(
198 Grafana.AssociateLicenseHttp,
199 Grafana.CreateWorkspaceServiceAccountHttp,
200 Grafana.CreateWorkspaceServiceAccountTokenHttp,
201 Grafana.DeleteWorkspaceServiceAccountHttp,
202 Grafana.DeleteWorkspaceServiceAccountTokenHttp,
203 Grafana.DescribeWorkspaceAuthenticationHttp,
204 Grafana.DescribeWorkspaceConfigurationHttp,
205 Grafana.DisassociateLicenseHttp,
206 Grafana.ListPermissionsHttp,
207 Grafana.ListVersionsHttp,
208 Grafana.ListWorkspaceServiceAccountsHttp,
209 Grafana.ListWorkspaceServiceAccountTokensHttp,
210 Grafana.UpdatePermissionsHttp,
211 Grafana.UpdateWorkspaceAuthenticationHttp,
212 Grafana.UpdateWorkspaceConfigurationHttp,
213 ),
214 ),
215 ),
216);