1import * as securitylake from "@distilled.cloud/aws/securitylake";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { toWireDays } from "../../Util/Duration.ts";
7import type { Providers } from "../Providers.ts";
8import { retryWhileConflict } from "./internal.ts";
9
10export interface ExceptionSubscriptionProps {
11 /**
12 * The protocol used to notify the subscription endpoint, e.g. `email`,
13 * `sqs`, or `https`.
14 */
15 subscriptionProtocol: string;
16
17 /**
18 * The endpoint Security Lake delivers exception notifications to (an email
19 * address, SQS queue ARN, or HTTPS endpoint, matching the protocol).
20 */
21 notificationEndpoint: string;
22
23 /**
24 * How long unresolved exceptions are retained before expiring. Accepts any
25 * `Duration.Input` (e.g. `"30 days"`, `Duration.days(30)`; a bare number is
26 * milliseconds); the wire unit is whole days.
27 * @default - exceptions do not expire
28 */
29 exceptionTimeToLive?: Duration.Input;
30}
31
32/** @resource */
33export interface ExceptionSubscription extends Resource<
34 "AWS.SecurityLake.ExceptionSubscription",
35 ExceptionSubscriptionProps,
36 {
37 /** The protocol used to notify the subscription endpoint. */
38 subscriptionProtocol: string;
39 /** The endpoint that receives exception notifications. */
40 notificationEndpoint: string;
41 /** Retention of unresolved exceptions, in whole days. */
42 exceptionTimeToLive: number | undefined;
43 },
44 never,
45 Providers
46> {}
47
48/**
49 * The Amazon Security Lake exception notification subscription — an
50 * account-Region singleton that delivers notifications (via SNS protocols
51 * like email, SQS, or HTTPS) whenever Security Lake hits an exception it
52 * cannot resolve automatically.
53 *
54 * ### Subscribing to exceptions
55 * **Example:** Email notifications
56 * ```typescript
57 * const exceptions = yield* SecurityLake.ExceptionSubscription("Exceptions", {
58 * subscriptionProtocol: "email",
59 * notificationEndpoint: "security-team@example.com",
60 * });
61 * ```
62 *
63 * **Example:** SQS notifications with a 30-day exception TTL
64 * ```typescript
65 * const exceptions = yield* SecurityLake.ExceptionSubscription("Exceptions", {
66 * subscriptionProtocol: "sqs",
67 * notificationEndpoint: queue.queueArn,
68 * exceptionTimeToLive: "30 days",
69 * });
70 * ```
71 */
72const ExceptionSubscriptionResource = Resource<ExceptionSubscription>(
73 "AWS.SecurityLake.ExceptionSubscription",
74);
75
76export { ExceptionSubscriptionResource as ExceptionSubscription };
77
78// Observation for read/list: an account that never onboarded Security Lake
79// (or has no subscription) yields "missing", not a failure.
80const observeSubscription = securitylake
81 .getDataLakeExceptionSubscription({})
82 .pipe(
83 Effect.map((response) =>
84 response.notificationEndpoint !== undefined &&
85 response.subscriptionProtocol !== undefined
86 ? {
87 subscriptionProtocol: response.subscriptionProtocol,
88 notificationEndpoint: response.notificationEndpoint,
89 exceptionTimeToLive: response.exceptionTimeToLive,
90 }
91 : undefined,
92 ),
93 Effect.catchTag(
94 [
95 "AccessDeniedException",
96 "ResourceNotFoundException",
97 "UnauthorizedException",
98 ],
99 () => Effect.succeed(undefined),
100 ),
101 );
102
103export const ExceptionSubscriptionProvider = () =>
104 Provider.effect(
105 ExceptionSubscriptionResource,
106 Effect.gen(function* () {
107 return {
108 // The subscription carries no tags and is an account-Region
109 // singleton — read reports observed state directly.
110 read: Effect.fn(function* () {
111 return yield* observeSubscription;
112 }),
113
114 list: () =>
115 Effect.gen(function* () {
116 const observed = yield* observeSubscription;
117 return observed === undefined ? [] : [observed];
118 }),
119
120 reconcile: Effect.fn(function* ({ news, session }) {
121 const desired = {
122 subscriptionProtocol: news.subscriptionProtocol,
123 notificationEndpoint: news.notificationEndpoint,
124 exceptionTimeToLive: toWireDays(news.exceptionTimeToLive),
125 };
126
127 // 1. OBSERVE — the singleton's current state.
128 const observed = yield* observeSubscription;
129
130 // 2. ENSURE — create when missing; a ConflictException means a
131 // concurrent create won the race, so converge via update below.
132 if (observed === undefined) {
133 yield* securitylake
134 .createDataLakeExceptionSubscription(desired)
135 .pipe(
136 Effect.catchTag("ConflictException", () =>
137 securitylake
138 .updateDataLakeExceptionSubscription(desired)
139 .pipe(retryWhileConflict),
140 ),
141 );
142 } else if (
143 observed.subscriptionProtocol !== desired.subscriptionProtocol ||
144 observed.notificationEndpoint !== desired.notificationEndpoint ||
145 observed.exceptionTimeToLive !== desired.exceptionTimeToLive
146 ) {
147 // 3. SYNC — apply only when observed differs from desired.
148 yield* securitylake
149 .updateDataLakeExceptionSubscription(desired)
150 .pipe(retryWhileConflict);
151 }
152
153 // 4. RETURN fresh attributes.
154 const final = (yield* observeSubscription) ?? {
155 subscriptionProtocol: desired.subscriptionProtocol,
156 notificationEndpoint: desired.notificationEndpoint,
157 exceptionTimeToLive: desired.exceptionTimeToLive,
158 };
159 yield* session.note(
160 `${final.subscriptionProtocol}:${final.notificationEndpoint}`,
161 );
162 return final;
163 }),
164
165 delete: Effect.fn(function* () {
166 yield* securitylake.deleteDataLakeExceptionSubscription({}).pipe(
167 retryWhileConflict,
168 // Gone already, or the account was never onboarded.
169 Effect.catchTag(
170 [
171 "AccessDeniedException",
172 "ResourceNotFoundException",
173 "UnauthorizedException",
174 ],
175 () => Effect.void,
176 ),
177 );
178 }),
179 };
180 }),
181 );