| 1 | import * as fms from "@distilled.cloud/aws/fms"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { AWSEnvironment } from "../Environment.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { Region } from "../Region.ts"; |
| 10 | |
| 11 | // FMS admin-account management (get/associate/disassociate AdminAccount) is a |
| 12 | // global, organization-level operation served exclusively from the us-east-1 |
| 13 | // endpoint — every other region rejects it with `InvalidOperationException: |
| 14 | // This operation is not supported in the '<region>' region.` Pin every |
| 15 | // control-plane call there regardless of the ambient stack region (same |
| 16 | // pattern as Cost Explorer / ECR Public / WAFv2 CloudFront-scope). |
| 17 | const FMS_ADMIN_REGION = "us-east-1"; |
| 18 | |
| 19 | /** Pin an FMS admin-account operation to the us-east-1 endpoint. */ |
| 20 | export const pinFms = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 21 | effect.pipe(Effect.provideService(Region, Effect.succeed(FMS_ADMIN_REGION))); |
| 22 | |
| 23 | export interface AdminAccountProps { |
| 24 | /** |
| 25 | * The AWS account ID to designate as the AWS Firewall Manager administrator |
| 26 | * account for the organization. Must be an account in the same AWS |
| 27 | * Organization as the caller (which must be the Organizations management |
| 28 | * account). If omitted, the caller's account is used. |
| 29 | */ |
| 30 | adminAccount?: string; |
| 31 | } |
| 32 | |
| 33 | /** @resource */ |
| 34 | export interface AdminAccount extends Resource< |
| 35 | "AWS.FMS.AdminAccount", |
| 36 | AdminAccountProps, |
| 37 | { |
| 38 | /** The account designated as the FMS administrator. */ |
| 39 | adminAccount: string; |
| 40 | /** Status of the FMS administrator IAM role (`READY` / `CREATING` / ...). */ |
| 41 | roleStatus: string | undefined; |
| 42 | }, |
| 43 | never, |
| 44 | Providers |
| 45 | > {} |
| 46 | |
| 47 | /** |
| 48 | * The AWS Firewall Manager administrator account — an organization-level |
| 49 | * singleton that designates which account manages FMS security policies. |
| 50 | * |
| 51 | * :::caution |
| 52 | * FMS requires the caller to be the AWS Organizations **management account**, |
| 53 | * and the organization must have AWS Config enabled. On accounts that are not |
| 54 | * an Organizations management account this resource fails at association time |
| 55 | * with a typed `InvalidOperationException`. |
| 56 | * |
| 57 | * FMS admin-account APIs are served only from the us-east-1 endpoint; this |
| 58 | * resource pins every control-plane call there regardless of the ambient |
| 59 | * stack region. |
| 60 | * ::: |
| 61 | * |
| 62 | * This is a capture-and-restore singleton: FMS exposes no tags, so ownership is |
| 63 | * tracked by Alchemy state — adopting a pre-existing admin account that Alchemy |
| 64 | * did not create requires `--adopt`, and destroy disassociates the admin. |
| 65 | * |
| 66 | * ### Designating the FMS admin |
| 67 | * **Example:** Designate the caller as the FMS admin |
| 68 | * ```typescript |
| 69 | * const admin = yield* FMS.AdminAccount("FmsAdmin", {}); |
| 70 | * ``` |
| 71 | * |
| 72 | * **Example:** Designate a specific member account |
| 73 | * ```typescript |
| 74 | * const admin = yield* FMS.AdminAccount("FmsAdmin", { |
| 75 | * adminAccount: "123456789012", |
| 76 | * }); |
| 77 | * ``` |
| 78 | */ |
| 79 | const AdminAccountResource = Resource<AdminAccount>("AWS.FMS.AdminAccount"); |
| 80 | |
| 81 | export { AdminAccountResource as AdminAccount }; |
| 82 | |
| 83 | // `getAdminAccount` throws `ResourceNotFoundException` when no FMS admin has |
| 84 | // been designated — collapse to `undefined`. |
| 85 | const getAdmin = pinFms(fms.getAdminAccount({})).pipe( |
| 86 | Effect.map((r) => r as fms.GetAdminAccountResponse | undefined), |
| 87 | Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)), |
| 88 | ); |
| 89 | |
| 90 | export const AdminAccountProvider = () => |
| 91 | Provider.effect( |
| 92 | AdminAccountResource, |
| 93 | Effect.gen(function* () { |
| 94 | // Association is asynchronous; the FMS role transitions CREATING → READY. |
| 95 | const waitUntilReady = getAdmin.pipe( |
| 96 | Effect.repeat({ |
| 97 | schedule: Schedule.spaced("3 seconds"), |
| 98 | until: (a) => a?.RoleStatus === "READY", |
| 99 | times: 20, |
| 100 | }), |
| 101 | ); |
| 102 | |
| 103 | return { |
| 104 | read: Effect.fn(function* ({ output }) { |
| 105 | const admin = yield* getAdmin; |
| 106 | if (!admin?.AdminAccount) return undefined; |
| 107 | const attrs = { |
| 108 | adminAccount: admin.AdminAccount, |
| 109 | roleStatus: admin.RoleStatus, |
| 110 | }; |
| 111 | // FMS admin has no tags — ownership can't be verified from the cloud. |
| 112 | // With no prior state, treat an existing admin as foreign. |
| 113 | return output ? attrs : Unowned(attrs); |
| 114 | }), |
| 115 | |
| 116 | // Organization-level singleton — report the single admin account. |
| 117 | list: () => |
| 118 | getAdmin.pipe( |
| 119 | Effect.map((a) => |
| 120 | a?.AdminAccount |
| 121 | ? [{ adminAccount: a.AdminAccount, roleStatus: a.RoleStatus }] |
| 122 | : [], |
| 123 | ), |
| 124 | ), |
| 125 | |
| 126 | reconcile: Effect.fn(function* ({ news = {}, session }) { |
| 127 | const { accountId } = yield* AWSEnvironment.current; |
| 128 | const desired = news.adminAccount ?? accountId; |
| 129 | |
| 130 | // 1. OBSERVE |
| 131 | let admin = yield* getAdmin; |
| 132 | |
| 133 | // 2. ENSURE — associate the admin account if none is set. |
| 134 | if (!admin?.AdminAccount) { |
| 135 | // On a fresh account the first associate call kicks off creation |
| 136 | // of the AWSServiceRoleForFMS service-linked role and fails |
| 137 | // transiently while it materializes — either with a bare |
| 138 | // `InvalidOperationException: Operation is invalid.` or with |
| 139 | // `Service role name AWSServiceRoleForFMS already exists ... |
| 140 | // Otherwise, try your request again.` Both are eventual- |
| 141 | // consistency races, not terminal failures — retry bounded. |
| 142 | yield* pinFms( |
| 143 | fms.associateAdminAccount({ AdminAccount: desired }), |
| 144 | ).pipe( |
| 145 | Effect.retry({ |
| 146 | // `: boolean` blocks TS 5.5+ inferred type predicates — see |
| 147 | // the note on the disassociate retry in `delete` below. |
| 148 | while: (e): boolean => |
| 149 | e._tag === "InvalidOperationException" && |
| 150 | (e.message === "Operation is invalid." || |
| 151 | (e.message?.includes("AWSServiceRoleForFMS") ?? false)), |
| 152 | schedule: Schedule.spaced("5 seconds"), |
| 153 | times: 8, |
| 154 | }), |
| 155 | ); |
| 156 | admin = yield* waitUntilReady; |
| 157 | } |
| 158 | |
| 159 | // 3. RETURN fresh attributes. |
| 160 | const final = yield* getAdmin; |
| 161 | yield* session.note(desired); |
| 162 | return { |
| 163 | adminAccount: final?.AdminAccount ?? desired, |
| 164 | roleStatus: final?.RoleStatus, |
| 165 | }; |
| 166 | }), |
| 167 | |
| 168 | delete: Effect.fn(function* () { |
| 169 | // For several minutes after association FMS reports the org as |
| 170 | // "currently onboarding with AWS Firewall Manager and cannot be |
| 171 | // offboarded" (a typed `InvalidOperationException`). Retry bounded; |
| 172 | // onboarding can take >10 minutes, so a destroy issued immediately |
| 173 | // after create may need to be re-run once onboarding settles. |
| 174 | yield* pinFms(fms.disassociateAdminAccount({})).pipe( |
| 175 | Effect.retry({ |
| 176 | // `: boolean` prevents TS 5.5+ from inferring a type predicate |
| 177 | // for this lambda — an inferred `e is InvalidOperationException` |
| 178 | // refinement makes `Effect.retry` collapse the error channel to |
| 179 | // `unknown`, which breaks the `catchTag` below. |
| 180 | while: (e): boolean => |
| 181 | e._tag === "InvalidOperationException" && |
| 182 | (e.message?.includes("cannot be offboarded") ?? false), |
| 183 | schedule: Schedule.spaced("8 seconds"), |
| 184 | times: 8, |
| 185 | }), |
| 186 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 187 | ); |
| 188 | }), |
| 189 | }; |
| 190 | }), |
| 191 | ); |