| 1 | import * as securitylake from "@distilled.cloud/aws/securitylake"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { retryWhileConflict } from "./internal.ts"; |
| 11 | |
| 12 | /** |
| 13 | * The Glue crawler configuration Security Lake uses to catalog the custom |
| 14 | * source's OCSF data. |
| 15 | */ |
| 16 | export interface CustomLogSourceCrawlerConfiguration { |
| 17 | /** |
| 18 | * ARN of the IAM role the Glue crawler assumes. The role must be assumable |
| 19 | * by `glue.amazonaws.com` and grant access to the Security Lake bucket |
| 20 | * prefix for this source. |
| 21 | */ |
| 22 | roleArn: string; |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * The AWS identity (principal + external ID) the custom-source provider uses |
| 27 | * to write data into the data lake. |
| 28 | */ |
| 29 | export interface CustomLogSourceProviderIdentity { |
| 30 | /** The AWS account ID (or principal) of the log provider. */ |
| 31 | principal: string; |
| 32 | /** The external ID the provider must present when assuming the role. */ |
| 33 | externalId: string; |
| 34 | } |
| 35 | |
| 36 | export interface CustomLogSourceProps { |
| 37 | /** |
| 38 | * Name of the custom log source. Must be unique per account/Region. |
| 39 | * If omitted, a unique physical name is generated from the app, stage, |
| 40 | * and logical ID. Changing this replaces the source. |
| 41 | */ |
| 42 | sourceName?: string; |
| 43 | |
| 44 | /** |
| 45 | * The version of the custom source schema. Changing this replaces the |
| 46 | * source. |
| 47 | * @default - Security Lake assigns a version |
| 48 | */ |
| 49 | sourceVersion?: string; |
| 50 | |
| 51 | /** |
| 52 | * The Open Cybersecurity Schema Framework (OCSF) event classes the source |
| 53 | * emits (e.g. `FILE_ACTIVITY`, `DNS_ACTIVITY`). Changing this replaces the |
| 54 | * source. |
| 55 | */ |
| 56 | eventClasses?: string[]; |
| 57 | |
| 58 | /** |
| 59 | * The Glue crawler configuration for cataloging the source's data. |
| 60 | * Changing this replaces the source. |
| 61 | */ |
| 62 | crawlerConfiguration: CustomLogSourceCrawlerConfiguration; |
| 63 | |
| 64 | /** |
| 65 | * The AWS identity of the provider that writes this source's data. |
| 66 | * Changing this replaces the source. |
| 67 | */ |
| 68 | providerIdentity: CustomLogSourceProviderIdentity; |
| 69 | } |
| 70 | |
| 71 | /** @resource */ |
| 72 | export interface CustomLogSource extends Resource< |
| 73 | "AWS.SecurityLake.CustomLogSource", |
| 74 | CustomLogSourceProps, |
| 75 | { |
| 76 | /** Name of the custom log source. */ |
| 77 | sourceName: string; |
| 78 | /** The resolved source schema version. */ |
| 79 | sourceVersion: string | undefined; |
| 80 | /** ARN of the Glue crawler created for the source. */ |
| 81 | crawlerArn: string | undefined; |
| 82 | /** ARN of the Glue database created for the source. */ |
| 83 | databaseArn: string | undefined; |
| 84 | /** ARN of the Glue table created for the source. */ |
| 85 | tableArn: string | undefined; |
| 86 | /** ARN of the IAM role the provider assumes to write data. */ |
| 87 | providerRoleArn: string | undefined; |
| 88 | /** S3 location the provider writes OCSF data to. */ |
| 89 | providerLocation: string | undefined; |
| 90 | }, |
| 91 | never, |
| 92 | Providers |
| 93 | > {} |
| 94 | |
| 95 | /** |
| 96 | * A custom (third-party) log source registered with Amazon Security Lake. |
| 97 | * Security Lake provisions a Glue crawler, database, and table for the |
| 98 | * source, plus an IAM role the provider assumes to write OCSF-formatted data |
| 99 | * into the data lake. Requires `SecurityLake.DataLake` to already be enabled |
| 100 | * in the Region. |
| 101 | * |
| 102 | * Every configuration property is create-only — changing any of them |
| 103 | * replaces the source. |
| 104 | * |
| 105 | * ### Registering a custom source |
| 106 | * **Example:** Custom source with a crawler role |
| 107 | * ```typescript |
| 108 | * const custom = yield* SecurityLake.CustomLogSource("AppLogs", { |
| 109 | * sourceName: "my-app-logs", |
| 110 | * eventClasses: ["FILE_ACTIVITY"], |
| 111 | * crawlerConfiguration: { roleArn: crawlerRole.roleArn }, |
| 112 | * providerIdentity: { |
| 113 | * principal: "123456789012", |
| 114 | * externalId: "my-app-external-id", |
| 115 | * }, |
| 116 | * }); |
| 117 | * ``` |
| 118 | */ |
| 119 | const CustomLogSourceResource = Resource<CustomLogSource>( |
| 120 | "AWS.SecurityLake.CustomLogSource", |
| 121 | ); |
| 122 | |
| 123 | export { CustomLogSourceResource as CustomLogSource }; |
| 124 | |
| 125 | const buildAttrs = (source: securitylake.CustomLogSourceResource) => ({ |
| 126 | sourceName: source.sourceName!, |
| 127 | sourceVersion: source.sourceVersion, |
| 128 | crawlerArn: source.attributes?.crawlerArn, |
| 129 | databaseArn: source.attributes?.databaseArn, |
| 130 | tableArn: source.attributes?.tableArn, |
| 131 | providerRoleArn: source.provider?.roleArn, |
| 132 | providerLocation: source.provider?.location, |
| 133 | }); |
| 134 | |
| 135 | export const CustomLogSourceProvider = () => |
| 136 | Provider.effect( |
| 137 | CustomLogSourceResource, |
| 138 | Effect.gen(function* () { |
| 139 | const createName = Effect.fn(function* ( |
| 140 | id: string, |
| 141 | props: { sourceName?: string }, |
| 142 | ) { |
| 143 | return ( |
| 144 | props.sourceName ?? (yield* createPhysicalName({ id, maxLength: 64 })) |
| 145 | ); |
| 146 | }); |
| 147 | |
| 148 | // listLogSources reports only the source identity (name + version) for |
| 149 | // custom sources — the Glue/provider attributes are only returned by |
| 150 | // createCustomLogSource, so `output` is the cache for those. |
| 151 | const findByName = ( |
| 152 | sourceName: string, |
| 153 | sourceVersion: string | undefined, |
| 154 | ) => |
| 155 | securitylake.listLogSources.items({}).pipe( |
| 156 | Stream.map((entry) => entry.sources ?? []), |
| 157 | Stream.flattenIterable, |
| 158 | Stream.filter( |
| 159 | (source) => |
| 160 | source.customLogSource?.sourceName === sourceName && |
| 161 | (sourceVersion === undefined || |
| 162 | source.customLogSource.sourceVersion === sourceVersion), |
| 163 | ), |
| 164 | Stream.take(1), |
| 165 | Stream.runHead, |
| 166 | Effect.map(Option.getOrUndefined), |
| 167 | Effect.map((source) => source?.customLogSource), |
| 168 | ); |
| 169 | |
| 170 | return { |
| 171 | read: Effect.fn(function* ({ id, olds, output }) { |
| 172 | const sourceName = |
| 173 | output?.sourceName ?? (yield* createName(id, olds ?? {})); |
| 174 | const sourceVersion = output?.sourceVersion ?? olds?.sourceVersion; |
| 175 | // An account that never onboarded Security Lake rejects |
| 176 | // listLogSources — that means "no source", not a failure. |
| 177 | const observed = yield* findByName(sourceName, sourceVersion).pipe( |
| 178 | Effect.catchTag( |
| 179 | [ |
| 180 | "AccessDeniedException", |
| 181 | "ResourceNotFoundException", |
| 182 | "UnauthorizedException", |
| 183 | ], |
| 184 | () => Effect.succeed(undefined), |
| 185 | ), |
| 186 | ); |
| 187 | if (observed === undefined) return undefined; |
| 188 | // Custom log sources carry no tags, so ownership can't be |
| 189 | // distinguished — read reports observed state (merged with cached |
| 190 | // create-time attributes). |
| 191 | return { |
| 192 | sourceName, |
| 193 | sourceVersion: observed.sourceVersion ?? sourceVersion, |
| 194 | crawlerArn: output?.crawlerArn, |
| 195 | databaseArn: output?.databaseArn, |
| 196 | tableArn: output?.tableArn, |
| 197 | providerRoleArn: output?.providerRoleArn, |
| 198 | providerLocation: output?.providerLocation, |
| 199 | }; |
| 200 | }), |
| 201 | |
| 202 | list: () => |
| 203 | securitylake.listLogSources.items({}).pipe( |
| 204 | Stream.map((entry) => entry.sources ?? []), |
| 205 | Stream.flattenIterable, |
| 206 | Stream.runCollect, |
| 207 | Effect.map((entries) => { |
| 208 | const seen = new Set<string>(); |
| 209 | const attrs: ReturnType<typeof buildAttrs>[] = []; |
| 210 | for (const entry of entries) { |
| 211 | const source = entry.customLogSource; |
| 212 | if (source?.sourceName === undefined) continue; |
| 213 | const key = `${source.sourceName}@${source.sourceVersion ?? ""}`; |
| 214 | if (seen.has(key)) continue; |
| 215 | seen.add(key); |
| 216 | attrs.push(buildAttrs(source)); |
| 217 | } |
| 218 | return attrs; |
| 219 | }), |
| 220 | // An account that never onboarded Security Lake has no data lake |
| 221 | // to list sources for. |
| 222 | Effect.catchTag( |
| 223 | [ |
| 224 | "AccessDeniedException", |
| 225 | "ResourceNotFoundException", |
| 226 | "UnauthorizedException", |
| 227 | ], |
| 228 | () => Effect.succeed([]), |
| 229 | ), |
| 230 | ), |
| 231 | |
| 232 | // There is no UpdateCustomLogSource — every property is create-only. |
| 233 | diff: Effect.fn(function* ({ news, olds }) { |
| 234 | if (!isResolved(news)) return undefined; |
| 235 | const changed = |
| 236 | news.sourceName !== olds.sourceName || |
| 237 | news.sourceVersion !== olds.sourceVersion || |
| 238 | JSON.stringify(news.eventClasses ?? []) !== |
| 239 | JSON.stringify(olds.eventClasses ?? []) || |
| 240 | news.crawlerConfiguration.roleArn !== |
| 241 | olds.crawlerConfiguration.roleArn || |
| 242 | news.providerIdentity.principal !== |
| 243 | olds.providerIdentity.principal || |
| 244 | news.providerIdentity.externalId !== |
| 245 | olds.providerIdentity.externalId; |
| 246 | if (changed) return { action: "replace" } as const; |
| 247 | }), |
| 248 | |
| 249 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 250 | const sourceName = yield* createName(id, news); |
| 251 | |
| 252 | // 1. OBSERVE — is the source already registered? |
| 253 | const observed = yield* findByName(sourceName, news.sourceVersion); |
| 254 | |
| 255 | // 2. ENSURE — create when missing. A ConflictException means a |
| 256 | // concurrent create won the race; fall through to observation. |
| 257 | let attrs = output; |
| 258 | if (observed === undefined) { |
| 259 | const created = yield* securitylake |
| 260 | .createCustomLogSource({ |
| 261 | sourceName, |
| 262 | sourceVersion: news.sourceVersion, |
| 263 | eventClasses: news.eventClasses, |
| 264 | configuration: { |
| 265 | crawlerConfiguration: news.crawlerConfiguration, |
| 266 | providerIdentity: news.providerIdentity, |
| 267 | }, |
| 268 | }) |
| 269 | .pipe( |
| 270 | Effect.map((response) => response.source), |
| 271 | // A concurrent create won the race — keep cached attributes. |
| 272 | Effect.catchTag("ConflictException", () => |
| 273 | Effect.succeed(undefined), |
| 274 | ), |
| 275 | ); |
| 276 | if (created !== undefined) { |
| 277 | attrs = buildAttrs(created); |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // 3. RETURN — merge observed identity with cached create-time |
| 282 | // attributes (Glue ARNs / provider role are only reported by |
| 283 | // create). |
| 284 | const final = { |
| 285 | sourceName, |
| 286 | sourceVersion: |
| 287 | attrs?.sourceVersion ?? |
| 288 | observed?.sourceVersion ?? |
| 289 | news.sourceVersion, |
| 290 | crawlerArn: attrs?.crawlerArn, |
| 291 | databaseArn: attrs?.databaseArn, |
| 292 | tableArn: attrs?.tableArn, |
| 293 | providerRoleArn: attrs?.providerRoleArn, |
| 294 | providerLocation: attrs?.providerLocation, |
| 295 | }; |
| 296 | yield* session.note(sourceName); |
| 297 | return final; |
| 298 | }), |
| 299 | |
| 300 | delete: Effect.fn(function* ({ output }) { |
| 301 | yield* securitylake |
| 302 | .deleteCustomLogSource({ |
| 303 | sourceName: output.sourceName, |
| 304 | sourceVersion: output.sourceVersion, |
| 305 | }) |
| 306 | .pipe( |
| 307 | retryWhileConflict, |
| 308 | // Gone already, or the data lake itself was offboarded first. |
| 309 | Effect.catchTag( |
| 310 | [ |
| 311 | "AccessDeniedException", |
| 312 | "ResourceNotFoundException", |
| 313 | "UnauthorizedException", |
| 314 | ], |
| 315 | () => Effect.void, |
| 316 | ), |
| 317 | ); |
| 318 | }), |
| 319 | }; |
| 320 | }), |
| 321 | ); |