1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
7import type { Providers } from "../Providers.ts";
8
9export type OrganizationProps = {
10 /**
11 * The unique subdomain assigned to your Zero Trust organization, e.g.
12 * `acme.cloudflareaccess.com`. Per-account this is functionally immutable —
13 * Cloudflare allocates exactly one team domain when the account first
14 * enables Zero Trust and changing it is a manual support operation.
15 *
16 * @see https://developers.cloudflare.com/cloudflare-one/setup/#1-create-a-team-name
17 */
18 authDomain: string;
19 /**
20 * Human-readable display name of your Zero Trust organization. Usually the
21 * same as `authDomain`. Mutable.
22 *
23 * @see https://developers.cloudflare.com/api/operations/zero-trust-organization-update-your-zero-trust-organization
24 */
25 name?: string;
26 /**
27 * Default session lifetime for Access applications. Must be a Go duration
28 * string (e.g. `30m`, `2h45m`, `24h`). Valid units: `ns`, `us`, `ms`, `s`,
29 * `m`, `h`. Per-application settings override this default.
30 *
31 * @see https://developers.cloudflare.com/cloudflare-one/identity/users/session-management/
32 */
33 sessionDuration?: string;
34 /**
35 * When `true`, users may authenticate to Access applications via the WARP
36 * client without going through the configured identity providers. Per-app
37 * settings take precedence.
38 *
39 * @default false
40 * @see https://developers.cloudflare.com/cloudflare-one/identity/devices/warp-authentication-identity/
41 */
42 allowAuthenticateViaWarp?: boolean;
43 /**
44 * When `true`, all Zero Trust settings in the Cloudflare dashboard are
45 * read-only and may only be modified via the API or Terraform.
46 *
47 * @default false
48 */
49 isUiReadOnly?: boolean;
50 /**
51 * When `true`, users skip the identity provider selection step on login.
52 * Only valid when exactly one IdP is configured (or one is set as default).
53 *
54 * @default false
55 */
56 autoRedirectToIdentity?: boolean;
57 /**
58 * Free-form description of why `isUiReadOnly` is toggled. Surfaced in the
59 * Cloudflare dashboard.
60 */
61 uiReadOnlyToggleReason?: string;
62 /**
63 * Duration of user-seat inactivity before the user is removed as an active
64 * seat and stops counting against the Teams seat quota. Go duration string.
65 */
66 userSeatExpirationInactiveTime?: string;
67 /**
68 * Lifetime of tokens issued by the WARP authentication flow. Go duration
69 * string limited to `m` and `h` units, e.g. `30m` or `2h45m`.
70 */
71 warpAuthSessionDuration?: string;
72 /**
73 * Branding for the Access login screen.
74 *
75 * @see https://developers.cloudflare.com/cloudflare-one/identity/users/login-page/
76 */
77 loginDesign?: Organization.LoginDesign;
78 /**
79 * Pointers to custom HTML pages shown when Access blocks a request.
80 *
81 * @see https://developers.cloudflare.com/cloudflare-one/policies/access/custom-pages/
82 */
83 customPages?: Organization.CustomPages;
84};
85
86export declare namespace Organization {
87 /**
88 * Branding for the Access login screen.
89 */
90 export interface LoginDesign {
91 /** URL of the logo image rendered at the top of the login form. */
92 logoPath?: string;
93 /** CSS color for the header background, e.g. `#1a1a1a`. */
94 headerBgColor?: string;
95 /** CSS color for the page background. */
96 backgroundColor?: string;
97 /** CSS color for the body text. */
98 textColor?: string;
99 /** Markdown rendered at the top of the login form (legacy). */
100 headerText?: string;
101 /** Markdown rendered at the bottom of the login form. */
102 footerText?: string;
103 }
104 /**
105 * Pointers to custom HTML pages shown when Access denies a request.
106 */
107 export interface CustomPages {
108 /**
109 * UUID of a custom forbidden page (created via the Access Custom Pages
110 * API) shown when a policy denies access.
111 */
112 forbidden?: string;
113 /**
114 * UUID of a custom identity-denied page shown when the identity provider
115 * rejects the user.
116 */
117 identityDenied?: string;
118 }
119}
120
121export type Organization = Resource<
122 "Cloudflare.Access.Organization",
123 OrganizationProps,
124 {
125 /** Cloudflare account that owns the Zero Trust organization. */
126 accountId: string;
127 /** The Zero Trust team domain, e.g. `acme.cloudflareaccess.com`. */
128 authDomain: string;
129 /** Display name of the organization. */
130 name: string;
131 /** Default Access application session duration. */
132 sessionDuration: string | undefined;
133 /** WARP-as-IdP toggle observed on Cloudflare. */
134 allowAuthenticateViaWarp: boolean | undefined;
135 /** Dashboard read-only lock observed on Cloudflare. */
136 isUiReadOnly: boolean | undefined;
137 /** Skip-IdP-picker toggle observed on Cloudflare. */
138 autoRedirectToIdentity: boolean | undefined;
139 /** Free-form note explaining the `isUiReadOnly` setting. */
140 uiReadOnlyToggleReason: string | undefined;
141 /** User-seat inactivity expiration observed on Cloudflare. */
142 userSeatExpirationInactiveTime: string | undefined;
143 /** WARP authentication session duration observed on Cloudflare. */
144 warpAuthSessionDuration: string | undefined;
145 /** Login-page branding observed on Cloudflare. */
146 loginDesign: Organization.LoginDesign | undefined;
147 /** Custom-pages pointers observed on Cloudflare. */
148 customPages: Organization.CustomPages | undefined;
149 },
150 never,
151 Providers
152>;
153
154/**
155 * Account-level Cloudflare Zero Trust organization settings — the team
156 * domain, login branding, session lifetimes, WARP authentication toggle, etc.
157 *
158 * Wraps `PUT /accounts/{account_id}/access/organizations`.
159 * @remarks
160 * **This resource is a singleton.** Every Cloudflare account owns exactly one
161 * Access Organization; you cannot create a second one and you cannot delete
162 * the existing one without deleting the entire Cloudflare account. As a
163 * result:
164 *
165 * - The `reconcile` lifecycle always **adopts** the existing organization on
166 * first deploy rather than failing on conflict.
167 * - The `delete` lifecycle is a **no-op** that logs a warning. Removing the
168 * resource from your stack leaves the Cloudflare-side settings untouched.
169 *
170 * ### Configuring the organization
171 * **Example:** Adopt and brand the organization
172 * ```typescript
173 * const org = yield* Cloudflare.Access.Organization("Org", {
174 * authDomain: "acme.cloudflareaccess.com",
175 * name: "Acme",
176 * sessionDuration: "24h",
177 * allowAuthenticateViaWarp: true,
178 * loginDesign: {
179 * logoPath: "https://acme.example/logo.png",
180 * backgroundColor: "#111111",
181 * textColor: "#ffffff",
182 * },
183 * });
184 * ```
185 *
186 * @resource
187 * @product Access
188 * @category Cloudflare One (Zero Trust)
189 */
190export const Organization = Resource<Organization>(
191 "Cloudflare.Access.Organization",
192 { aliases: ["Cloudflare.AccessOrganization"] },
193);
194
195export const OrganizationProvider = () =>
196 Provider.succeed(Organization, {
197 nuke: { singleton: true },
198 stables: ["accountId", "authDomain"],
199 reconcile: Effect.fn(function* ({ news }) {
200 const { accountId } = yield* yield* CloudflareEnvironment;
201
202 const desiredName = news.name ?? news.authDomain;
203 const loginDesign = buildLoginDesign(news.loginDesign);
204 const customPages = news.customPages
205 ? {
206 ...(news.customPages.forbidden !== undefined
207 ? { forbidden: news.customPages.forbidden }
208 : {}),
209 ...(news.customPages.identityDenied !== undefined
210 ? { identityDenied: news.customPages.identityDenied }
211 : {}),
212 }
213 : undefined;
214
215 // Observe — singleton lookup. The org always exists for any
216 // account that has enabled Zero Trust; only a brand-new account
217 // returns "missing".
218 let observed = yield* observe();
219
220 // Ensure — create on a fresh account. If a race or out-of-band
221 // setup created it between the observe and create call, fall
222 // back to update.
223 if (!observed) {
224 observed = yield* zeroTrust
225 .createOrganizationForAccount({
226 accountId,
227 authDomain: news.authDomain,
228 name: desiredName,
229 ...(news.sessionDuration !== undefined
230 ? { sessionDuration: news.sessionDuration }
231 : {}),
232 ...(news.allowAuthenticateViaWarp !== undefined
233 ? { allowAuthenticateViaWarp: news.allowAuthenticateViaWarp }
234 : {}),
235 ...(news.isUiReadOnly !== undefined
236 ? { isUiReadOnly: news.isUiReadOnly }
237 : {}),
238 ...(news.autoRedirectToIdentity !== undefined
239 ? { autoRedirectToIdentity: news.autoRedirectToIdentity }
240 : {}),
241 ...(news.uiReadOnlyToggleReason !== undefined
242 ? { uiReadOnlyToggleReason: news.uiReadOnlyToggleReason }
243 : {}),
244 ...(news.userSeatExpirationInactiveTime !== undefined
245 ? {
246 userSeatExpirationInactiveTime:
247 news.userSeatExpirationInactiveTime,
248 }
249 : {}),
250 ...(news.warpAuthSessionDuration !== undefined
251 ? { warpAuthSessionDuration: news.warpAuthSessionDuration }
252 : {}),
253 ...(loginDesign ? { loginDesign } : {}),
254 })
255 .pipe(
256 Effect.catchTag("OrganizationAlreadyExists", () =>
257 Effect.gen(function* () {
258 const existing = yield* observe();
259 if (existing) return existing;
260 return yield* Effect.fail(
261 new Error(
262 "Cloudflare returned OrganizationAlreadyExists on createOrganizationForAccount but the org could not be observed afterwards",
263 ),
264 );
265 }),
266 ),
267 );
268 }
269
270 // Sync — Cloudflare's PUT is a true upsert. Always push so any
271 // drift in observed vs desired converges in one call. Cheap and
272 // idempotent.
273 const updated = yield* zeroTrust.updateOrganizationForAccount({
274 accountId,
275 authDomain: news.authDomain,
276 name: desiredName,
277 ...(news.sessionDuration !== undefined
278 ? { sessionDuration: news.sessionDuration }
279 : {}),
280 ...(news.allowAuthenticateViaWarp !== undefined
281 ? { allowAuthenticateViaWarp: news.allowAuthenticateViaWarp }
282 : {}),
283 ...(news.isUiReadOnly !== undefined
284 ? { isUiReadOnly: news.isUiReadOnly }
285 : {}),
286 ...(news.autoRedirectToIdentity !== undefined
287 ? { autoRedirectToIdentity: news.autoRedirectToIdentity }
288 : {}),
289 ...(news.uiReadOnlyToggleReason !== undefined
290 ? { uiReadOnlyToggleReason: news.uiReadOnlyToggleReason }
291 : {}),
292 ...(news.userSeatExpirationInactiveTime !== undefined
293 ? {
294 userSeatExpirationInactiveTime:
295 news.userSeatExpirationInactiveTime,
296 }
297 : {}),
298 ...(news.warpAuthSessionDuration !== undefined
299 ? { warpAuthSessionDuration: news.warpAuthSessionDuration }
300 : {}),
301 ...(loginDesign ? { loginDesign } : {}),
302 ...(customPages ? { customPages } : {}),
303 });
304
305 return toAttrs(accountId, updated, news.authDomain, desiredName);
306 }),
307 delete: Effect.fn(function* () {
308 yield* Effect.logWarning(
309 "Organization.delete is a no-op — the Cloudflare Access Organization is a singleton tied to the account and cannot be deleted without deleting the Cloudflare account itself.",
310 );
311 }),
312 read: Effect.fn(function* ({ olds }) {
313 const { accountId } = yield* yield* CloudflareEnvironment;
314
315 const observed = yield* observe();
316 if (!observed) return undefined;
317 return toAttrs(
318 accountId,
319 observed,
320 olds?.authDomain ?? observed.authDomain ?? "",
321 olds?.name ?? observed.name ?? olds?.authDomain ?? "",
322 );
323 }),
324 // Account singleton: every Cloudflare account owns exactly one Access
325 // Organization and there is no enumeration API. Read the single org via
326 // the same `observe` path `read` uses and return the one-element array
327 // (or `[]` when the account has never enabled Zero Trust). `observe`
328 // already swallows the typed `OrganizationNotFound` error.
329 list: Effect.fn(function* () {
330 const { accountId } = yield* yield* CloudflareEnvironment;
331
332 const observed = yield* observe();
333 if (!observed) return [];
334 return [
335 toAttrs(
336 accountId,
337 observed,
338 observed.authDomain ?? "",
339 observed.name ?? observed.authDomain ?? "",
340 ),
341 ];
342 }),
343 });
344
345const observe = Effect.fn(function* () {
346 const { accountId } = yield* yield* CloudflareEnvironment;
347
348 return yield* zeroTrust.listOrganizationsForAccount({ accountId }).pipe(
349 Effect.map((org) => {
350 // listOrganizationsForAccount returns a single object (the
351 // singleton org) under `result`; an account that has not yet
352 // enabled Zero Trust returns a sparse object with no
353 // `authDomain`. Treat that as "missing".
354 const typed = org;
355 return typed && typed.authDomain ? typed : undefined;
356 }),
357 Effect.catchTag("OrganizationNotFound", () =>
358 Effect.succeed<zeroTrust.ListOrganizationsResponse | undefined>(
359 undefined,
360 ),
361 ),
362 );
363});
364
365const toAttrs = (
366 accountId: string,
367 org: {
368 authDomain?: string | null;
369 name?: string | null;
370 sessionDuration?: string | null;
371 allowAuthenticateViaWarp?: boolean | null;
372 isUiReadOnly?: boolean | null;
373 autoRedirectToIdentity?: boolean | null;
374 uiReadOnlyToggleReason?: string | null;
375 userSeatExpirationInactiveTime?: string | null;
376 warpAuthSessionDuration?: string | null;
377 loginDesign?: {
378 backgroundColor?: string | null;
379 footerText?: string | null;
380 headerText?: string | null;
381 logoPath?: string | null;
382 textColor?: string | null;
383 } | null;
384 customPages?: {
385 forbidden?: string | null;
386 identityDenied?: string | null;
387 } | null;
388 },
389 fallbackAuthDomain: string,
390 fallbackName: string,
391) => ({
392 accountId,
393 authDomain: org.authDomain ?? fallbackAuthDomain,
394 name: org.name ?? fallbackName,
395 sessionDuration: org.sessionDuration ?? undefined,
396 allowAuthenticateViaWarp: org.allowAuthenticateViaWarp ?? undefined,
397 isUiReadOnly: org.isUiReadOnly ?? undefined,
398 autoRedirectToIdentity: org.autoRedirectToIdentity ?? undefined,
399 uiReadOnlyToggleReason: org.uiReadOnlyToggleReason ?? undefined,
400 userSeatExpirationInactiveTime:
401 org.userSeatExpirationInactiveTime ?? undefined,
402 warpAuthSessionDuration: org.warpAuthSessionDuration ?? undefined,
403 loginDesign: observedLoginDesign(org.loginDesign),
404 customPages: observedCustomPages(org.customPages),
405});
406
407const buildLoginDesign = (
408 design: Organization.LoginDesign | undefined,
409):
410 | {
411 backgroundColor?: string;
412 footerText?: string;
413 headerText?: string;
414 logoPath?: string;
415 textColor?: string;
416 }
417 | undefined => {
418 if (!design) return undefined;
419 const out: {
420 backgroundColor?: string;
421 footerText?: string;
422 headerText?: string;
423 logoPath?: string;
424 textColor?: string;
425 } = {};
426 if (design.backgroundColor !== undefined)
427 out.backgroundColor = design.backgroundColor;
428 if (design.footerText !== undefined) out.footerText = design.footerText;
429 if (design.headerText !== undefined) out.headerText = design.headerText;
430 if (design.logoPath !== undefined) out.logoPath = design.logoPath;
431 if (design.textColor !== undefined) out.textColor = design.textColor;
432 return out;
433};
434
435const observedLoginDesign = (
436 design:
437 | {
438 backgroundColor?: string | null;
439 footerText?: string | null;
440 headerText?: string | null;
441 logoPath?: string | null;
442 textColor?: string | null;
443 }
444 | null
445 | undefined,
446): Organization.LoginDesign | undefined => {
447 if (!design) return undefined;
448 return {
449 backgroundColor: design.backgroundColor ?? undefined,
450 footerText: design.footerText ?? undefined,
451 headerText: design.headerText ?? undefined,
452 logoPath: design.logoPath ?? undefined,
453 textColor: design.textColor ?? undefined,
454 };
455};
456
457const observedCustomPages = (
458 pages:
459 | { forbidden?: string | null; identityDenied?: string | null }
460 | null
461 | undefined,
462): Organization.CustomPages | undefined => {
463 if (!pages) return undefined;
464 return {
465 forbidden: pages.forbidden ?? undefined,
466 identityDenied: pages.identityDenied ?? undefined,
467 };
468};