1import * as signer from "@distilled.cloud/aws/signer";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export 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
45export 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 */
95export 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 */
107const 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. */
131const conflictRetry = {
132 while: (e: { _tag: string }): boolean => e._tag === "ConflictException",
133 schedule: Schedule.exponential("500 millis"),
134 times: 5,
135} as const;
136
137export 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 );