1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Devices.PostureIntegration" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * The third-party provider behind the posture integration. Determines
20 * which {@link DevicePostureIntegrationConfig} fields are required.
21 */
22export type DevicePostureIntegrationType =
23 | "workspace_one"
24 | "crowdstrike_s2s"
25 | "uptycs"
26 | "intune"
27 | "kolide"
28 | "tanium_s2s"
29 | "sentinelone_s2s"
30 | "custom_s2s";
31
32/**
33 * Connection settings for the third-party posture provider. Which fields
34 * are required depends on the integration `type`:
35 *
36 * - `workspace_one` — `apiUrl`, `authUrl`, `clientId`, `clientSecret`
37 * - `crowdstrike_s2s` — `apiUrl`, `clientId`, `clientSecret`, `customerId`
38 * - `uptycs` — `apiUrl`, `clientKey`, `clientSecret`, `customerId`
39 * - `intune` — `clientId`, `clientSecret`, `customerId`
40 * - `kolide` — `clientId`, `clientSecret`
41 * - `tanium_s2s` — `apiUrl`, `clientSecret`, optional `accessClientId` /
42 * `accessClientSecret`
43 * - `sentinelone_s2s` — `apiUrl`, `clientSecret`
44 * - `custom_s2s` — `apiUrl`, `clientSecret`, `accessClientId`,
45 * `accessClientSecret`
46 *
47 * Secrets are write-only: Cloudflare never returns them on reads, so they
48 * are carried forward from the desired state.
49 */
50export interface DevicePostureIntegrationConfig {
51 /** The third-party API URL the integration polls. */
52 apiUrl?: string;
53 /** The OAuth authorization URL (workspace_one). */
54 authUrl?: string;
55 /** The OAuth client ID used to authenticate with the provider. */
56 clientId?: string;
57 /** The client key (uptycs). */
58 clientKey?: string;
59 /** The customer/tenant identifier (crowdstrike, uptycs, intune). */
60 customerId?: string;
61 /** The OAuth client secret. Write-only — never returned on reads. */
62 clientSecret?: Redacted.Redacted<string>;
63 /** Access service-token client ID guarding the custom endpoint. */
64 accessClientId?: string;
65 /** Access service-token client secret. Write-only. */
66 accessClientSecret?: Redacted.Redacted<string>;
67}
68
69export interface DevicePostureIntegrationProps {
70 /**
71 * Name of the posture integration. If omitted, a unique name is
72 * generated from the app, stage, and logical ID.
73 * @default ${app}-${stage}-${id}
74 */
75 name?: string;
76 /**
77 * The third-party provider type. Immutable — changing it triggers a
78 * replacement.
79 */
80 type: DevicePostureIntegrationType;
81 /**
82 * The interval between each posture check against the third-party API.
83 * Use `m` for minutes (e.g. `5m`) and `h` for hours (e.g. `12h`).
84 */
85 interval: string;
86 /**
87 * Connection settings for the provider (see
88 * {@link DevicePostureIntegrationConfig} for the per-type field
89 * requirements). Cloudflare validates the credentials against the live
90 * third-party API on create/update.
91 */
92 config: DevicePostureIntegrationConfig;
93}
94
95export type DevicePostureIntegrationAttributes = {
96 /** API UUID of the posture integration. */
97 integrationId: string;
98 /** Account that owns the integration. */
99 accountId: string;
100 /** Observed integration name. */
101 name: string;
102 /** The third-party provider type. */
103 type: DevicePostureIntegrationType;
104 /** Observed polling interval. */
105 interval: string;
106 /** Non-secret connection details as reported by Cloudflare. */
107 config: {
108 apiUrl: string | undefined;
109 authUrl: string | undefined;
110 clientId: string | undefined;
111 };
112};
113
114export type DevicePostureIntegration = Resource<
115 TypeId,
116 DevicePostureIntegrationProps,
117 DevicePostureIntegrationAttributes,
118 never,
119 Providers
120>;
121
122/**
123 * A Cloudflare Zero Trust **device posture integration** — a service-to-
124 * service connection to a third-party endpoint security provider
125 * (CrowdStrike, Intune, Kolide, Workspace ONE, ...) whose signals power
126 * `*_s2s` device posture rules.
127 *
128 * Cloudflare validates the configured credentials against the live
129 * provider API at create/update time, so a reachable third-party tenant
130 * is required.
131 * ### Creating a posture integration
132 * **Example:** CrowdStrike Falcon
133 * ```typescript
134 * const falcon = yield* Cloudflare.Devices.DevicePostureIntegration("Falcon", {
135 * type: "crowdstrike_s2s",
136 * interval: "10m",
137 * config: {
138 * apiUrl: "https://api.crowdstrike.com",
139 * clientId: Alchemy.env("CROWDSTRIKE_CLIENT_ID"),
140 * clientSecret: Redacted.make(process.env.CROWDSTRIKE_SECRET!),
141 * customerId: "ccid-1234",
142 * },
143 * });
144 * ```
145 *
146 * **Example:** Custom service-to-service provider behind Access
147 * ```typescript
148 * const custom = yield* Cloudflare.Devices.DevicePostureIntegration("Custom", {
149 * type: "custom_s2s",
150 * interval: "30m",
151 * config: {
152 * apiUrl: "https://posture.example.com/check",
153 * clientSecret: Redacted.make(process.env.POSTURE_SECRET!),
154 * accessClientId: serviceToken.clientId,
155 * accessClientSecret: serviceToken.clientSecret,
156 * },
157 * });
158 * ```
159 *
160 * **Example:** Reference the integration from a posture rule
161 * ```typescript
162 * yield* Cloudflare.Devices.DevicePostureRule("FalconScore", {
163 * type: "crowdstrike_s2s",
164 * input: { connectionId: falcon.integrationId, os: "windows" },
165 * });
166 * ```
167 *
168 * @see https://developers.cloudflare.com/cloudflare-one/identity/devices/service-providers/
169 *
170 * @resource
171 * @product Devices
172 * @category Cloudflare One (Zero Trust)
173 */
174export const DevicePostureIntegration =
175 Resource<DevicePostureIntegration>(TypeId);
176
177/**
178 * Returns true if the given value is a DevicePostureIntegration resource.
179 */
180export const isDevicePostureIntegration = (
181 value: unknown,
182): value is DevicePostureIntegration =>
183 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
184
185export const DevicePostureIntegrationProvider = () =>
186 Provider.succeed(DevicePostureIntegration, {
187 stables: ["integrationId", "accountId", "type"],
188
189 // Account collection — enumerate every posture integration in the
190 // ambient account, exhaustively paginating the distilled list op.
191 // Zero Trust is plan-gated; a `Forbidden` rejection means the account
192 // lacks the entitlement, so treat it as "nothing to list".
193 list: Effect.fn(function* () {
194 const { accountId } = yield* yield* CloudflareEnvironment;
195 return yield* zeroTrust.listDevicePostureIntegrations
196 .pages({ accountId })
197 .pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk).flatMap((page) =>
201 (page.result ?? []).map((i) => toAttributes(i, accountId)),
202 ),
203 ),
204 Effect.catchTag("Forbidden", () => Effect.succeed([])),
205 );
206 }),
207
208 diff: Effect.fn(function* ({ olds, news, output }) {
209 if (!isResolved(news)) return undefined;
210 // The provider type is immutable on Cloudflare's side — the config
211 // shapes are disjoint, so model a type change as replacement.
212 const oldType = output?.type ?? olds?.type;
213 if (oldType !== undefined && oldType !== news.type) {
214 return { action: "replace" } as const;
215 }
216 return undefined;
217 }),
218
219 read: Effect.fn(function* ({ id, output, olds }) {
220 const { accountId } = yield* yield* CloudflareEnvironment;
221 const acct = output?.accountId ?? accountId;
222
223 if (output?.integrationId) {
224 const observed = yield* observeIntegration(acct, output.integrationId);
225 return observed ? toAttributes(observed, acct) : undefined;
226 }
227
228 // Cold lookup by deterministic name. The list result carries no
229 // ownership markers, so brand the match `Unowned` and let the
230 // engine gate takeover behind the adopt policy.
231 const name = yield* createIntegrationName(id, olds?.name);
232 const match = yield* findByName(acct, name);
233 if (match) return Unowned(toAttributes(match, acct));
234 return undefined;
235 }),
236
237 reconcile: Effect.fn(function* ({ id, news, output }) {
238 const { accountId } = yield* yield* CloudflareEnvironment;
239 const name = yield* createIntegrationName(id, news.name);
240
241 // 1. Observe — `output.integrationId` is a cached hint; fall back to
242 // a name scan so a crashed prior run converges.
243 let observed = output?.integrationId
244 ? yield* observeIntegration(accountId, output.integrationId)
245 : undefined;
246 if (!observed) {
247 observed = yield* findByName(accountId, name);
248 }
249
250 // 2. Ensure — create when missing.
251 if (!observed) {
252 const created = yield* zeroTrust.createDevicePostureIntegration({
253 accountId,
254 name,
255 type: news.type,
256 interval: news.interval,
257 config: encodeConfig(news.config),
258 });
259 return toAttributes(created, accountId);
260 }
261
262 // 3. Sync — secrets are masked on reads so they cannot be diffed
263 // against observed state; PATCH when any observable field drifts
264 // OR when the desired config carries secrets that may have
265 // rotated. Cloudflare re-validates credentials on PATCH, so only
266 // send config when the non-secret surface differs.
267 const sameObservable =
268 (observed.name ?? "") === name &&
269 (observed.interval ?? "") === news.interval &&
270 sameObservedConfig(observed.config, news.config);
271 if (sameObservable) {
272 return toAttributes(observed, accountId);
273 }
274 const updated = yield* zeroTrust.patchDevicePostureIntegration({
275 accountId,
276 integrationId: observed.id!,
277 name,
278 type: news.type,
279 interval: news.interval,
280 config: encodeConfig(news.config),
281 });
282 return toAttributes(updated, accountId);
283 }),
284
285 delete: Effect.fn(function* ({ output }) {
286 yield* zeroTrust
287 .deleteDevicePostureIntegration({
288 accountId: output.accountId,
289 integrationId: output.integrationId,
290 })
291 .pipe(
292 Effect.catchTag(
293 "DevicePostureIntegrationNotFound",
294 () => Effect.void,
295 ),
296 );
297 }),
298 });
299
300/**
301 * Structural shape shared by get/list/create/patch responses.
302 */
303type ObservedIntegration = {
304 id?: string | null;
305 config?: { apiUrl: string; authUrl: string; clientId: string } | null;
306 interval?: string | null;
307 name?: string | null;
308 type?: (string & {}) | null;
309};
310
311/**
312 * Read an integration by id, mapping "gone" to `undefined`.
313 */
314const observeIntegration = (accountId: string, integrationId: string) =>
315 zeroTrust
316 .getDevicePostureIntegration({ accountId, integrationId })
317 .pipe(
318 Effect.catchTag("DevicePostureIntegrationNotFound", () =>
319 Effect.succeed(undefined),
320 ),
321 );
322
323/**
324 * Find an integration by exact name.
325 */
326const findByName = (accountId: string, name: string) =>
327 zeroTrust
328 .listDevicePostureIntegrations({ accountId })
329 .pipe(
330 Effect.map((list) =>
331 list.result.find((i) => i.name === name && i.id != null),
332 ),
333 );
334
335const createIntegrationName = (id: string, name: string | undefined) =>
336 Effect.gen(function* () {
337 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
338 });
339
340type EncodedConfig = Parameters<
341 typeof zeroTrust.createDevicePostureIntegration
342>[0]["config"];
343
344/**
345 * Project the alchemy config (flat, secrets `Redacted`) onto the wire
346 * shape. The distilled request type is a union of per-provider structs;
347 * the API discriminates by the sibling `type` field, so a single
348 * present-fields projection is sufficient. The localized cast bridges the
349 * flat shape to the union — it never touches error handling.
350 */
351const encodeConfig = (
352 config: DevicePostureIntegrationConfig,
353): EncodedConfig => {
354 const out: Record<string, string> = {};
355 if (config.apiUrl !== undefined) out.apiUrl = config.apiUrl;
356 if (config.authUrl !== undefined) out.authUrl = config.authUrl;
357 if (config.clientId !== undefined) out.clientId = config.clientId;
358 if (config.clientKey !== undefined) out.clientKey = config.clientKey;
359 if (config.customerId !== undefined) out.customerId = config.customerId;
360 if (config.clientSecret !== undefined) {
361 out.clientSecret = Redacted.value(config.clientSecret);
362 }
363 if (config.accessClientId !== undefined) {
364 out.accessClientId = config.accessClientId;
365 }
366 if (config.accessClientSecret !== undefined) {
367 out.accessClientSecret = Redacted.value(config.accessClientSecret);
368 }
369 return out as EncodedConfig;
370};
371
372/**
373 * Compare only the fields Cloudflare echoes back (secrets are masked).
374 */
375const sameObservedConfig = (
376 observed: ObservedIntegration["config"],
377 desired: DevicePostureIntegrationConfig,
378): boolean =>
379 observed != null &&
380 (desired.apiUrl === undefined || observed.apiUrl === desired.apiUrl) &&
381 (desired.authUrl === undefined || observed.authUrl === desired.authUrl) &&
382 (desired.clientId === undefined || observed.clientId === desired.clientId);
383
384const toAttributes = (
385 integration: ObservedIntegration,
386 accountId: string,
387): DevicePostureIntegrationAttributes => ({
388 integrationId: integration.id ?? "",
389 accountId,
390 name: integration.name ?? "",
391 type: (integration.type ?? "custom_s2s") as DevicePostureIntegrationType,
392 interval: integration.interval ?? "",
393 config: {
394 apiUrl: integration.config?.apiUrl ?? undefined,
395 authUrl: integration.config?.authUrl ?? undefined,
396 clientId: integration.config?.clientId ?? undefined,
397 },
398});