1/**
2 * Shared scaffolding for EventBridge Scheduler HTTP bindings.
3 *
4 * NOT exported from `index.ts` — every thin `{Op}Http.ts` in this service is
5 * a `Layer.effect(Cap, make…HttpBinding({ … }))` over one of the builders
6 * below. Everything except the operation and the IAM action list is
7 * boilerplate:
8 *
9 * - **Group-scoped operations** (`scheduler:GetSchedule`,
10 * `scheduler:DeleteSchedule`, `scheduler:ListSchedules`) inject the bound
11 * {@link ScheduleGroup}'s name as the request's `GroupName` and are granted
12 * on the group's schedule ARN pattern. Dynamic schedules are named at
13 * runtime, so the resource pattern is group-scoped rather than an exact
14 * ARN: the group (or the default group) is the least-privilege boundary.
15 * - **Write operations** (`scheduler:CreateSchedule`,
16 * `scheduler:UpdateSchedule`) additionally take the schedule **execution
17 * role** (the IAM role EventBridge Scheduler assumes to invoke the target)
18 * and contribute `iam:PassRole` on it — without the PassRole statement the
19 * write fails only at runtime. The bound role is injected as the target's
20 * `RoleArn` unless the request overrides it, and `FlexibleTimeWindow`
21 * defaults to `{ Mode: "OFF" }`.
22 */
23import type * as scheduler from "@distilled.cloud/aws/scheduler";
24import * as Effect from "effect/Effect";
25import * as Binding from "../../Binding.ts";
26import * as Output from "../../Output.ts";
27import { AWSEnvironment } from "../Environment.ts";
28import type { Role } from "../IAM/Role.ts";
29import { isBindingHost } from "../Lambda/Function.ts";
30import type { ScheduleGroup } from "./ScheduleGroup.ts";
31
32/**
33 * Resolve the group-scoped schedule ARN pattern the binding is granted on.
34 * Passes unresolved Outputs — binding data is resolved by the engine before
35 * the host reconciles.
36 */
37const scheduleArnPattern = Effect.fn(function* (
38 group: ScheduleGroup | undefined,
39) {
40 const { accountId, region } =
41 yield* AWSEnvironment.current as unknown as Effect.Effect<{
42 accountId: string;
43 region: string;
44 }>;
45 return group
46 ? Output.interpolate`arn:aws:scheduler:${region}:${accountId}:schedule/${group.scheduleGroupName}/*`
47 : (`arn:aws:scheduler:${region}:${accountId}:schedule/default/*` as const);
48});
49
50/**
51 * Account-wide schedule ARN pattern — the resource IAM evaluates
52 * `scheduler:ListSchedules` against, regardless of any `GroupName` filter in
53 * the request.
54 */
55const allSchedulesArnPattern = Effect.gen(function* () {
56 const { accountId, region } =
57 yield* AWSEnvironment.current as unknown as Effect.Effect<{
58 accountId: string;
59 region: string;
60 }>;
61 return `arn:aws:scheduler:${region}:${accountId}:schedule/*/*` as const;
62});
63
64/**
65 * Build the impl Effect for a group-scoped Scheduler operation: the
66 * deploy-time half grants `actions` on the group's schedule ARN pattern, and
67 * the runtime half injects the group's name as the request's `GroupName`.
68 */
69export const makeScheduleGroupScopedHttpBinding = <
70 I extends { GroupName?: string },
71 A,
72 E,
73 R,
74>(options: {
75 /** Fully-qualified binding tag, e.g. `AWS.Scheduler.GetSchedule`. */
76 tag: string;
77 /** The distilled operation; `GroupName` is injected from the bound group. */
78 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
79 /** IAM actions granted on the group's schedule ARN pattern. */
80 actions: readonly string[];
81 /**
82 * `GroupName` injected when no group is bound. Get/Delete leave it
83 * undefined (the API defaults to the `default` group); ListSchedules pins
84 * `"default"` explicitly because an absent `GroupName` there means "all
85 * groups".
86 */
87 fallbackGroupName?: string;
88 /**
89 * IAM resource scope. `"group"` (default) grants on the bound group's
90 * `schedule/{group}/*` pattern. `"all"` grants on `schedule/*​/*` —
91 * required for `scheduler:ListSchedules`, which IAM evaluates against the
92 * account-wide schedule pattern regardless of the request's `GroupName`
93 * filter (AccessDenied cites `schedule/*​/*`).
94 */
95 resourceScope?: "group" | "all";
96}) =>
97 Effect.gen(function* () {
98 const op = yield* options.operation;
99
100 return Effect.fn(function* (group?: ScheduleGroup) {
101 const GroupName = group ? yield* group.scheduleGroupName : undefined;
102 if (!globalThis.__ALCHEMY_RUNTIME__) {
103 const host = yield* Binding.Host;
104 if (isBindingHost(host)) {
105 const pattern =
106 options.resourceScope === "all"
107 ? yield* allSchedulesArnPattern
108 : yield* scheduleArnPattern(group);
109 yield* host.bind`Allow(${host}, ${options.tag}(${group ?? "default"}))`(
110 {
111 policyStatements: [
112 {
113 Effect: "Allow",
114 Action: [...options.actions],
115 Resource: [pattern],
116 },
117 ],
118 },
119 );
120 }
121 }
122 return Effect.fn(`${options.tag}(${group?.LogicalId ?? "default"})`)(
123 function* (request?: Omit<I, "GroupName">) {
124 const groupName = GroupName
125 ? yield* GroupName
126 : options.fallbackGroupName;
127 return yield* op({
128 ...request,
129 GroupName: groupName,
130 } as I);
131 },
132 );
133 });
134 });
135
136/**
137 * Build the impl Effect for a Scheduler write operation
138 * (`CreateSchedule`/`UpdateSchedule`): constructed with the schedule
139 * execution role (plus an optional scoping {@link ScheduleGroup}), the
140 * deploy-time half grants `actions` on the group's schedule ARN pattern AND
141 * `iam:PassRole` on the execution role; the runtime half injects the group's
142 * name, defaults `FlexibleTimeWindow` to `{ Mode: "OFF" }`, and defaults the
143 * target's `RoleArn` to the bound execution role.
144 */
145export const makeScheduleWriteHttpBinding = <
146 I extends {
147 GroupName?: string;
148 Target: scheduler.Target;
149 FlexibleTimeWindow: scheduler.FlexibleTimeWindow;
150 },
151 A,
152 E,
153 R,
154>(options: {
155 /** Fully-qualified binding tag, e.g. `AWS.Scheduler.CreateSchedule`. */
156 tag: string;
157 /** The distilled operation; group, time window, and role are injected. */
158 operation: Effect.Effect<(input: I) => Effect.Effect<A, E>, never, R>;
159 /** IAM actions granted on the group's schedule ARN pattern. */
160 actions: readonly string[];
161}) =>
162 Effect.gen(function* () {
163 const op = yield* options.operation;
164
165 return Effect.fn(function* <Ro extends Role>(
166 executionRole: Ro,
167 group?: ScheduleGroup,
168 ) {
169 const RoleArn = yield* executionRole.roleArn;
170 const GroupName = group ? yield* group.scheduleGroupName : undefined;
171 if (!globalThis.__ALCHEMY_RUNTIME__) {
172 const host = yield* Binding.Host;
173 if (isBindingHost(host)) {
174 const pattern = yield* scheduleArnPattern(group);
175 yield* host.bind`Allow(${host}, ${options.tag}(${executionRole}, ${group ?? "default"}))`(
176 {
177 policyStatements: [
178 {
179 Effect: "Allow",
180 Action: [...options.actions],
181 Resource: [pattern],
182 },
183 // CRITICAL: without iam:PassRole on the execution role, the
184 // write fails only at runtime with an AccessDenied.
185 {
186 Effect: "Allow",
187 Action: ["iam:PassRole"],
188 Resource: [Output.interpolate`${executionRole.roleArn}`],
189 Condition: {
190 StringEquals: {
191 "iam:PassedToService": "scheduler.amazonaws.com",
192 },
193 },
194 },
195 ],
196 },
197 );
198 }
199 }
200 return Effect.fn(`${options.tag}(${executionRole.LogicalId})`)(function* (
201 request: Omit<I, "GroupName" | "Target" | "FlexibleTimeWindow"> & {
202 Target: Omit<scheduler.Target, "RoleArn"> & { RoleArn?: string };
203 FlexibleTimeWindow?: scheduler.FlexibleTimeWindow;
204 },
205 ) {
206 const roleArn = yield* RoleArn;
207 const groupName = GroupName ? yield* GroupName : undefined;
208 return yield* op({
209 ...request,
210 GroupName: groupName,
211 FlexibleTimeWindow: request.FlexibleTimeWindow ?? { Mode: "OFF" },
212 Target: {
213 ...request.Target,
214 RoleArn: request.Target.RoleArn ?? roleArn,
215 },
216 } as I);
217 });
218 });
219 });