1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.Devices.PostureRule" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * The type of device posture rule, e.g. `os_version`, `firewall`,
19 * `disk_encryption`, or a third-party service-to-service integration type
20 * like `crowdstrike_s2s` (which requires a posture integration's
21 * `connectionId` in {@link DevicePostureRuleProps.input}).
22 */
23export type DevicePostureRuleType =
24 zeroTrust.CreateDevicePostureRequest["type"];
25
26/**
27 * The per-type check definition for a posture rule. The accepted shape
28 * depends on {@link DevicePostureRuleProps.type} — e.g. `os_version` takes
29 * `{ operatingSystem, operator, version }`, `firewall` takes
30 * `{ enabled, operatingSystem }`, `disk_encryption` takes
31 * `{ checkDisks?, requireAll? }`.
32 */
33export type DevicePostureRuleInput =
34 zeroTrust.CreateDevicePostureRequest["input"];
35
36/**
37 * Platform conditions that scope which devices run the rule.
38 */
39export type DevicePostureRuleMatch =
40 zeroTrust.CreateDevicePostureRequest["match"];
41
42export interface DevicePostureRuleProps {
43 /**
44 * Name of the device posture rule. If omitted, a unique name is
45 * generated from the app, stage, and logical ID.
46 * @default ${app}-${stage}-${id}
47 */
48 name?: string;
49 /**
50 * The type of posture check (e.g. `os_version`, `firewall`,
51 * `disk_encryption`, `crowdstrike_s2s`). Immutable — changing the type
52 * triggers a replacement.
53 */
54 type: DevicePostureRuleType;
55 /**
56 * A description of the posture rule.
57 */
58 description?: string;
59 /**
60 * Polling frequency for the WARP client posture check (e.g. `"5m"`,
61 * `"1h"`). Minimum `1m`.
62 * @default "5m"
63 */
64 schedule?: string;
65 /**
66 * Expiration time for a posture check result (e.g. `"1h"`). If empty,
67 * the result remains valid until overwritten by new data from the WARP
68 * client.
69 */
70 expiration?: string;
71 /**
72 * The conditions (platforms) that a device must match for the rule to
73 * run, e.g. `[{ platform: "mac" }]`.
74 */
75 match?: DevicePostureRuleMatch;
76 /**
77 * The per-type value to check against. Shape depends on {@link type}.
78 */
79 input?: DevicePostureRuleInput;
80}
81
82export type DevicePostureRuleAttributes = {
83 /** API UUID of the posture rule. */
84 postureRuleId: string;
85 /** Account that owns the rule. */
86 accountId: string;
87 /** Observed rule name. */
88 name: string;
89 /** Observed rule type. */
90 type: string;
91 /** Observed description. */
92 description: string | undefined;
93 /** Observed polling schedule. */
94 schedule: string | undefined;
95 /** Observed result expiration. */
96 expiration: string | undefined;
97 /** Observed platform conditions. */
98 match: { platform: string | undefined }[] | undefined;
99 /** Observed per-type check definition. */
100 input: unknown;
101};
102
103export type DevicePostureRule = Resource<
104 TypeId,
105 DevicePostureRuleProps,
106 DevicePostureRuleAttributes,
107 never,
108 Providers
109>;
110
111/**
112 * A Cloudflare Zero Trust **device posture rule** — a periodic check the
113 * WARP client runs on enrolled devices (OS version, firewall status, disk
114 * encryption, file presence, or a third-party security provider's
115 * verdict). Posture results can then gate Access policies and Gateway
116 * rules.
117 *
118 * Everything except `type` is mutable in place (full PUT). Changing
119 * `type` replaces the rule.
120 * ### Infrastructure-free checks
121 * **Example:** Require a minimum Windows version
122 * ```typescript
123 * const rule = yield* Cloudflare.Devices.DevicePostureRule("WindowsOsVersion", {
124 * type: "os_version",
125 * description: "Require Windows 10.0.19045+",
126 * match: [{ platform: "windows" }],
127 * schedule: "5m",
128 * input: {
129 * operatingSystem: "windows",
130 * operator: ">=",
131 * version: "10.0.19045",
132 * },
133 * });
134 * ```
135 *
136 * **Example:** Require the OS firewall to be enabled
137 * ```typescript
138 * yield* Cloudflare.Devices.DevicePostureRule("Firewall", {
139 * type: "firewall",
140 * match: [{ platform: "windows" }, { platform: "mac" }],
141 * input: { enabled: true, operatingSystem: "windows" },
142 * });
143 * ```
144 *
145 * **Example:** Require disk encryption on all drives
146 * ```typescript
147 * yield* Cloudflare.Devices.DevicePostureRule("DiskEncryption", {
148 * type: "disk_encryption",
149 * match: [{ platform: "mac" }],
150 * input: { requireAll: true },
151 * });
152 * ```
153 *
154 * @see https://developers.cloudflare.com/cloudflare-one/identity/devices/
155 *
156 * @resource
157 * @product Devices
158 * @category Cloudflare One (Zero Trust)
159 */
160export const DevicePostureRule = Resource<DevicePostureRule>(TypeId);
161
162/**
163 * Returns true if the given value is a DevicePostureRule resource.
164 */
165export const isDevicePostureRule = (
166 value: unknown,
167): value is DevicePostureRule =>
168 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
169
170export const DevicePostureRuleProvider = () =>
171 Provider.succeed(DevicePostureRule, {
172 stables: ["postureRuleId", "accountId", "type"],
173
174 list: Effect.fn(function* () {
175 const { accountId } = yield* yield* CloudflareEnvironment;
176 // Account-scoped collection: exhaustively paginate the device
177 // posture rules list and hydrate each into the `read` shape.
178 return yield* zeroTrust.listDevicePostures.pages({ accountId }).pipe(
179 Stream.runCollect,
180 Effect.map((chunk) =>
181 Array.from(chunk).flatMap((page) =>
182 (page.result ?? []).map((rule) => toAttributes(rule, accountId)),
183 ),
184 ),
185 // Account lacks the Zero Trust / device posture entitlement.
186 Effect.catchTag("Forbidden", () => Effect.succeed([])),
187 );
188 }),
189
190 diff: Effect.fn(function* ({ olds, news, output }) {
191 if (!isResolved(news)) return undefined;
192 // `type` is immutable on Cloudflare's side — replace on change.
193 const oldType = output?.type ?? olds?.type;
194 if (oldType !== undefined && oldType !== news.type) {
195 return { action: "replace" } as const;
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ id, output, olds }) {
201 const { accountId } = yield* yield* CloudflareEnvironment;
202 const acct = output?.accountId ?? accountId;
203
204 // Owned path: refresh by our persisted rule id.
205 if (output?.postureRuleId) {
206 const observed = yield* observeRule(acct, output.postureRuleId);
207 return observed ? toAttributes(observed, acct) : undefined;
208 }
209
210 // Cold lookup: recover from lost state by exact name. Posture rules
211 // carry no ownership markers, so brand the match `Unowned`.
212 const name = yield* createRuleName(id, olds?.name);
213 const match = yield* findByName(acct, name);
214 if (match) return Unowned(toAttributes(match, acct));
215 return undefined;
216 }),
217
218 reconcile: Effect.fn(function* ({ id, news, output }) {
219 const { accountId } = yield* yield* CloudflareEnvironment;
220 const name = yield* createRuleName(id, news.name);
221
222 // 1. Observe — the rule id cached on `output` is a hint, not a
223 // guarantee: a missing rule falls through to create.
224 const observed = output?.postureRuleId
225 ? yield* observeRule(accountId, output.postureRuleId)
226 : undefined;
227
228 // 2. Ensure — create when missing. Names are not unique on
229 // Cloudflare's side, so there is no AlreadyExists race.
230 if (!observed) {
231 const created = yield* zeroTrust.createDevicePosture({
232 accountId,
233 name,
234 type: news.type,
235 description: news.description,
236 schedule: news.schedule,
237 expiration: news.expiration,
238 match: news.match,
239 input: news.input,
240 });
241 return toAttributes(created, accountId);
242 }
243
244 // 3. Sync — the update API is a PUT that requires the full body;
245 // send everything, but skip the call entirely on a no-op.
246 const dirty =
247 (observed.name ?? "") !== name ||
248 (news.description !== undefined &&
249 !sameJSON(denull(observed.description), news.description)) ||
250 (news.schedule !== undefined &&
251 denull(observed.schedule) !== news.schedule) ||
252 (news.expiration !== undefined &&
253 denull(observed.expiration) !== news.expiration) ||
254 (news.match !== undefined &&
255 !sameJSON(normalizeMatch(observed.match), news.match)) ||
256 (news.input !== undefined && !sameJSON(observed.input, news.input));
257 if (!dirty) {
258 return toAttributes(observed, accountId);
259 }
260 const updated = yield* zeroTrust.updateDevicePosture({
261 accountId,
262 ruleId: observed.id!,
263 name,
264 type: news.type,
265 description: news.description,
266 schedule: news.schedule,
267 expiration: news.expiration,
268 match: news.match,
269 input: news.input,
270 });
271 return toAttributes(updated, accountId);
272 }),
273
274 delete: Effect.fn(function* ({ output }) {
275 // Cloudflare's delete is already idempotent (200 on a missing
276 // rule); the typed catch covers the documented 404 path too.
277 yield* zeroTrust
278 .deleteDevicePosture({
279 accountId: output.accountId,
280 ruleId: output.postureRuleId,
281 })
282 .pipe(Effect.catchTag("PostureRuleNotFound", () => Effect.void));
283 }),
284 });
285
286type ObservedRule =
287 | zeroTrust.GetDevicePostureResponse
288 | zeroTrust.CreateDevicePostureResponse
289 | zeroTrust.UpdateDevicePostureResponse
290 | zeroTrust.ListDevicePosturesResponse["result"][number];
291
292/**
293 * Read a posture rule by id, mapping "gone" (`PostureRuleNotFound`,
294 * Cloudflare error code 6024) to `undefined`.
295 */
296const observeRule = (accountId: string, ruleId: string) =>
297 zeroTrust
298 .getDevicePosture({ accountId, ruleId })
299 .pipe(
300 Effect.catchTag("PostureRuleNotFound", () => Effect.succeed(undefined)),
301 );
302
303/**
304 * Find a posture rule by exact name (oldest-id-first for determinism when
305 * names collide).
306 */
307const findByName = (accountId: string, name: string) =>
308 zeroTrust.listDevicePostures.items({ accountId }).pipe(
309 Stream.filter((r) => r.name === name && r.id != null),
310 Stream.runCollect,
311 Effect.map((chunk) =>
312 Array.from(chunk)
313 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
314 .at(0),
315 ),
316 );
317
318const createRuleName = (id: string, name: string | undefined) =>
319 Effect.gen(function* () {
320 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
321 });
322
323const toAttributes = (
324 rule: ObservedRule,
325 accountId: string,
326): DevicePostureRuleAttributes => ({
327 postureRuleId: rule.id ?? "",
328 accountId,
329 name: rule.name ?? "",
330 type: rule.type ?? "",
331 description: denull(rule.description),
332 schedule: denull(rule.schedule),
333 expiration: denull(rule.expiration),
334 match: normalizeMatch(rule.match),
335 input: denull(rule.input),
336});
337
338const normalizeMatch = (
339 match: ObservedRule["match"],
340): { platform: string | undefined }[] | undefined =>
341 match == null
342 ? undefined
343 : match.map((m) => ({ platform: denull(m.platform) }));
344
345/**
346 * Strip Cloudflare's `null` echoes to `undefined` so structural equality
347 * (`JSON.stringify`) works.
348 */
349const denull = <T>(v: T | null | undefined): T | undefined =>
350 v == null ? undefined : v;
351
352/** Structural deep-equality via canonical JSON. */
353const sameJSON = (a: unknown, b: unknown): boolean =>
354 JSON.stringify(a) === JSON.stringify(b);