| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.RiskScoring.Integration" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | export 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 | |
| 42 | export 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 | |
| 61 | export 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 | */ |
| 101 | export const Integration = Resource<Integration>(TypeId); |
| 102 | |
| 103 | /** |
| 104 | * Returns true if the given value is a Integration resource. |
| 105 | */ |
| 106 | export const isIntegration = (value: unknown): value is Integration => |
| 107 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 108 | |
| 109 | export 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 | */ |
| 216 | type 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 | */ |
| 229 | const 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 | */ |
| 241 | const 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 | |
| 248 | const 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 | }); |