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 * 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.RiskScoring.Integration" as const;
13type TypeId = typeof TypeId;
14
15export interface IntegrationProps {
16 /**
17 * The third-party SOAR/SSF consumer of risk-score changes. Only
18 * `Okta` is supported by the API today.
19 * @default "Okta"
20 */
21 integrationType?: "Okta";
22 /**
23 * The base URL of the tenant that receives risk-score changes, e.g.
24 * `https://tenant.okta.com`. Mutable — updated in place via PUT.
25 */
26 tenantUrl: string;
27 /**
28 * A reference id supplied by the client. Cloudflare recommends setting
29 * it to the Access-Okta identity provider ID (a UUIDv4) so the
30 * integration can be recalled by that secondary asset.
31 */
32 referenceId?: string;
33 /**
34 * Whether the integration exports risk-score changes to the
35 * third-party. Only togglable after create (the create API always
36 * provisions an active integration).
37 * @default true
38 */
39 active?: boolean;
40}
41
42export type IntegrationAttributes = {
43 /** API UUID of the integration. */
44 integrationId: string;
45 /** Account that owns the integration. */
46 accountId: string;
47 /** The third-party consumer of risk-score changes. */
48 integrationType: "Okta" | (string & {});
49 /** Observed tenant base URL. */
50 tenantUrl: string;
51 /** Observed client-supplied reference id. */
52 referenceId: string;
53 /** Whether risk-score changes are exported. */
54 active: boolean;
55 /** The Shared Signals Framework configuration URL. */
56 wellKnownUrl: string;
57 /** RFC 3339 timestamp of when the integration was created. */
58 createdAt: string;
59};
60
61export type Integration = Resource<
62 TypeId,
63 IntegrationProps,
64 IntegrationAttributes,
65 never,
66 Providers
67>;
68
69/**
70 * A Cloudflare Zero Trust **risk scoring integration** — a Shared
71 * Signals Framework (SSF) push integration that exports user risk-score
72 * changes to a third-party tenant (currently Okta) so the IdP can react
73 * to risky behavior detected by Zero Trust.
74 *
75 * Requires the Zero Trust risk-scoring entitlement (an Enterprise
76 * feature); accounts without it receive the typed `Forbidden` error on
77 * all writes.
78 * ### Creating a risk scoring integration
79 * **Example:** Push risk scores to an Okta tenant
80 * ```typescript
81 * const okta = yield* Cloudflare.RiskScoring.Integration("OktaSsf", {
82 * tenantUrl: "https://tenant.okta.com",
83 * referenceId: oktaIdp.identityProviderId,
84 * });
85 * ```
86 *
87 * **Example:** Pause exporting without deleting
88 * ```typescript
89 * const okta = yield* Cloudflare.RiskScoring.Integration("OktaSsf", {
90 * tenantUrl: "https://tenant.okta.com",
91 * active: false,
92 * });
93 * ```
94 *
95 * @see https://developers.cloudflare.com/cloudflare-one/insights/risk-score/
96 *
97 * @resource
98 * @product Risk Scoring
99 * @category Cloudflare One (Zero Trust)
100 */
101export const Integration = Resource<Integration>(TypeId);
102
103/**
104 * Returns true if the given value is a Integration resource.
105 */
106export const isIntegration = (value: unknown): value is Integration =>
107 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
108
109export const IntegrationProvider = () =>
110 Provider.succeed(Integration, {
111 stables: ["integrationId", "accountId", "integrationType", "createdAt"],
112
113 read: Effect.fn(function* ({ output, olds }) {
114 const { accountId } = yield* yield* CloudflareEnvironment;
115 const acct = output?.accountId ?? accountId;
116
117 if (output?.integrationId) {
118 const observed = yield* observeIntegration(acct, output.integrationId);
119 return observed ? toAttributes(observed, acct) : undefined;
120 }
121
122 // Cold lookup by tenant URL. Integrations carry no ownership
123 // markers — brand the match `Unowned`.
124 const tenantUrl = olds?.tenantUrl;
125 if (tenantUrl === undefined) return undefined;
126 const match = yield* findByTenantUrl(acct, tenantUrl);
127 if (match) return Unowned(toAttributes(match, acct));
128 return undefined;
129 }),
130
131 reconcile: Effect.fn(function* ({ news, output }) {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133
134 // 1. Observe — cached id is a hint; fall back to a tenant-URL scan
135 // so a crashed prior run converges.
136 let observed = output?.integrationId
137 ? yield* observeIntegration(accountId, output.integrationId)
138 : undefined;
139 if (!observed) {
140 observed = yield* findByTenantUrl(accountId, news.tenantUrl);
141 }
142
143 // 2. Ensure — create when missing. Create always provisions an
144 // active integration; the sync step below applies `active: false`.
145 if (!observed) {
146 observed = yield* zeroTrust.createRiskScoringIntegration({
147 accountId,
148 integrationType: news.integrationType ?? "Okta",
149 tenantUrl: news.tenantUrl,
150 ...(news.referenceId !== undefined
151 ? { referenceId: news.referenceId }
152 : {}),
153 });
154 }
155
156 // 3. Sync — PUT the full desired state when the observed state
157 // differs; skip the call on a no-op.
158 const desiredActive = news.active ?? true;
159 const dirty =
160 observed.tenantUrl !== news.tenantUrl ||
161 observed.active !== desiredActive ||
162 (news.referenceId !== undefined &&
163 observed.referenceId !== news.referenceId);
164 if (!dirty) {
165 return toAttributes(observed, accountId);
166 }
167 const updated = yield* zeroTrust.updateRiskScoringIntegration({
168 accountId,
169 integrationId: observed.id,
170 active: desiredActive,
171 tenantUrl: news.tenantUrl,
172 ...(news.referenceId !== undefined
173 ? { referenceId: news.referenceId }
174 : {}),
175 });
176 return toAttributes(updated, accountId);
177 }),
178
179 delete: Effect.fn(function* ({ output }) {
180 yield* zeroTrust
181 .deleteRiskScoringIntegration({
182 accountId: output.accountId,
183 integrationId: output.integrationId,
184 })
185 .pipe(
186 Effect.catchTag("RiskScoringIntegrationNotFound", () => Effect.void),
187 );
188 }),
189
190 // Account collection (pattern b). Enumerate every risk-scoring
191 // integration in the ambient account, exhaustively paginating the
192 // distilled list op. Accounts lacking the Zero Trust risk-scoring
193 // entitlement reject the route with a typed `Forbidden` — treat that
194 // as "nothing to list" and return [].
195 list: Effect.fn(function* () {
196 const { accountId } = yield* yield* CloudflareEnvironment;
197 return yield* zeroTrust.listRiskScoringIntegrations
198 .pages({ accountId })
199 .pipe(
200 Stream.runCollect,
201 Effect.map((chunk) =>
202 Array.from(chunk).flatMap((page) =>
203 (page.result ?? []).map((integration) =>
204 toAttributes(integration, accountId),
205 ),
206 ),
207 ),
208 Effect.catchTag("Forbidden", () => Effect.succeed([])),
209 );
210 }),
211 });
212
213/**
214 * Structural shape shared by get/list/create/update responses.
215 */
216type ObservedIntegration = {
217 id: string;
218 active: boolean;
219 createdAt: string;
220 integrationType: string;
221 referenceId: string;
222 tenantUrl: string;
223 wellKnownUrl: string;
224};
225
226/**
227 * Read an integration by id, mapping "gone" to `undefined`.
228 */
229const observeIntegration = (accountId: string, integrationId: string) =>
230 zeroTrust
231 .getRiskScoringIntegration({ accountId, integrationId })
232 .pipe(
233 Effect.catchTag("RiskScoringIntegrationNotFound", () =>
234 Effect.succeed(undefined),
235 ),
236 );
237
238/**
239 * Find an integration by exact tenant URL.
240 */
241const findByTenantUrl = (accountId: string, tenantUrl: string) =>
242 zeroTrust
243 .listRiskScoringIntegrations({ accountId })
244 .pipe(
245 Effect.map((list) => list.result.find((i) => i.tenantUrl === tenantUrl)),
246 );
247
248const toAttributes = (
249 integration: ObservedIntegration,
250 accountId: string,
251): IntegrationAttributes => ({
252 integrationId: integration.id,
253 accountId,
254 integrationType: integration.integrationType,
255 tenantUrl: integration.tenantUrl,
256 referenceId: integration.referenceId,
257 active: integration.active,
258 wellKnownUrl: integration.wellKnownUrl,
259 createdAt: integration.createdAt,
260});