| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; |
| 4 | import * as Predicate from "effect/Predicate"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | |
| 14 | /** |
| 15 | * One arm of a Cloudflare Access group rule discriminated union. A rule is a |
| 16 | * single-key object whose key selects the rule kind (`email`, `emailDomain`, |
| 17 | * `everyone`, `ip`, `geo`, `serviceToken`, etc.) and whose value carries the |
| 18 | * rule's parameters. |
| 19 | * |
| 20 | * Re-exported from `@distilled.cloud/cloudflare/zero-trust`'s |
| 21 | * `CreateAccessGroupForAccountRequest` so the full Cloudflare rule surface is |
| 22 | * available without re-declaring the union. |
| 23 | */ |
| 24 | export type GroupRule = |
| 25 | zeroTrust.CreateAccessGroupForAccountRequest["include"][number]; |
| 26 | |
| 27 | /** |
| 28 | * One arm of the exclude-side rule union, and its require-side twin. |
| 29 | * Cloudflare's spec types the exclude/require rule lists separately from |
| 30 | * include — a few rule kinds (e.g. the GitHub-organization rule) carry the |
| 31 | * raw wire shape there — so these props use the SDK's own unions rather |
| 32 | * than reusing {@link GroupRule}. |
| 33 | */ |
| 34 | export type GroupExcludeRule = NonNullable< |
| 35 | zeroTrust.CreateAccessGroupForAccountRequest["exclude"] |
| 36 | >[number]; |
| 37 | export type GroupRequireRule = NonNullable< |
| 38 | zeroTrust.CreateAccessGroupForAccountRequest["require"] |
| 39 | >[number]; |
| 40 | |
| 41 | export type GroupProps = { |
| 42 | /** |
| 43 | * Display name for the group. Used as a stable identifier so the provider |
| 44 | * can locate the group by name during adoption / state recovery. If |
| 45 | * omitted, a unique name is generated from the stack/stage/logical id. |
| 46 | * |
| 47 | * @default ${app}-${stage}-${id} |
| 48 | */ |
| 49 | name?: string; |
| 50 | /** |
| 51 | * Rules combined with logical OR. A user needs to meet only one of the |
| 52 | * Include rules to match the group. Required and must be non-empty. |
| 53 | */ |
| 54 | include: GroupRule[]; |
| 55 | /** |
| 56 | * Rules combined with logical NOT. A user matching any Exclude rule does |
| 57 | * not match the group, even if they satisfied an Include rule. |
| 58 | */ |
| 59 | exclude?: GroupExcludeRule[]; |
| 60 | /** |
| 61 | * Rules combined with logical AND. A user must satisfy every Require rule |
| 62 | * in addition to an Include rule. |
| 63 | */ |
| 64 | require?: GroupRequireRule[]; |
| 65 | /** |
| 66 | * Whether this is the default group for the Zero Trust organization. |
| 67 | * |
| 68 | * @default false |
| 69 | */ |
| 70 | isDefault?: boolean; |
| 71 | }; |
| 72 | |
| 73 | export type Group = Resource< |
| 74 | "Cloudflare.Access.Group", |
| 75 | GroupProps, |
| 76 | { |
| 77 | /** UUID of the group assigned by Cloudflare. */ |
| 78 | groupId: string; |
| 79 | /** Cloudflare account that owns the group. */ |
| 80 | accountId: string; |
| 81 | /** Display name reported by Cloudflare. */ |
| 82 | name: string; |
| 83 | /** Whether Cloudflare reports this group as the organization default. */ |
| 84 | isDefault: boolean | undefined; |
| 85 | }, |
| 86 | never, |
| 87 | Providers |
| 88 | >; |
| 89 | |
| 90 | /** |
| 91 | * A Cloudflare Zero Trust Access group — a reusable, account-scoped set of |
| 92 | * Access rule criteria. Groups are referenced from Access policies via a |
| 93 | * `{ group: { id } }` rule, letting many policies share one membership |
| 94 | * definition. |
| 95 | * ### Creating a Group |
| 96 | * **Example:** Allow a single email domain |
| 97 | * ```typescript |
| 98 | * const group = yield* Cloudflare.Access.Group("ExampleDomain", { |
| 99 | * include: [{ emailDomain: { domain: "example.com" } }], |
| 100 | * }); |
| 101 | * ``` |
| 102 | * |
| 103 | * **Example:** Combine include, exclude and require rules |
| 104 | * ```typescript |
| 105 | * const group = yield* Cloudflare.Access.Group("UsEngineers", { |
| 106 | * include: [{ emailDomain: { domain: "example.com" } }], |
| 107 | * exclude: [{ email: { email: "intern@example.com" } }], |
| 108 | * require: [{ geo: { countryCode: "US" } }], |
| 109 | * }); |
| 110 | * ``` |
| 111 | * |
| 112 | * ### Referencing a Group from a Policy |
| 113 | * **Example:** Allow members of the group |
| 114 | * ```typescript |
| 115 | * const group = yield* Cloudflare.Access.Group("Team", { |
| 116 | * include: [{ emailDomain: { domain: "example.com" } }], |
| 117 | * }); |
| 118 | * |
| 119 | * const policy = yield* Cloudflare.Access.Policy("AllowTeam", { |
| 120 | * decision: "allow", |
| 121 | * include: [{ group: { id: group.groupId } }], |
| 122 | * }); |
| 123 | * ``` |
| 124 | * |
| 125 | * @resource |
| 126 | * @product Access |
| 127 | * @category Cloudflare One (Zero Trust) |
| 128 | */ |
| 129 | export const Group = Resource<Group>("Cloudflare.Access.Group"); |
| 130 | |
| 131 | export const isGroup = (value: unknown): value is Group => |
| 132 | Predicate.hasProperty(value, "Type") && |
| 133 | value.Type === "Cloudflare.Access.Group"; |
| 134 | |
| 135 | export const GroupProvider = () => |
| 136 | Provider.succeed(Group, { |
| 137 | stables: ["groupId", "accountId"], |
| 138 | diff: Effect.fn(function* ({ news, output }) { |
| 139 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 140 | if (!isResolved(news)) return undefined; |
| 141 | if ((output?.accountId ?? accountId) !== accountId) { |
| 142 | return { action: "replace" } as const; |
| 143 | } |
| 144 | // Everything else (name, rules, isDefault) converges via PUT. |
| 145 | }), |
| 146 | read: Effect.fn(function* ({ id, output, olds }) { |
| 147 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 148 | const acct = output?.accountId ?? accountId; |
| 149 | if (output?.groupId) { |
| 150 | const direct = yield* zeroTrust |
| 151 | .getAccessGroupForAccount({ |
| 152 | accountId: acct, |
| 153 | groupId: output.groupId, |
| 154 | }) |
| 155 | .pipe( |
| 156 | Effect.map(toObserved), |
| 157 | Effect.catchTag("AccessGroupNotFound", () => |
| 158 | Effect.succeed(undefined), |
| 159 | ), |
| 160 | ); |
| 161 | if (direct && direct.id) { |
| 162 | return { |
| 163 | groupId: direct.id, |
| 164 | accountId: acct, |
| 165 | name: direct.name ?? output.name, |
| 166 | isDefault: direct.isDefault ?? output.isDefault, |
| 167 | }; |
| 168 | } |
| 169 | } |
| 170 | const name = yield* createGroupName(id, olds?.name ?? output?.name); |
| 171 | const existing = yield* findGroupByName(acct, name); |
| 172 | if (!existing || !existing.id) return undefined; |
| 173 | return { |
| 174 | groupId: existing.id, |
| 175 | accountId: acct, |
| 176 | name: existing.name ?? name, |
| 177 | isDefault: existing.isDefault ?? undefined, |
| 178 | }; |
| 179 | }), |
| 180 | list: Effect.fn(function* () { |
| 181 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 182 | return yield* zeroTrust.listAccessGroupsForAccount |
| 183 | .pages({ accountId }) |
| 184 | .pipe( |
| 185 | Stream.runCollect, |
| 186 | Effect.map((chunk) => |
| 187 | Array.from(chunk).flatMap((page) => |
| 188 | (page.result ?? []) |
| 189 | .filter( |
| 190 | (g): g is (typeof page.result)[number] & { id: string } => |
| 191 | g.id != null, |
| 192 | ) |
| 193 | .map((g) => ({ |
| 194 | groupId: g.id, |
| 195 | accountId, |
| 196 | name: g.name ?? "", |
| 197 | isDefault: g.isDefault ?? undefined, |
| 198 | })), |
| 199 | ), |
| 200 | ), |
| 201 | ); |
| 202 | }), |
| 203 | reconcile: Effect.fn(function* ({ id, news = {} as GroupProps, output }) { |
| 204 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 205 | const name = yield* createGroupName(id, news.name); |
| 206 | const acct = output?.accountId ?? accountId; |
| 207 | |
| 208 | // Observe — prefer the cached groupId, fall back to a name lookup so |
| 209 | // we recover from out-of-band deletes and partial state-persistence |
| 210 | // failures. |
| 211 | let observed: ObservedGroup | undefined; |
| 212 | if (output?.groupId) { |
| 213 | observed = yield* zeroTrust |
| 214 | .getAccessGroupForAccount({ |
| 215 | accountId: acct, |
| 216 | groupId: output.groupId, |
| 217 | }) |
| 218 | .pipe( |
| 219 | Effect.map(toObserved), |
| 220 | Effect.catchTag("AccessGroupNotFound", () => |
| 221 | Effect.succeed(undefined), |
| 222 | ), |
| 223 | ); |
| 224 | } |
| 225 | if (!observed) { |
| 226 | observed = yield* findGroupByName(acct, name); |
| 227 | } |
| 228 | |
| 229 | // Ensure — create the group when missing. Tolerate a race where a |
| 230 | // parallel actor created the same-named group by re-observing. |
| 231 | let ensured: ObservedGroup; |
| 232 | if (!observed || !observed.id) { |
| 233 | ensured = yield* zeroTrust |
| 234 | .createAccessGroupForAccount({ |
| 235 | accountId: acct, |
| 236 | name, |
| 237 | include: news.include, |
| 238 | exclude: news.exclude, |
| 239 | require: news.require, |
| 240 | isDefault: news.isDefault, |
| 241 | }) |
| 242 | .pipe( |
| 243 | Effect.map(toObserved), |
| 244 | Effect.catch((err) => |
| 245 | Effect.gen(function* () { |
| 246 | const existing = yield* findGroupByName(acct, name); |
| 247 | if (existing && existing.id) return existing; |
| 248 | return yield* Effect.fail(err); |
| 249 | }), |
| 250 | ), |
| 251 | ); |
| 252 | } else { |
| 253 | // Sync — Cloudflare PUTs the group as a whole replacement, so a |
| 254 | // single update converges every mutable field (name, rule sets, |
| 255 | // isDefault). The API is idempotent for equal payloads. |
| 256 | const prior = observed; |
| 257 | const updated = yield* zeroTrust.updateAccessGroupForAccount({ |
| 258 | accountId: acct, |
| 259 | groupId: prior.id!, |
| 260 | name, |
| 261 | include: news.include, |
| 262 | exclude: news.exclude, |
| 263 | require: news.require, |
| 264 | isDefault: news.isDefault, |
| 265 | }); |
| 266 | ensured = { |
| 267 | id: updated.id ?? prior.id, |
| 268 | name: updated.name ?? prior.name, |
| 269 | isDefault: updated.isDefault ?? prior.isDefault, |
| 270 | }; |
| 271 | } |
| 272 | |
| 273 | if (!ensured.id) { |
| 274 | return yield* Effect.fail(new Error("Group: ensured group missing id")); |
| 275 | } |
| 276 | return { |
| 277 | groupId: ensured.id, |
| 278 | accountId: acct, |
| 279 | name: ensured.name ?? name, |
| 280 | isDefault: ensured.isDefault ?? news.isDefault, |
| 281 | }; |
| 282 | }), |
| 283 | delete: Effect.fn(function* ({ output }) { |
| 284 | yield* zeroTrust |
| 285 | .deleteAccessGroupForAccount({ |
| 286 | accountId: output.accountId, |
| 287 | groupId: output.groupId, |
| 288 | }) |
| 289 | .pipe(Effect.catchTag("AccessGroupNotFound", () => Effect.void)); |
| 290 | }), |
| 291 | }); |
| 292 | |
| 293 | const createGroupName = (id: string, name: string | undefined) => |
| 294 | Effect.gen(function* () { |
| 295 | if (name) return name; |
| 296 | return yield* createPhysicalName({ id }); |
| 297 | }); |
| 298 | |
| 299 | const findGroupByName = (acct: string, name: string) => |
| 300 | zeroTrust.listAccessGroupsForAccount.items({ accountId: acct }).pipe( |
| 301 | Stream.filter((g): g is ObservedGroup => g.name === name), |
| 302 | Stream.runHead, |
| 303 | Effect.map(Option.getOrUndefined), |
| 304 | Effect.catch(() => Effect.succeed(undefined)), |
| 305 | ); |
| 306 | |
| 307 | type ObservedGroup = { |
| 308 | id?: string | null; |
| 309 | name?: string | null; |
| 310 | isDefault?: boolean | null; |
| 311 | }; |
| 312 | |
| 313 | const toObserved = (g: ObservedGroup): ObservedGroup => ({ |
| 314 | id: g.id, |
| 315 | name: g.name, |
| 316 | isDefault: g.isDefault, |
| 317 | }); |