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 { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Dlp.Profile" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * A detection entry defined inline on the profile — a regular expression
17 * the DLP engine scans for.
18 */
19export interface ProfileEntry {
20 /** Name of the entry. Unique within the profile. */
21 name: string;
22 /** Whether the entry participates in scans. */
23 enabled: boolean;
24 /** The detection pattern. */
25 pattern: {
26 /** The regular expression to match. */
27 regex: string;
28 /** Optional checksum validation applied to matches. */
29 validation?: "luhn";
30 };
31 /** Optional description of the entry. */
32 description?: string;
33}
34
35export interface ProfileProps {
36 /**
37 * Name of the profile. If omitted, a unique name is generated from the
38 * app, stage, and logical ID.
39 * @default ${app}-${stage}-${id}
40 */
41 name?: string;
42 /**
43 * The description of the profile.
44 */
45 description?: string;
46 /**
47 * Related DLP policies will trigger when the match count exceeds this
48 * number.
49 * @default 0
50 */
51 allowedMatchCount?: number;
52 /**
53 * Whether to scan images via OCR.
54 */
55 ocrEnabled?: boolean;
56 /**
57 * Confidence threshold applied to AI-context detections
58 * (`low` | `medium` | `high` | `very_high`).
59 */
60 confidenceThreshold?: string;
61 /**
62 * Custom detection entries owned by this profile. Synced declaratively:
63 * entries are matched to observed entries by name; entries removed from
64 * this list are deleted from the profile.
65 */
66 entries?: ProfileEntry[];
67}
68
69export type ProfileAttributes = {
70 /** API UUID of the profile. */
71 profileId: string;
72 /** Account that owns the profile. */
73 accountId: string;
74 /** Observed profile name. */
75 name: string;
76 /** Observed description, if any. */
77 description: string | undefined;
78 /** Observed allowed match count. */
79 allowedMatchCount: number;
80 /** Whether OCR scanning is enabled. */
81 ocrEnabled: boolean;
82 /** Ids of the custom entries owned by the profile, keyed by name. */
83 entryIds: Record<string, string>;
84};
85
86export type Profile = Resource<
87 TypeId,
88 ProfileProps,
89 ProfileAttributes,
90 never,
91 Providers
92>;
93
94/**
95 * A Cloudflare Zero Trust **DLP custom profile** — a named collection of
96 * detection entries (regular expressions) that Gateway HTTP policies and
97 * CASB integrations reference to detect sensitive data in transit.
98 *
99 * Requires the Cloudflare DLP entitlement (a paid Zero Trust add-on);
100 * accounts without it receive the typed `Forbidden` error on all writes.
101 * ### Creating a DLP profile
102 * **Example:** Profile with a custom regex entry
103 * ```typescript
104 * const profile = yield* Cloudflare.Dlp.Profile("EmployeeIds", {
105 * description: "Detects internal employee identifiers",
106 * allowedMatchCount: 0,
107 * entries: [
108 * {
109 * name: "employee-id",
110 * enabled: true,
111 * pattern: { regex: "EMP-[0-9]{6}" },
112 * },
113 * ],
114 * });
115 * ```
116 *
117 * **Example:** Credit-card-like entry with Luhn validation
118 * ```typescript
119 * const cards = yield* Cloudflare.Dlp.Profile("Cards", {
120 * entries: [
121 * {
122 * name: "card-number",
123 * enabled: true,
124 * pattern: { regex: "[0-9]{13,16}", validation: "luhn" },
125 * },
126 * ],
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/cloudflare-one/policies/data-loss-prevention/dlp-profiles/
131 *
132 * @resource
133 * @product DLP
134 * @category Cloudflare One (Zero Trust)
135 */
136export const Profile = Resource<Profile>(TypeId);
137
138/**
139 * Returns true if the given value is a Profile resource.
140 */
141export const isProfile = (value: unknown): value is Profile =>
142 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
143
144export const ProfileProvider = () =>
145 Provider.succeed(Profile, {
146 stables: ["profileId", "accountId"],
147
148 read: Effect.fn(function* ({ output }) {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150 const acct = output?.accountId ?? accountId;
151
152 // DLP profiles are only refreshed by id; without a persisted id
153 // there is nothing safe to adopt (names are not guaranteed unique
154 // and the entitlement is account-gated).
155 if (!output?.profileId) return undefined;
156 const observed = yield* observeProfile(acct, output.profileId);
157 return observed ? toAttributes(observed, acct) : undefined;
158 }),
159
160 reconcile: Effect.fn(function* ({ id, news, output }) {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 const name = yield* createProfileName(id, news.name);
163
164 // 1. Observe.
165 const observed = output?.profileId
166 ? yield* observeProfile(accountId, output.profileId)
167 : undefined;
168
169 // 2. Ensure — create with the full desired state when missing.
170 if (!observed) {
171 const created = yield* zeroTrust.createDlpProfileCustom({
172 accountId,
173 name,
174 ...(news.description !== undefined
175 ? { description: news.description }
176 : {}),
177 ...(news.allowedMatchCount !== undefined
178 ? { allowedMatchCount: news.allowedMatchCount }
179 : {}),
180 ...(news.ocrEnabled !== undefined
181 ? { ocrEnabled: news.ocrEnabled }
182 : {}),
183 ...(news.confidenceThreshold !== undefined
184 ? { confidenceThreshold: news.confidenceThreshold }
185 : {}),
186 ...(news.entries !== undefined
187 ? { entries: news.entries.map(encodeNewEntry) }
188 : {}),
189 });
190 const createdCustom = narrowCustom(created);
191 return toAttributes(createdCustom, accountId);
192 }
193
194 // 3. Sync — PUT the full desired state when the observed state
195 // differs. Observed entries are matched to desired entries by
196 // name so existing entry ids are preserved.
197 const dirty =
198 observed.name !== name ||
199 (observed.description ?? undefined) !== news.description ||
200 observed.allowedMatchCount !== (news.allowedMatchCount ?? 0) ||
201 observed.ocrEnabled !== (news.ocrEnabled ?? false) ||
202 (news.confidenceThreshold !== undefined &&
203 (observed.confidenceThreshold ?? undefined) !==
204 news.confidenceThreshold) ||
205 !sameEntries(observed, news.entries);
206 if (!dirty) {
207 return toAttributes(observed, accountId);
208 }
209 const observedIds = entryIdsByName(observed);
210 const updated = yield* zeroTrust.updateDlpProfileCustom({
211 accountId,
212 profileId: observed.id,
213 name,
214 description: news.description ?? null,
215 allowedMatchCount: news.allowedMatchCount ?? 0,
216 ocrEnabled: news.ocrEnabled ?? false,
217 ...(news.confidenceThreshold !== undefined
218 ? { confidenceThreshold: news.confidenceThreshold }
219 : {}),
220 ...(news.entries !== undefined
221 ? {
222 entries: news.entries.map((entry) => {
223 const entryId = observedIds[entry.name];
224 return entryId !== undefined
225 ? { ...encodeNewEntry(entry), entryId }
226 : encodeNewEntry(entry);
227 }),
228 }
229 : {}),
230 });
231 return toAttributes(narrowCustom(updated), accountId);
232 }),
233
234 delete: Effect.fn(function* ({ output }) {
235 yield* zeroTrust
236 .deleteDlpProfileCustom({
237 accountId: output.accountId,
238 profileId: output.profileId,
239 })
240 .pipe(Effect.catchTag("DlpProfileNotFound", () => Effect.void));
241 }),
242
243 // Account-scoped collection (pattern b): enumerate every custom DLP
244 // profile via the account profiles list, then hydrate each by id into
245 // the exact `read` Attributes shape so list items are delete-ready.
246 // The list endpoint also returns predefined / integration profiles —
247 // only `custom` profiles are modelled by this resource, so the rest
248 // are filtered out.
249 list: Effect.fn(function* () {
250 const { accountId } = yield* yield* CloudflareEnvironment;
251
252 const profileIds = yield* zeroTrust.listDlpProfiles
253 .pages({ accountId })
254 .pipe(
255 Stream.runCollect,
256 Effect.map((chunk) =>
257 Array.from(chunk).flatMap((page) =>
258 (page.result ?? []).flatMap((profile) =>
259 profile.type === "custom" ? [profile.id] : [],
260 ),
261 ),
262 ),
263 );
264
265 const rows = yield* Effect.forEach(
266 profileIds,
267 (profileId) =>
268 observeProfile(accountId, profileId).pipe(
269 Effect.map((observed) =>
270 observed ? toAttributes(observed, accountId) : undefined,
271 ),
272 ),
273 { concurrency: 10 },
274 );
275
276 return rows.filter((row): row is ProfileAttributes => row !== undefined);
277 }),
278 });
279
280/**
281 * The custom-profile member of the DLP profile response union.
282 */
283type ObservedCustomProfile = Extract<
284 zeroTrust.GetDlpProfileCustomResponse,
285 { type: "custom" }
286>;
287
288/**
289 * Narrow a profile response union to its `custom` member. Create/update
290 * on the custom endpoint always return a custom profile; a different
291 * shape would be a Cloudflare contract break, so fall back to a minimal
292 * empty view rather than crash the engine.
293 */
294const narrowCustom = (
295 profile: zeroTrust.GetDlpProfileCustomResponse,
296): ObservedCustomProfile | undefined =>
297 "type" in profile && profile.type === "custom" ? profile : undefined;
298
299/**
300 * Read a profile by id, mapping "gone" to `undefined` and narrowing to
301 * the custom variant.
302 */
303const observeProfile = (accountId: string, profileId: string) =>
304 zeroTrust.getDlpProfileCustom({ accountId, profileId }).pipe(
305 Effect.map(narrowCustom),
306 Effect.catchTag("DlpProfileNotFound", () => Effect.succeed(undefined)),
307 );
308
309const createProfileName = (id: string, name: string | undefined) =>
310 Effect.gen(function* () {
311 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
312 });
313
314const encodeNewEntry = (
315 entry: ProfileEntry,
316): {
317 enabled: boolean;
318 name: string;
319 pattern: { regex: string; validation?: "luhn" };
320 description?: string | null;
321} => ({
322 enabled: entry.enabled,
323 name: entry.name,
324 pattern: {
325 regex: entry.pattern.regex,
326 ...(entry.pattern.validation !== undefined
327 ? { validation: entry.pattern.validation }
328 : {}),
329 },
330 ...(entry.description !== undefined
331 ? { description: entry.description }
332 : {}),
333});
334
335/**
336 * Custom regex entries observed on the profile, keyed by name.
337 */
338const customEntries = (profile: ObservedCustomProfile | undefined) =>
339 (profile?.entries ?? []).flatMap((entry) =>
340 entry.type === "custom" ? [entry] : [],
341 );
342
343const entryIdsByName = (
344 profile: ObservedCustomProfile | undefined,
345): Record<string, string> =>
346 Object.fromEntries(customEntries(profile).map((e) => [e.name, e.id]));
347
348const sameEntries = (
349 observed: ObservedCustomProfile,
350 desired: ProfileEntry[] | undefined,
351): boolean => {
352 if (desired === undefined) return true;
353 const observedByName = new Map(
354 customEntries(observed).map((e) => [e.name, e]),
355 );
356 if (observedByName.size !== desired.length) return false;
357 return desired.every((entry) => {
358 const live = observedByName.get(entry.name);
359 return (
360 live !== undefined &&
361 live.enabled === entry.enabled &&
362 live.pattern.regex === entry.pattern.regex &&
363 (live.pattern.validation ?? undefined) === entry.pattern.validation
364 );
365 });
366};
367
368const toAttributes = (
369 profile: ObservedCustomProfile | undefined,
370 accountId: string,
371): ProfileAttributes => ({
372 profileId: profile?.id ?? "",
373 accountId,
374 name: profile?.name ?? "",
375 description: profile?.description ?? undefined,
376 allowedMatchCount: profile?.allowedMatchCount ?? 0,
377 ocrEnabled: profile?.ocrEnabled ?? false,
378 entryIds: entryIdsByName(profile),
379});