| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Role } from "@/AWS/IAM/Role.ts"; |
| 3 | import { |
| 4 | AwsLogSource, |
| 5 | CustomLogSource, |
| 6 | DataLake, |
| 7 | ExceptionSubscription, |
| 8 | Subscriber, |
| 9 | SubscriberNotification, |
| 10 | } from "@/AWS/SecurityLake"; |
| 11 | import * as Test from "@/Test/Alchemy"; |
| 12 | import * as securitylake from "@distilled.cloud/aws/securitylake"; |
| 13 | import { expect } from "alchemy-test"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Result from "effect/Result"; |
| 16 | import * as Schedule from "effect/Schedule"; |
| 17 | |
| 18 | const { 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 | |
| 26 | test.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 | |
| 46 | test.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 | |
| 62 | test.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 | |
| 88 | test.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 | |
| 114 | const 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 | |
| 125 | test.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 | ); |