1import * as AWS from "@/AWS";
2import { Role } from "@/AWS/IAM/Role.ts";
3import {
4 AwsLogSource,
5 CustomLogSource,
6 DataLake,
7 ExceptionSubscription,
8 Subscriber,
9 SubscriberNotification,
10} from "@/AWS/SecurityLake";
11import * as Test from "@/Test/Alchemy";
12import * as securitylake from "@distilled.cloud/aws/securitylake";
13import { expect } from "alchemy-test";
14import * as Effect from "effect/Effect";
15import * as Result from "effect/Result";
16import * as Schedule from "effect/Schedule";
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20// ---------------------------------------------------------------------------
21// Ungated typed-error probes: prove the distilled error union carries the tags
22// the providers' read/delete paths depend on, at near-zero cost. Security Lake
23// lifecycle itself is account-wide and heavy, so it is gated below.
24// ---------------------------------------------------------------------------
25
26test.provider(
27 "getSubscriber on a nonexistent subscriber fails with a typed tag",
28 () =>
29 Effect.gen(function* () {
30 const error = yield* Effect.flip(
31 securitylake.getSubscriber({
32 // valid UUID shape, never allocated
33 subscriberId: "00000000-0000-4000-8000-000000000000",
34 }),
35 );
36 // Onboarded accounts return ResourceNotFoundException; accounts that
37 // never enabled Security Lake reject every subscriber API with the
38 // (patched-in) UnauthorizedException wire error.
39 expect(["ResourceNotFoundException", "UnauthorizedException"]).toContain(
40 error._tag,
41 );
42 }),
43 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
44);
45
46test.provider(
47 "listDataLakes returns data lakes or a typed not-onboarded rejection",
48 () =>
49 Effect.gen(function* () {
50 const result = yield* Effect.result(securitylake.listDataLakes({}));
51 if (Result.isSuccess(result)) {
52 expect(Array.isArray(result.success.dataLakes ?? [])).toBe(true);
53 } else {
54 // Accounts that never onboarded Security Lake reject listDataLakes
55 // with AccessDeniedException.
56 expect(result.failure._tag).toBe("AccessDeniedException");
57 }
58 }),
59 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
60);
61
62test.provider(
63 "getDataLakeExceptionSubscription returns the subscription or a typed rejection",
64 () =>
65 Effect.gen(function* () {
66 const result = yield* Effect.result(
67 securitylake.getDataLakeExceptionSubscription({}),
68 );
69 if (Result.isSuccess(result)) {
70 // No subscription configured — every field is absent.
71 expect(typeof (result.success.notificationEndpoint ?? "")).toBe(
72 "string",
73 );
74 } else {
75 // Accounts that never onboarded Security Lake (or have no
76 // subscription) reject with one of the typed tags the
77 // ExceptionSubscription provider's read/delete paths depend on.
78 expect([
79 "AccessDeniedException",
80 "ResourceNotFoundException",
81 "UnauthorizedException",
82 ]).toContain(result.failure._tag);
83 }
84 }),
85 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
86);
87
88test.provider(
89 "deleteSubscriberNotification on a nonexistent subscriber fails with a typed tag",
90 () =>
91 Effect.gen(function* () {
92 const error = yield* Effect.flip(
93 securitylake.deleteSubscriberNotification({
94 subscriberId: "00000000-0000-4000-8000-000000000000",
95 }),
96 );
97 expect([
98 "AccessDeniedException",
99 "ResourceNotFoundException",
100 "UnauthorizedException",
101 ]).toContain(error._tag);
102 }),
103 { tags: ["provider:aws", "provider:aws:securitylake", "live"] },
104);
105
106// ---------------------------------------------------------------------------
107// Gated live lifecycle. Enabling Security Lake onboards the whole account in
108// the Region: it creates S3 buckets, registers them with Lake Formation, and
109// configures the Glue metastore. The buckets are retained after delete (AWS
110// behavior). Run with AWS_TEST_SECURITYLAKE=1 on an account you are willing to
111// onboard/offboard.
112// ---------------------------------------------------------------------------
113
114const assertDataLakeGone = securitylake.listDataLakes({}).pipe(
115 Effect.flatMap((response) =>
116 (response.dataLakes ?? []).length === 0
117 ? Effect.void
118 : Effect.fail(new Error("data lake still present")),
119 ),
120 Effect.retry({
121 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(12)]),
122 }),
123);
124
125test.provider.skipIf(!process.env.AWS_TEST_SECURITYLAKE)(
126 "lifecycle: enable data lake, add ROUTE53 source + subscriber, update, destroy",
127 (stack) =>
128 Effect.gen(function* () {
129 const { accountId, region } = yield* AWS.AWSEnvironment.current;
130
131 // Never stomp an existing Security Lake deployment.
132 const preexisting = yield* securitylake.listDataLakes({});
133 if ((preexisting.dataLakes ?? []).length > 0) {
134 yield* Effect.logInfo(
135 "Security Lake already enabled in this account — skipping destructive lifecycle test",
136 );
137 return;
138 }
139
140 yield* stack.destroy();
141
142 const deployOnce = (subscriberDescription: string, withExtras: boolean) =>
143 stack.deploy(
144 Effect.gen(function* () {
145 const metastoreRole = yield* Role("MetastoreRole", {
146 assumeRolePolicyDocument: {
147 Version: "2012-10-17",
148 Statement: [
149 {
150 Effect: "Allow",
151 Principal: { Service: "lambda.amazonaws.com" },
152 Action: ["sts:AssumeRole"],
153 },
154 ],
155 },
156 managedPolicyArns: [
157 "arn:aws:iam::aws:policy/service-role/AmazonSecurityLakeMetastoreManager",
158 ],
159 });
160 const lake = yield* DataLake("Lake", {
161 configurations: [
162 {
163 region,
164 lifecycleConfiguration: { expiration: { days: "30 days" } },
165 },
166 ],
167 metaStoreManagerRoleArn: metastoreRole.roleArn,
168 tags: { fixture: "securitylake" },
169 });
170 const source = yield* AwsLogSource("Route53Source", {
171 sourceName: "ROUTE53",
172 regions: lake.regions,
173 });
174 const subscriber = yield* Subscriber("Consumer", {
175 subscriberIdentity: {
176 principal: accountId,
177 externalId: "alchemy-securitylake-test",
178 },
179 subscriberDescription,
180 // Reference the log source's output so destroy ordering tears
181 // the subscriber down before the source and data lake.
182 sources: [{ awsLogSource: { sourceName: source.sourceName } }],
183 accessTypes: ["S3"],
184 tags: { fixture: "securitylake" },
185 });
186 if (!withExtras) {
187 return { lake, source, subscriber, extras: undefined };
188 }
189
190 // Second wave (the data lake already exists by now): the
191 // exception subscription singleton, an SQS subscriber
192 // notification, and a custom log source with its crawler role.
193 const exceptions = yield* ExceptionSubscription("Exceptions", {
194 subscriptionProtocol: "email",
195 notificationEndpoint: "securitylake-test@example.com",
196 exceptionTimeToLive: "30 days",
197 });
198 const crawlerRole = yield* Role("CrawlerRole", {
199 assumeRolePolicyDocument: {
200 Version: "2012-10-17",
201 Statement: [
202 {
203 Effect: "Allow",
204 Principal: { Service: "glue.amazonaws.com" },
205 Action: ["sts:AssumeRole"],
206 },
207 ],
208 },
209 managedPolicyArns: [
210 "arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole",
211 ],
212 });
213 const custom = yield* CustomLogSource("CustomSource", {
214 sourceName: "alchemy-securitylake-test-custom",
215 eventClasses: ["FILE_ACTIVITY"],
216 crawlerConfiguration: { roleArn: crawlerRole.roleArn },
217 providerIdentity: {
218 principal: accountId,
219 externalId: "alchemy-securitylake-custom",
220 },
221 });
222 const notification = yield* SubscriberNotification("Notify", {
223 subscriberId: subscriber.subscriberId,
224 sqs: true,
225 });
226 return {
227 lake,
228 source,
229 subscriber,
230 extras: { exceptions, custom, notification },
231 };
232 }),
233 );
234
235 // Create.
236 const { lake, source, subscriber } = yield* deployOnce("v1", false);
237 expect(lake.dataLakeArn).toContain(":securitylake:");
238 expect(lake.regions).toContain(region);
239 expect(source.sourceName).toBe("ROUTE53");
240 expect(source.regions).toContain(region);
241 expect(subscriber.subscriberId).toBeDefined();
242 expect(subscriber.s3BucketArn).toBeDefined();
243
244 // Out-of-band verification via distilled.
245 const lakes = yield* securitylake.listDataLakes({});
246 expect(lakes.dataLakes?.some((l) => l.region === region)).toBe(true);
247 const observedSubscriber = yield* securitylake.getSubscriber({
248 subscriberId: subscriber.subscriberId,
249 });
250 expect(observedSubscriber.subscriber?.subscriberArn).toBe(
251 subscriber.subscriberArn,
252 );
253
254 // Update in place (subscriber description) + add the second wave of
255 // resources (exception subscription, custom source, notification).
256 const second = yield* deployOnce("v2", true);
257 expect(second.subscriber.subscriberId).toBe(subscriber.subscriberId);
258 const updated = yield* securitylake.getSubscriber({
259 subscriberId: subscriber.subscriberId,
260 });
261 expect(updated.subscriber?.subscriberDescription).toBe("v2");
262
263 const extras = second.extras!;
264 expect(extras.exceptions.subscriptionProtocol).toBe("email");
265 expect(extras.exceptions.exceptionTimeToLive).toBe(30);
266 expect(extras.custom.sourceName).toBe("alchemy-securitylake-test-custom");
267 expect(extras.notification.subscriberEndpoint).toBeDefined();
268
269 // Out-of-band verification via distilled.
270 const exceptionSubscription =
271 yield* securitylake.getDataLakeExceptionSubscription({});
272 expect(exceptionSubscription.notificationEndpoint).toBe(
273 "securitylake-test@example.com",
274 );
275 const logSources = yield* securitylake.listLogSources({});
276 expect(
277 (logSources.sources ?? []).some((entry) =>
278 (entry.sources ?? []).some(
279 (source) =>
280 source.customLogSource?.sourceName ===
281 "alchemy-securitylake-test-custom",
282 ),
283 ),
284 ).toBe(true);
285 const notified = yield* securitylake.getSubscriber({
286 subscriberId: subscriber.subscriberId,
287 });
288 expect(notified.subscriber?.subscriberEndpoint).toBeDefined();
289
290 // Destroy — offboards the account; buckets are retained by AWS design.
291 yield* stack.destroy();
292 const goneSubscriber = yield* Effect.flip(
293 securitylake.getSubscriber({
294 subscriberId: subscriber.subscriberId,
295 }),
296 );
297 expect(goneSubscriber._tag).toBe("ResourceNotFoundException");
298 yield* assertDataLakeGone;
299 }),
300 {
301 tags: [
302 "provider:aws",
303 "provider:aws:iam",
304 "provider:aws:securitylake",
305 "live",
306 ],
307 timeout: 1_200_000,
308 },
309);