| 1 | import * as accounts from "@distilled.cloud/cloudflare/accounts"; |
| 2 | import { |
| 3 | apiKeyCredentials, |
| 4 | apiTokenCredentials, |
| 5 | } from "@distilled.cloud/cloudflare/Credentials"; |
| 6 | import * as user from "@distilled.cloud/cloudflare/user"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import { |
| 11 | selectableScopes, |
| 12 | tokenPolicies, |
| 13 | type PermissionGroup, |
| 14 | type TokenPolicy, |
| 15 | } from "../../Cloudflare/Auth/TokenPolicy.ts"; |
| 16 | import * as CloudflareCredentials from "../../Cloudflare/Credentials.ts"; |
| 17 | import { AlchemistInvalidInput, type Diagnostic } from "../Errors.ts"; |
| 18 | |
| 19 | export interface GlobalCredentials { |
| 20 | readonly email: string; |
| 21 | readonly apiKey: Redacted.Redacted<string>; |
| 22 | } |
| 23 | |
| 24 | export interface Account { |
| 25 | readonly id: string; |
| 26 | readonly name: string; |
| 27 | } |
| 28 | |
| 29 | export type { |
| 30 | PermissionGroup, |
| 31 | TokenPolicy, |
| 32 | } from "../../Cloudflare/Auth/TokenPolicy.ts"; |
| 33 | |
| 34 | export interface TokenCatalog { |
| 35 | readonly accounts: ReadonlyArray<Account>; |
| 36 | readonly permissionGroups: ReadonlyArray<PermissionGroup>; |
| 37 | } |
| 38 | |
| 39 | export interface PlanInput { |
| 40 | readonly credentials: GlobalCredentials; |
| 41 | readonly name: string; |
| 42 | readonly accountIds: ReadonlyArray<string>; |
| 43 | readonly permissionGroupIds: ReadonlyArray<string> | "all"; |
| 44 | } |
| 45 | |
| 46 | export interface TokenPlan { |
| 47 | readonly name: string; |
| 48 | readonly accountIds: ReadonlyArray<string>; |
| 49 | readonly permissionGroupIds: ReadonlyArray<string>; |
| 50 | readonly permissionCount: number; |
| 51 | readonly grantsFullAccess: boolean; |
| 52 | readonly policies: ReadonlyArray<TokenPolicy>; |
| 53 | } |
| 54 | |
| 55 | export interface CreateInput { |
| 56 | readonly credentials: GlobalCredentials; |
| 57 | readonly plan: TokenPlan; |
| 58 | } |
| 59 | |
| 60 | export interface CreatedToken { |
| 61 | readonly id: string; |
| 62 | readonly name: string; |
| 63 | readonly value: Redacted.Redacted<string>; |
| 64 | readonly grantedPermissionGroups: number; |
| 65 | /** Echoed back by Cloudflare; may contain effects we never request. */ |
| 66 | readonly policies: ReadonlyArray<unknown>; |
| 67 | readonly verificationStatus?: string; |
| 68 | readonly diagnostics: ReadonlyArray<Diagnostic>; |
| 69 | } |
| 70 | |
| 71 | /** Cloudflare accepted the create call but returned no usable token. */ |
| 72 | export class CloudflareTokenError extends Data.TaggedError( |
| 73 | "CloudflareTokenError", |
| 74 | )<{ readonly message: string }> {} |
| 75 | |
| 76 | /** Authenticate the surrounding effect with the user's Global API Key. */ |
| 77 | const withGlobalKey = (credentials: GlobalCredentials) => |
| 78 | Effect.provideService( |
| 79 | CloudflareCredentials.Credentials, |
| 80 | Effect.succeed( |
| 81 | apiKeyCredentials({ |
| 82 | apiKey: Redacted.value(credentials.apiKey), |
| 83 | email: credentials.email, |
| 84 | }), |
| 85 | ), |
| 86 | ); |
| 87 | |
| 88 | /** The accounts and permission groups a token can be scoped to. */ |
| 89 | export const catalog = Effect.fn("Alchemist.cloudflare.token.catalog")( |
| 90 | // The credentials only authenticate the call, via the transformer below. |
| 91 | function* (_credentials: GlobalCredentials) { |
| 92 | const listAccounts = yield* accounts.listAccounts; |
| 93 | const listPermissionGroups = yield* user.listTokenPermissionGroups; |
| 94 | const { accountResponse, permissionGroupResponse } = yield* Effect.all( |
| 95 | { |
| 96 | accountResponse: listAccounts({}), |
| 97 | permissionGroupResponse: listPermissionGroups({}), |
| 98 | }, |
| 99 | { concurrency: "unbounded" }, |
| 100 | ); |
| 101 | return { |
| 102 | accounts: accountResponse.result.map(({ id, name }) => ({ |
| 103 | id, |
| 104 | name, |
| 105 | })), |
| 106 | permissionGroups: permissionGroupResponse.result.flatMap((group) => |
| 107 | group.id && group.name && group.scopes?.length |
| 108 | ? [ |
| 109 | { |
| 110 | id: group.id, |
| 111 | name: group.name, |
| 112 | category: group.category ?? undefined, |
| 113 | scopes: group.scopes, |
| 114 | selectable: selectableScopes.has(group.scopes[0]!), |
| 115 | }, |
| 116 | ] |
| 117 | : [], |
| 118 | ), |
| 119 | } satisfies TokenCatalog; |
| 120 | }, |
| 121 | (effect, credentials) => withGlobalKey(credentials)(effect), |
| 122 | ); |
| 123 | |
| 124 | /** Resolve the selected permission groups into concrete token policies. */ |
| 125 | export const plan = Effect.fn("Alchemist.cloudflare.token.plan")(function* ( |
| 126 | input: PlanInput, |
| 127 | ) { |
| 128 | const tokenCatalog = yield* catalog(input.credentials); |
| 129 | const selected = |
| 130 | input.permissionGroupIds === "all" |
| 131 | ? tokenCatalog.permissionGroups |
| 132 | : yield* Effect.forEach(input.permissionGroupIds, (id) => { |
| 133 | const group = tokenCatalog.permissionGroups.find( |
| 134 | (candidate) => candidate.id === id, |
| 135 | ); |
| 136 | if (group === undefined) { |
| 137 | return Effect.fail( |
| 138 | new AlchemistInvalidInput({ |
| 139 | field: "permissionGroupIds", |
| 140 | message: `Unknown Cloudflare permission group '${id}'.`, |
| 141 | }), |
| 142 | ); |
| 143 | } |
| 144 | return Effect.succeed(group); |
| 145 | }); |
| 146 | const currentUser = yield* user |
| 147 | .getUser({}) |
| 148 | .pipe(withGlobalKey(input.credentials)); |
| 149 | const resolved = tokenPolicies(input.accountIds, currentUser.id, selected); |
| 150 | if (resolved.length === 0) { |
| 151 | return yield* Effect.fail( |
| 152 | new AlchemistInvalidInput({ |
| 153 | field: "permissionGroupIds", |
| 154 | message: |
| 155 | "No selected permission groups can be expressed as token policies.", |
| 156 | }), |
| 157 | ); |
| 158 | } |
| 159 | return { |
| 160 | name: input.name, |
| 161 | accountIds: input.accountIds, |
| 162 | permissionGroupIds: selected.map(({ id }) => id), |
| 163 | permissionCount: selected.length, |
| 164 | grantsFullAccess: input.permissionGroupIds === "all", |
| 165 | policies: resolved, |
| 166 | } satisfies TokenPlan; |
| 167 | }); |
| 168 | |
| 169 | /** Mint the planned token with the user's Global API Key. */ |
| 170 | export const create = Effect.fn("Alchemist.cloudflare.token.create")( |
| 171 | function* (input: CreateInput) { |
| 172 | const result = yield* user.createToken({ |
| 173 | name: input.plan.name, |
| 174 | policies: input.plan.policies as TokenPolicy[], |
| 175 | }); |
| 176 | if (!result.value) { |
| 177 | return yield* Effect.fail( |
| 178 | new CloudflareTokenError({ |
| 179 | message: "Cloudflare did not return a token value.", |
| 180 | }), |
| 181 | ); |
| 182 | } |
| 183 | const granted = (result.policies ?? []).reduce( |
| 184 | (count, policy) => count + (policy.permissionGroups?.length ?? 0), |
| 185 | 0, |
| 186 | ); |
| 187 | const verificationStatus = yield* user.verifyToken({}).pipe( |
| 188 | Effect.provideService( |
| 189 | CloudflareCredentials.Credentials, |
| 190 | Effect.succeed(apiTokenCredentials({ apiToken: result.value })), |
| 191 | ), |
| 192 | Effect.map(({ status }) => status), |
| 193 | Effect.catch(() => Effect.succeed(undefined)), |
| 194 | ); |
| 195 | return { |
| 196 | id: result.id ?? "unknown", |
| 197 | name: result.name ?? input.plan.name, |
| 198 | value: Redacted.make(result.value), |
| 199 | grantedPermissionGroups: granted, |
| 200 | policies: result.policies ?? input.plan.policies, |
| 201 | verificationStatus, |
| 202 | diagnostics: |
| 203 | granted === 0 |
| 204 | ? [ |
| 205 | { |
| 206 | severity: "warning" as const, |
| 207 | code: "cloudflare.token.zero-permissions", |
| 208 | message: "Cloudflare created the token with zero permissions.", |
| 209 | }, |
| 210 | ] |
| 211 | : [], |
| 212 | } satisfies CreatedToken; |
| 213 | }, |
| 214 | (effect, input) => withGlobalKey(input.credentials)(effect), |
| 215 | ); |