| 1 | import * as signer from "@distilled.cloud/aws/signer"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | |
| 11 | export interface ProfilePermissionProps { |
| 12 | /** |
| 13 | * The name of the signing profile the permission is attached to. |
| 14 | * Changing it replaces the permission. |
| 15 | */ |
| 16 | profileName: string; |
| 17 | |
| 18 | /** |
| 19 | * The Signer action the principal is allowed to perform on the profile: |
| 20 | * `signer:StartSigningJob`, `signer:SignPayload`, |
| 21 | * `signer:GetSigningProfile`, or `signer:RevokeSignature`. |
| 22 | */ |
| 23 | action: string; |
| 24 | |
| 25 | /** |
| 26 | * The AWS principal (account id, or IAM user/role ARN) receiving |
| 27 | * cross-account permission to use the profile. |
| 28 | */ |
| 29 | principal: string; |
| 30 | |
| 31 | /** |
| 32 | * A unique statement id identifying this permission inside the profile's |
| 33 | * resource policy. If omitted, a deterministic id is generated from the |
| 34 | * logical id. Changing it replaces the permission. |
| 35 | */ |
| 36 | statementId?: string; |
| 37 | |
| 38 | /** |
| 39 | * The signing profile version the permission applies to. Omit to attach |
| 40 | * the permission to the profile as a whole. |
| 41 | */ |
| 42 | profileVersion?: string; |
| 43 | } |
| 44 | |
| 45 | export interface ProfilePermission extends Resource< |
| 46 | "AWS.Signer.ProfilePermission", |
| 47 | ProfilePermissionProps, |
| 48 | { |
| 49 | /** The name of the signing profile the permission is attached to. */ |
| 50 | profileName: string; |
| 51 | /** The statement id of the permission inside the profile's policy. */ |
| 52 | statementId: string; |
| 53 | }, |
| 54 | never, |
| 55 | Providers |
| 56 | > {} |
| 57 | |
| 58 | /** |
| 59 | * A cross-account permission on an AWS Signer signing profile — one statement |
| 60 | * in the profile's resource policy granting another AWS account (or IAM |
| 61 | * identity) a Signer action such as `signer:StartSigningJob`. The Signer |
| 62 | * counterpart of CloudFormation's `AWS::Signer::ProfilePermission`. |
| 63 | * |
| 64 | * Permissions have no update API: changing the `action`, `principal`, or |
| 65 | * `profileVersion` converges by removing and re-adding the statement under |
| 66 | * the same statement id (revision-checked, so concurrent policy edits are |
| 67 | * retried); changing `profileName` or `statementId` replaces the permission. |
| 68 | * |
| 69 | * ### Sharing a Signing Profile |
| 70 | * **Example:** Allow Another Account to Sign |
| 71 | * ```typescript |
| 72 | * const profile = yield* Signer.SigningProfile("ReleaseProfile", { |
| 73 | * platformId: "AWSLambda-SHA384-ECDSA", |
| 74 | * }); |
| 75 | * |
| 76 | * const permission = yield* Signer.ProfilePermission("CiAccountCanSign", { |
| 77 | * profileName: profile.profileName, |
| 78 | * action: "signer:StartSigningJob", |
| 79 | * principal: "123456789012", |
| 80 | * }); |
| 81 | * ``` |
| 82 | * |
| 83 | * **Example:** Pin the Permission to a Profile Version |
| 84 | * ```typescript |
| 85 | * const permission = yield* Signer.ProfilePermission("CiAccountCanSign", { |
| 86 | * profileName: profile.profileName, |
| 87 | * action: "signer:StartSigningJob", |
| 88 | * principal: "123456789012", |
| 89 | * profileVersion: profile.profileVersion, |
| 90 | * }); |
| 91 | * ``` |
| 92 | * |
| 93 | * @resource |
| 94 | */ |
| 95 | export const ProfilePermission = Resource<ProfilePermission>( |
| 96 | "AWS.Signer.ProfilePermission", |
| 97 | ); |
| 98 | |
| 99 | /** |
| 100 | * The profile policy is capped at 20 statements, but the list API still |
| 101 | * pages — walk it fully so revision ids and statement lookups are accurate. |
| 102 | * A profile that has never had a permission answers with a typed |
| 103 | * `ResourceNotFoundException` ("No policies associated with profile …") — |
| 104 | * observed as an empty policy. A genuinely missing profile surfaces on the |
| 105 | * subsequent `addProfilePermission` instead. |
| 106 | */ |
| 107 | const listAllPermissions = Effect.fn(function* (profileName: string) { |
| 108 | const permissions: signer.Permission[] = []; |
| 109 | let revisionId: string | undefined; |
| 110 | let nextToken: string | undefined; |
| 111 | do { |
| 112 | const page = yield* signer |
| 113 | .listProfilePermissions({ profileName, nextToken }) |
| 114 | .pipe( |
| 115 | Effect.catchTag("ResourceNotFoundException", () => |
| 116 | Effect.succeed({ |
| 117 | permissions: [] as signer.Permission[], |
| 118 | revisionId: undefined, |
| 119 | nextToken: undefined, |
| 120 | } as signer.ListProfilePermissionsResponse), |
| 121 | ), |
| 122 | ); |
| 123 | permissions.push(...(page.permissions ?? [])); |
| 124 | revisionId = page.revisionId ?? revisionId; |
| 125 | nextToken = page.nextToken; |
| 126 | } while (nextToken !== undefined); |
| 127 | return { permissions, revisionId }; |
| 128 | }); |
| 129 | |
| 130 | /** Concurrent policy edits bump the revision id — re-observe and retry. */ |
| 131 | const conflictRetry = { |
| 132 | while: (e: { _tag: string }): boolean => e._tag === "ConflictException", |
| 133 | schedule: Schedule.exponential("500 millis"), |
| 134 | times: 5, |
| 135 | } as const; |
| 136 | |
| 137 | export const ProfilePermissionProvider = () => |
| 138 | Provider.effect( |
| 139 | ProfilePermission, |
| 140 | Effect.gen(function* () { |
| 141 | const createStatementId = Effect.fn(function* ( |
| 142 | id: string, |
| 143 | props: Pick<ProfilePermissionProps, "statementId">, |
| 144 | ) { |
| 145 | return ( |
| 146 | props.statementId ?? |
| 147 | // Signer rejects statement ids of 64+ characters. |
| 148 | (yield* createPhysicalName({ id, delimiter: "-", maxLength: 63 })) |
| 149 | ); |
| 150 | }); |
| 151 | |
| 152 | return ProfilePermission.Provider.of({ |
| 153 | stables: ["profileName", "statementId"], |
| 154 | |
| 155 | list: () => |
| 156 | Effect.gen(function* () { |
| 157 | // A ProfilePermission is one statement inside a profile's |
| 158 | // resource policy — fan out: enumerate active profiles, list |
| 159 | // each policy, emit one Attributes per statement id. |
| 160 | const profileNames = yield* signer.listSigningProfiles |
| 161 | .pages({ includeCanceled: false }) |
| 162 | .pipe( |
| 163 | Stream.runCollect, |
| 164 | Effect.map((chunk) => |
| 165 | Array.from(chunk).flatMap((page) => |
| 166 | (page.profiles ?? []) |
| 167 | .map((p) => p.profileName) |
| 168 | .filter((name): name is string => name != null), |
| 169 | ), |
| 170 | ), |
| 171 | ); |
| 172 | const perProfile = yield* Effect.forEach( |
| 173 | profileNames, |
| 174 | (profileName) => |
| 175 | listAllPermissions(profileName).pipe( |
| 176 | Effect.map(({ permissions }) => |
| 177 | permissions |
| 178 | .filter( |
| 179 | (p): p is signer.Permission & { statementId: string } => |
| 180 | typeof p.statementId === "string", |
| 181 | ) |
| 182 | .map((p) => ({ |
| 183 | profileName, |
| 184 | statementId: p.statementId, |
| 185 | })), |
| 186 | ), |
| 187 | ), |
| 188 | { concurrency: 5 }, |
| 189 | ); |
| 190 | return perProfile.flat(); |
| 191 | }), |
| 192 | |
| 193 | read: Effect.fn(function* ({ id, olds, output }) { |
| 194 | const profileName = output?.profileName ?? olds?.profileName; |
| 195 | if (profileName === undefined) return undefined; |
| 196 | const statementId = |
| 197 | output?.statementId ?? (yield* createStatementId(id, olds ?? {})); |
| 198 | const { permissions } = yield* listAllPermissions(profileName); |
| 199 | const statement = permissions.find( |
| 200 | (p) => p.statementId === statementId, |
| 201 | ); |
| 202 | if (statement === undefined) return undefined; |
| 203 | return { profileName, statementId }; |
| 204 | }), |
| 205 | |
| 206 | diff: Effect.fn(function* ({ id, news, olds }) { |
| 207 | if (!isResolved(news)) return undefined; |
| 208 | const oldStatementId = yield* createStatementId(id, olds); |
| 209 | const newStatementId = yield* createStatementId(id, news); |
| 210 | // The statement id is the permission's identity within the policy |
| 211 | // and the profile is the policy's host — either change replaces. |
| 212 | // action/principal/profileVersion converge in reconcile via |
| 213 | // remove + re-add under the same statement id. |
| 214 | if ( |
| 215 | news.profileName !== olds.profileName || |
| 216 | oldStatementId !== newStatementId |
| 217 | ) { |
| 218 | return { action: "replace" } as const; |
| 219 | } |
| 220 | return undefined; |
| 221 | }), |
| 222 | |
| 223 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 224 | const statementId = |
| 225 | output?.statementId ?? (yield* createStatementId(id, news)); |
| 226 | |
| 227 | yield* Effect.gen(function* () { |
| 228 | // OBSERVE — the profile's policy is authoritative. |
| 229 | const { permissions, revisionId } = yield* listAllPermissions( |
| 230 | news.profileName, |
| 231 | ); |
| 232 | const existing = permissions.find( |
| 233 | (p) => p.statementId === statementId, |
| 234 | ); |
| 235 | |
| 236 | // SYNC — statements have no update API. If the existing statement |
| 237 | // differs from the desired one, remove it (revision-checked) and |
| 238 | // fall through to re-add. |
| 239 | if (existing !== undefined) { |
| 240 | const matches = |
| 241 | existing.action === news.action && |
| 242 | existing.principal === news.principal && |
| 243 | (existing.profileVersion ?? undefined) === |
| 244 | (news.profileVersion ?? undefined); |
| 245 | if (matches) return; |
| 246 | yield* signer |
| 247 | .removeProfilePermission({ |
| 248 | profileName: news.profileName, |
| 249 | statementId, |
| 250 | revisionId: revisionId!, |
| 251 | }) |
| 252 | .pipe( |
| 253 | // Already removed by a concurrent writer — proceed to add. |
| 254 | Effect.catchTag( |
| 255 | "ResourceNotFoundException", |
| 256 | () => Effect.void, |
| 257 | ), |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | // ENSURE — add the desired statement. A ConflictException here is |
| 262 | // a revision race (or the remove above still propagating); the |
| 263 | // outer retry re-observes and converges. |
| 264 | yield* signer.addProfilePermission({ |
| 265 | profileName: news.profileName, |
| 266 | action: news.action, |
| 267 | principal: news.principal, |
| 268 | statementId, |
| 269 | profileVersion: news.profileVersion, |
| 270 | revisionId: existing !== undefined ? undefined : revisionId, |
| 271 | }); |
| 272 | }).pipe(Effect.retry(conflictRetry)); |
| 273 | |
| 274 | yield* session.note(`${statementId} on ${news.profileName}`); |
| 275 | return { profileName: news.profileName, statementId }; |
| 276 | }), |
| 277 | |
| 278 | delete: Effect.fn(function* ({ output }) { |
| 279 | yield* Effect.gen(function* () { |
| 280 | const { permissions, revisionId } = yield* listAllPermissions( |
| 281 | output.profileName, |
| 282 | ); |
| 283 | const existing = permissions.find( |
| 284 | (p) => p.statementId === output.statementId, |
| 285 | ); |
| 286 | if (existing === undefined || revisionId === undefined) return; |
| 287 | yield* signer |
| 288 | .removeProfilePermission({ |
| 289 | profileName: output.profileName, |
| 290 | statementId: output.statementId, |
| 291 | revisionId, |
| 292 | }) |
| 293 | .pipe( |
| 294 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 295 | ); |
| 296 | }).pipe(Effect.retry(conflictRetry)); |
| 297 | }), |
| 298 | }); |
| 299 | }), |
| 300 | ); |