1import * as securitylake from "@distilled.cloud/aws/securitylake";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { retryWhileConflict } from "./internal.ts";
11
12/**
13 * The Glue crawler configuration Security Lake uses to catalog the custom
14 * source's OCSF data.
15 */
16export 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 */
29export 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
36export 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 */
72export 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 */
119const CustomLogSourceResource = Resource<CustomLogSource>(
120 "AWS.SecurityLake.CustomLogSource",
121);
122
123export { CustomLogSourceResource as CustomLogSource };
124
125const 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
135export 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 );