1import * as iot from "@distilled.cloud/aws/iot";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import { readIotTags, syncIotTags } from "./internal.ts";
12
13/**
14 * An IoT policy statement. Structurally similar to an IAM statement but
15 * scopes IoT actions (`iot:Connect`, `iot:Publish`, `iot:Subscribe`,
16 * `iot:Receive`) to topic / client ARNs.
17 */
18export interface IoTPolicyStatement {
19 Effect: "Allow" | "Deny";
20 Action: string | string[];
21 Resource: string | string[];
22}
23
24export interface IoTPolicyDocument {
25 Version?: string;
26 Statement: IoTPolicyStatement[];
27}
28
29export interface PolicyProps {
30 /**
31 * Name of the policy. If omitted, a unique name is generated.
32 * Changing it replaces the policy.
33 */
34 policyName?: string;
35
36 /**
37 * The JSON policy document, either as a string or a structured document.
38 */
39 policyDocument: string | IoTPolicyDocument;
40
41 /**
42 * User tags to attach to the policy.
43 */
44 tags?: Record<string, string>;
45}
46
47export interface Policy extends Resource<
48 "AWS.IoT.Policy",
49 PolicyProps,
50 {
51 /** The name of the policy. */
52 policyName: string;
53 /** The ARN of the policy. */
54 policyArn: string;
55 },
56 never,
57 Providers
58> {}
59
60/**
61 * An AWS IoT policy that grants MQTT permissions (connect, publish,
62 * subscribe, receive) to certificates and other principals.
63 *
64 * ### Creating a Policy
65 * **Example:** Allow Publish and Subscribe
66 * ```typescript
67 * const policy = yield* Policy("device-policy", {
68 * policyDocument: {
69 * Version: "2012-10-17",
70 * Statement: [
71 * { Effect: "Allow", Action: "iot:Connect", Resource: "*" },
72 * { Effect: "Allow", Action: ["iot:Publish", "iot:Receive"], Resource: "*" },
73 * ],
74 * },
75 * });
76 * ```
77 *
78 * @resource
79 */
80export const Policy = Resource<Policy>("AWS.IoT.Policy");
81
82const stringifyDocument = (doc: string | IoTPolicyDocument): string =>
83 typeof doc === "string" ? doc : JSON.stringify(doc);
84
85// Normalize a policy document to a stable canonical JSON string for diffing.
86const normalizeDocument = (doc: string | undefined): string => {
87 if (!doc) return "";
88 try {
89 return JSON.stringify(JSON.parse(doc));
90 } catch {
91 return doc;
92 }
93};
94
95export const PolicyProvider = () =>
96 Provider.effect(
97 Policy,
98 Effect.gen(function* () {
99 const createName = Effect.fn(function* (id: string, props: PolicyProps) {
100 return (
101 props.policyName ??
102 (yield* createPhysicalName({ id, maxLength: 128 }))
103 );
104 });
105
106 return Policy.Provider.of({
107 stables: ["policyName", "policyArn"],
108 list: () =>
109 iot.listPolicies.pages({}).pipe(
110 Stream.runCollect,
111 Effect.map((chunk) =>
112 Array.from(chunk).flatMap((page) =>
113 (page.policies ?? [])
114 .filter((p) => p.policyName != null && p.policyArn != null)
115 .map((p) => ({
116 policyName: p.policyName!,
117 policyArn: p.policyArn!,
118 })),
119 ),
120 ),
121 ),
122 read: Effect.fn(function* ({ id, olds, output }) {
123 const policyName =
124 output?.policyName ?? (yield* createName(id, olds ?? {}));
125 const found = yield* iot
126 .getPolicy({ policyName })
127 .pipe(
128 Effect.catchTag("ResourceNotFoundException", () =>
129 Effect.succeed(undefined),
130 ),
131 );
132 if (!found) return undefined;
133 const attrs = {
134 policyName,
135 policyArn: found.policyArn!,
136 };
137 const tags = yield* readIotTags(found.policyArn!);
138 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
139 }),
140 diff: Effect.fn(function* ({ id, news, olds }) {
141 if (!isResolved(news)) return undefined;
142 const oldName = yield* createName(id, olds);
143 const newName = yield* createName(id, news);
144 if (oldName !== newName) return { action: "replace" } as const;
145 return undefined;
146 }),
147 reconcile: Effect.fn(function* ({ id, news, output, session }) {
148 const policyName =
149 output?.policyName ?? (yield* createName(id, news));
150 const desiredDocument = stringifyDocument(news.policyDocument);
151
152 // OBSERVE
153 let live = yield* iot
154 .getPolicy({ policyName })
155 .pipe(
156 Effect.catchTag("ResourceNotFoundException", () =>
157 Effect.succeed(undefined),
158 ),
159 );
160
161 // ENSURE
162 if (live === undefined) {
163 yield* iot
164 .createPolicy({ policyName, policyDocument: desiredDocument })
165 .pipe(
166 Effect.catchTag(
167 "ResourceAlreadyExistsException",
168 () => Effect.void,
169 ),
170 );
171 live = yield* iot.getPolicy({ policyName });
172 } else if (
173 normalizeDocument(live.policyDocument) !==
174 normalizeDocument(desiredDocument)
175 ) {
176 // SYNC document — a new default version. IoT caps a policy at 5
177 // versions, so prune the oldest non-default version first.
178 const { policyVersions = [] } = yield* iot.listPolicyVersions({
179 policyName,
180 });
181 const nonDefault = policyVersions
182 .filter((v) => !v.isDefaultVersion && v.versionId)
183 .sort((a, b) => (a.versionId! < b.versionId! ? -1 : 1));
184 if (policyVersions.length >= 5 && nonDefault[0]?.versionId) {
185 yield* iot
186 .deletePolicyVersion({
187 policyName,
188 policyVersionId: nonDefault[0].versionId,
189 })
190 .pipe(
191 Effect.catchTag(
192 "ResourceNotFoundException",
193 () => Effect.void,
194 ),
195 );
196 }
197 yield* iot.createPolicyVersion({
198 policyName,
199 policyDocument: desiredDocument,
200 setAsDefault: true,
201 });
202 live = yield* iot.getPolicy({ policyName });
203 }
204
205 // SYNC tags
206 yield* syncIotTags(live.policyArn!, id, news.tags);
207
208 yield* session.note(policyName);
209 return {
210 policyName,
211 policyArn: live.policyArn!,
212 };
213 }),
214 delete: Effect.fn(function* ({ output }) {
215 const policyName = output.policyName;
216 // Delete all non-default versions first — deletePolicy rejects a
217 // policy that still has extra versions.
218 const { policyVersions = [] } = yield* iot
219 .listPolicyVersions({ policyName })
220 .pipe(
221 Effect.catchTag("ResourceNotFoundException", () =>
222 Effect.succeed({ policyVersions: [] }),
223 ),
224 );
225 yield* Effect.forEach(
226 policyVersions.filter((v) => !v.isDefaultVersion && v.versionId),
227 (v) =>
228 iot
229 .deletePolicyVersion({
230 policyName,
231 policyVersionId: v.versionId!,
232 })
233 .pipe(
234 Effect.catchTag(
235 "ResourceNotFoundException",
236 () => Effect.void,
237 ),
238 ),
239 );
240 // Detach any lingering targets (certificates/groups) before delete.
241 const detachTargets = Effect.gen(function* () {
242 const { targets = [] } = yield* iot
243 .listTargetsForPolicy({ policyName })
244 .pipe(
245 Effect.catchTag("ResourceNotFoundException", () =>
246 Effect.succeed({ targets: [] }),
247 ),
248 );
249 yield* Effect.forEach(targets, (target) =>
250 iot
251 .detachPolicy({ policyName, target })
252 .pipe(
253 Effect.catchTag("InvalidRequestException", () => Effect.void),
254 ),
255 );
256 });
257 yield* iot.deletePolicy({ policyName }).pipe(
258 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
259 Effect.catchTag("DeleteConflictException", () =>
260 detachTargets.pipe(
261 Effect.andThen(
262 iot
263 .deletePolicy({ policyName })
264 .pipe(
265 Effect.catchTag(
266 [
267 "ResourceNotFoundException",
268 "DeleteConflictException",
269 ],
270 () => Effect.void,
271 ),
272 ),
273 ),
274 ),
275 ),
276 );
277 }),
278 });
279 }),
280 );