1import * as aa from "@distilled.cloud/aws/accessanalyzer";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7
8/**
9 * A single filter criterion evaluated against a finding attribute. Provide at
10 * least one comparison; multiple comparisons on one criterion are ANDed.
11 */
12export interface ArchiveRuleCriterion {
13 /** The attribute value must equal one of these values. */
14 eq?: string[];
15 /** The attribute value must not equal any of these values. */
16 neq?: string[];
17 /** The attribute value must contain one of these substrings. */
18 contains?: string[];
19 /** The attribute must (or must not) be present. */
20 exists?: boolean;
21}
22
23export interface ArchiveRuleProps {
24 /**
25 * The name of the analyzer the rule belongs to. Changing the analyzer
26 * replaces the rule.
27 */
28 analyzerName: string;
29 /**
30 * The name of the archive rule, unique within the analyzer. Changing the
31 * name replaces the rule.
32 */
33 ruleName: string;
34 /**
35 * The filter criteria keyed by finding attribute (e.g. `principal.AWS`,
36 * `resource`, `condition.aws:PrincipalOrgID`, `isPublic`). New findings
37 * matching every criterion are automatically archived. Mutable — changing
38 * the filter updates the rule in place.
39 */
40 filter: Record<string, ArchiveRuleCriterion>;
41}
42
43export interface ArchiveRule extends Resource<
44 "AWS.AccessAnalyzer.ArchiveRule",
45 ArchiveRuleProps,
46 {
47 analyzerName: string;
48 ruleName: string;
49 },
50 {},
51 Providers
52> {}
53
54/**
55 * An archive rule for an IAM Access Analyzer — automatically archives new
56 * findings that match the filter criteria, so expected cross-account or
57 * public grants don't clutter the active findings list.
58 *
59 * Archive rules apply only to findings created after the rule; existing
60 * findings are unaffected.
61 * ### Creating Archive Rules
62 * **Example:** Archive Findings from a Trusted Account
63 * ```typescript
64 * import * as AWS from "alchemy/AWS";
65 *
66 * const analyzer = yield* AWS.AccessAnalyzer.Analyzer("AccountAnalyzer", {});
67 *
68 * yield* AWS.AccessAnalyzer.ArchiveRule("TrustedAccount", {
69 * analyzerName: analyzer.analyzerName,
70 * ruleName: "trusted-account",
71 * filter: {
72 * "principal.AWS": { eq: ["123456789012"] },
73 * },
74 * });
75 * ```
76 *
77 * **Example:** Archive Public S3 Findings
78 * ```typescript
79 * yield* AWS.AccessAnalyzer.ArchiveRule("PublicBuckets", {
80 * analyzerName: analyzer.analyzerName,
81 * ruleName: "public-buckets",
82 * filter: {
83 * resourceType: { eq: ["AWS::S3::Bucket"] },
84 * isPublic: { eq: ["true"] },
85 * },
86 * });
87 * ```
88 *
89 * @resource
90 */
91export const ArchiveRule = Resource<ArchiveRule>(
92 "AWS.AccessAnalyzer.ArchiveRule",
93);
94
95export const ArchiveRuleProvider = () =>
96 Provider.effect(
97 ArchiveRule,
98 Effect.gen(function* () {
99 const toFilter = (filter: Record<string, ArchiveRuleCriterion>) =>
100 Object.fromEntries(
101 Object.entries(filter).map(([key, criterion]) => [
102 key,
103 {
104 eq: criterion.eq,
105 neq: criterion.neq,
106 contains: criterion.contains,
107 exists: criterion.exists,
108 },
109 ]),
110 );
111
112 const observe = Effect.fn(function* (
113 analyzerName: string,
114 ruleName: string,
115 ) {
116 return yield* aa.getArchiveRule({ analyzerName, ruleName }).pipe(
117 Effect.map((r) => r.archiveRule),
118 Effect.catchTag("ResourceNotFoundException", () =>
119 Effect.succeed(undefined),
120 ),
121 );
122 });
123
124 return ArchiveRule.Provider.of({
125 stables: ["analyzerName", "ruleName"],
126
127 // sub-resource keyed by parent analyzer + rule name — not enumerable
128 // account-wide without the parent
129 list: () => Effect.succeed([]),
130
131 read: Effect.fn(function* ({ olds, output }) {
132 const analyzerName = output?.analyzerName ?? olds?.analyzerName;
133 const ruleName = output?.ruleName ?? olds?.ruleName;
134 if (analyzerName === undefined || ruleName === undefined) {
135 return undefined;
136 }
137 const rule = yield* observe(analyzerName, ruleName);
138 if (rule === undefined) return undefined;
139 return { analyzerName, ruleName: rule.ruleName };
140 }),
141
142 diff: Effect.fn(function* ({ news, olds }) {
143 if (!isResolved(news)) return undefined;
144 if (olds.analyzerName !== news.analyzerName) {
145 return { action: "replace" } as const;
146 }
147 if (olds.ruleName !== news.ruleName) {
148 return { action: "replace" } as const;
149 }
150 // filter is mutable → default update path
151 }),
152
153 reconcile: Effect.fn(function* ({ news, output, session }) {
154 const filter = toFilter(news.filter);
155
156 // 1. OBSERVE — cloud state is authoritative
157 const existing = yield* observe(news.analyzerName, news.ruleName);
158
159 // 2. ENSURE / SYNC — createArchiveRule for a new rule, otherwise
160 // updateArchiveRule to converge the filter. Both are idempotent.
161 if (existing === undefined) {
162 yield* aa
163 .createArchiveRule({
164 analyzerName: news.analyzerName,
165 ruleName: news.ruleName,
166 filter,
167 })
168 .pipe(
169 // a concurrent create — fall through to update the filter
170 Effect.catchTag("ConflictException", () =>
171 aa.updateArchiveRule({
172 analyzerName: news.analyzerName,
173 ruleName: news.ruleName,
174 filter,
175 }),
176 ),
177 );
178 } else {
179 yield* aa.updateArchiveRule({
180 analyzerName: news.analyzerName,
181 ruleName: news.ruleName,
182 filter,
183 });
184 }
185
186 yield* session.note(news.ruleName);
187 return {
188 analyzerName: news.analyzerName,
189 ruleName: news.ruleName,
190 };
191 }),
192
193 delete: Effect.fn(function* ({ output }) {
194 yield* aa
195 .deleteArchiveRule({
196 analyzerName: output.analyzerName,
197 ruleName: output.ruleName,
198 })
199 .pipe(
200 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
201 );
202 }),
203 });
204 }),
205 );