1import * as dsql from "@distilled.cloud/aws/dsql";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface ClusterPolicyProps {
10 /**
11 * Identifier of the DSQL cluster the policy is attached to. A cluster has
12 * at most one resource-based policy. Changing the cluster replaces the
13 * policy.
14 */
15 clusterId: string;
16 /**
17 * The resource-based policy document (JSON) that defines access
18 * permissions and conditions for the cluster — e.g. denying
19 * `dsql:DbConnect` from outside a VPC, or restricting connections to an
20 * AWS Organization. Updated in place.
21 */
22 policy: string;
23 /**
24 * Skip the lockout safety check that prevents attaching a policy which
25 * would lock the calling principal out of `dsql:PutClusterPolicy` /
26 * `dsql:DeleteClusterPolicy` on the cluster.
27 * @default false
28 */
29 bypassPolicyLockoutSafetyCheck?: boolean;
30}
31
32export interface ClusterPolicy extends Resource<
33 "AWS.DSQL.ClusterPolicy",
34 ClusterPolicyProps,
35 {
36 /** Identifier of the cluster the policy is attached to. */
37 clusterId: string;
38 /** The policy document attached to the cluster. */
39 policy: string;
40 /** Version of the attached policy, used for optimistic concurrency. */
41 policyVersion: string;
42 },
43 never,
44 Providers
45> {}
46
47/**
48 * The resource-based policy of an Aurora DSQL cluster — controls which
49 * principals may perform actions on the cluster (most commonly gating
50 * `dsql:DbConnect` / `dsql:DbConnectAdmin` behind VPC or Organization
51 * conditions). A cluster has at most one.
52 *
53 * ### Creating a Cluster Policy
54 * **Example:** Block Connections from Outside a VPC
55 * ```typescript
56 * const cluster = yield* DSQL.Cluster("AppDb", {});
57 * const policy = yield* DSQL.ClusterPolicy("VpcOnly", {
58 * clusterId: cluster.clusterId,
59 * policy: JSON.stringify({
60 * Version: "2012-10-17",
61 * Statement: [
62 * {
63 * Effect: "Deny",
64 * Principal: { AWS: "*" },
65 * Action: ["dsql:DbConnect", "dsql:DbConnectAdmin"],
66 * Resource: "*",
67 * Condition: { Null: { "aws:SourceVpc": "true" } },
68 * },
69 * ],
70 * }),
71 * });
72 * ```
73 *
74 * **Example:** Restrict Access to an AWS Organization
75 * ```typescript
76 * const policy = yield* DSQL.ClusterPolicy("OrgOnly", {
77 * clusterId: cluster.clusterId,
78 * policy: JSON.stringify({
79 * Version: "2012-10-17",
80 * Statement: [
81 * {
82 * Effect: "Deny",
83 * Principal: { AWS: "*" },
84 * Action: ["dsql:DbConnect", "dsql:DbConnectAdmin"],
85 * Resource: "*",
86 * Condition: {
87 * StringNotEquals: { "aws:PrincipalOrgID": "o-exampleorgid" },
88 * },
89 * },
90 * ],
91 * }),
92 * });
93 * ```
94 *
95 * @resource
96 */
97export const ClusterPolicy = Resource<ClusterPolicy>("AWS.DSQL.ClusterPolicy");
98
99/** Order-insensitive canonical form of a JSON policy document. */
100const canonicalJson = (document: string): string => {
101 const sort = (value: unknown): unknown =>
102 Array.isArray(value)
103 ? value.map(sort)
104 : value !== null && typeof value === "object"
105 ? Object.fromEntries(
106 Object.entries(value as Record<string, unknown>)
107 .sort(([a], [b]) => a.localeCompare(b))
108 .map(([k, v]) => [k, sort(v)]),
109 )
110 : value;
111 try {
112 return JSON.stringify(sort(JSON.parse(document)));
113 } catch {
114 return document;
115 }
116};
117
118export const ClusterPolicyProvider = () =>
119 Provider.effect(
120 ClusterPolicy,
121 Effect.gen(function* () {
122 /** Read the policy; typed not-found → undefined. */
123 const readPolicy = Effect.fn(function* (identifier: string) {
124 return yield* dsql
125 .getClusterPolicy({ identifier })
126 .pipe(
127 Effect.catchTag("ResourceNotFoundException", () =>
128 Effect.succeed(undefined),
129 ),
130 );
131 });
132
133 const toAttrs = (
134 clusterId: string,
135 policy: string,
136 policyVersion: string,
137 ) => ({ clusterId, policy, policyVersion });
138
139 return {
140 stables: ["clusterId"],
141
142 diff: Effect.fn(function* ({ olds, news }) {
143 if (!isResolved(news)) return undefined;
144 if (olds?.clusterId !== news.clusterId) {
145 return { action: "replace" } as const;
146 }
147 }),
148
149 read: Effect.fn(function* ({ olds, output }) {
150 const clusterId = output?.clusterId ?? olds?.clusterId;
151 if (!clusterId) return undefined;
152 const existing = yield* readPolicy(clusterId);
153 if (existing === undefined) return undefined;
154 // Cluster policies are not taggable — ownership is implied by the
155 // owned parent cluster.
156 return toAttrs(clusterId, existing.policy, existing.policyVersion);
157 }),
158
159 reconcile: Effect.fn(function* ({ news, session }) {
160 const clusterId = news!.clusterId;
161
162 // 1. Observe — the live policy is authoritative.
163 const observed = yield* readPolicy(clusterId);
164
165 // 2/3. Ensure + sync — `putClusterPolicy` is a true upsert; call
166 // it only when the canonical document drifts. `expectedPolicyVersion`
167 // pins the observed version so a concurrent writer surfaces as a
168 // typed ConflictException instead of a silent overwrite.
169 if (
170 observed !== undefined &&
171 canonicalJson(observed.policy) === canonicalJson(news!.policy)
172 ) {
173 yield* session.note(clusterId);
174 return toAttrs(clusterId, observed.policy, observed.policyVersion);
175 }
176
177 const updated = yield* dsql.putClusterPolicy({
178 identifier: clusterId,
179 policy: news!.policy,
180 bypassPolicyLockoutSafetyCheck:
181 news!.bypassPolicyLockoutSafetyCheck,
182 expectedPolicyVersion: observed?.policyVersion,
183 });
184
185 yield* session.note(clusterId);
186 return toAttrs(clusterId, news!.policy, updated.policyVersion);
187 }),
188
189 delete: Effect.fn(function* ({ output }) {
190 yield* dsql
191 .deleteClusterPolicy({ identifier: output.clusterId })
192 .pipe(
193 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
194 Effect.retry({
195 while: (e) => e._tag === "ConflictException",
196 schedule: Schedule.max([
197 Schedule.fixed("3 seconds"),
198 Schedule.recurs(20),
199 ]),
200 }),
201 );
202 }),
203
204 // Singleton sub-resource keyed by its parent cluster.
205 list: () => Effect.succeed([]),
206 };
207 }),
208 );