1import * as memorydb from "@distilled.cloud/aws/memorydb";
2import * as Effect from "effect/Effect";
3import type * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import { readMemoryDbTags } from "./internal.ts";
14
15/**
16 * Authentication mode for a MemoryDB user.
17 */
18export interface UserAuthenticationMode {
19 /**
20 * How the user authenticates:
21 * - `"password"` — one or two SHA-256 passwords (supplied in `passwords`).
22 * - `"iam"` — IAM authentication; the username must match the IAM identity.
23 * - `"no-password"` — the user has no password (typically the default user).
24 */
25 type: "password" | "iam" | "no-password";
26 /**
27 * One or two passwords (16-128 printable characters each). Required when
28 * `type` is `"password"`. Passwords are write-only — the API never returns
29 * them, so changes to `passwords` alone are not auto-detected on update;
30 * change `type` (or replace the user) to force a credential reset.
31 */
32 passwords?: Redacted.Redacted<string>[];
33}
34
35export interface UserProps {
36 /**
37 * Name of the user. Must be 1-40 alphanumeric characters. If omitted, a
38 * deterministic physical name is generated. Changing the name replaces the
39 * user.
40 */
41 userName?: string;
42 /**
43 * How the user authenticates.
44 */
45 authenticationMode: UserAuthenticationMode;
46 /**
47 * Redis ACL access string describing the user's permissions, e.g.
48 * `"on ~* +@all"`. See the MemoryDB / Redis ACL documentation.
49 */
50 accessString: string;
51 /**
52 * User-defined tags for the user.
53 */
54 tags?: Record<string, string>;
55}
56
57export interface User extends Resource<
58 "AWS.MemoryDB.User",
59 UserProps,
60 {
61 /** Name of the user. */
62 userName: string;
63 /** ARN of the user. */
64 userArn: string;
65 /** Current lifecycle status (e.g. `active`, `modifying`). */
66 status: string;
67 /** Access string defining the user's permissions. */
68 accessString: string | undefined;
69 /** How the user authenticates (`password` or `iam`). */
70 authenticationType: string | undefined;
71 /** Minimum engine version the user is compatible with. */
72 minimumEngineVersion: string | undefined;
73 /** Tags on the user (user + internal Alchemy tags). */
74 tags: Record<string, string>;
75 },
76 never,
77 Providers
78> {}
79
80/**
81 * A MemoryDB user — an RBAC identity that authenticates to a cluster and is
82 * granted permissions through an access string. Users are grouped into
83 * {@link ACL}s, which are attached to clusters.
84 *
85 * Users are free and provision quickly. Passwords are write-only.
86 * ### Creating a User
87 * **Example:** Password User with Full Access
88 * ```typescript
89 * const user = yield* User("AppUser", {
90 * authenticationMode: { type: "password", passwords: [appPassword] },
91 * accessString: "on ~* +@all",
92 * });
93 * ```
94 *
95 * **Example:** IAM-Authenticated User
96 * ```typescript
97 * const user = yield* User("IamUser", {
98 * userName: "iam-app-user",
99 * authenticationMode: { type: "iam" },
100 * accessString: "on ~* +@all",
101 * });
102 * ```
103 *
104 * @resource
105 */
106export const User = Resource<User>("AWS.MemoryDB.User");
107
108export const UserProvider = () =>
109 Provider.effect(
110 User,
111 Effect.gen(function* () {
112 const toName = (id: string, props: Partial<UserProps>) =>
113 props.userName
114 ? Effect.succeed(props.userName)
115 : createPhysicalName({ id, maxLength: 40, lowercase: true });
116
117 const readUser = Effect.fn(function* (name: string) {
118 const response = yield* memorydb
119 .describeUsers({ UserName: name })
120 .pipe(
121 Effect.catchTag("UserNotFoundFault", () =>
122 Effect.succeed(undefined),
123 ),
124 );
125 return response?.Users?.[0];
126 });
127
128 // Wait for the user to leave a transitional (modifying) state so a
129 // subsequent modify/delete does not hit InvalidUserStateFault.
130 const waitUntilActive = Effect.fn(function* (name: string) {
131 return yield* readUser(name).pipe(
132 Effect.flatMap((user) => {
133 if (user !== undefined && user.Status === "modifying") {
134 return Effect.fail(
135 new Error(`User '${name}' still modifying (${user.Status})`),
136 );
137 }
138 return Effect.succeed(user);
139 }),
140 Effect.retry({
141 schedule: Schedule.max([
142 Schedule.fixed("5 seconds"),
143 Schedule.recurs(24),
144 ]),
145 }),
146 );
147 });
148
149 const toAttrs = Effect.fn(function* (user: memorydb.User) {
150 if (!user.Name || !user.ARN) {
151 return yield* Effect.fail(
152 new Error(`User '${user.Name}' is missing its ARN`),
153 );
154 }
155 return {
156 userName: user.Name,
157 userArn: user.ARN,
158 status: user.Status ?? "active",
159 accessString: user.AccessString,
160 authenticationType: user.Authentication?.Type,
161 minimumEngineVersion: user.MinimumEngineVersion,
162 tags: yield* readMemoryDbTags(user.ARN),
163 };
164 });
165
166 // Distilled types `Passwords` as sensitive — pass the Redacted values
167 // through as-is so they stay redacted in traces and logs.
168 const toWireAuth = (
169 mode: UserAuthenticationMode,
170 ): memorydb.AuthenticationMode => ({
171 Type: mode.type,
172 ...(mode.passwords ? { Passwords: mode.passwords } : {}),
173 });
174
175 return {
176 stables: ["userName", "userArn"],
177
178 diff: Effect.fn(function* ({ id, olds, news }) {
179 if (!isResolved(news)) return undefined;
180 if (
181 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
182 ) {
183 return { action: "replace" } as const;
184 }
185 }),
186
187 read: Effect.fn(function* ({ id, olds, output }) {
188 const name = output?.userName ?? (yield* toName(id, olds ?? {}));
189 const user = yield* readUser(name);
190 if (!user?.ARN) return undefined;
191 const attrs = yield* toAttrs(user);
192 return (yield* hasAlchemyTags(id, attrs.tags))
193 ? attrs
194 : Unowned(attrs);
195 }),
196
197 reconcile: Effect.fn(function* ({ id, news, output, session }) {
198 const props = news!;
199 const name = output?.userName ?? (yield* toName(id, props));
200 const internalTags = yield* createInternalTags(id);
201 const desiredTags = { ...internalTags, ...props.tags };
202
203 // 1. Observe — cloud state is authoritative.
204 let observed = yield* readUser(name);
205
206 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
207 if (observed === undefined) {
208 yield* memorydb
209 .createUser({
210 UserName: name,
211 AuthenticationMode: toWireAuth(props.authenticationMode),
212 AccessString: props.accessString,
213 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
214 Key,
215 Value,
216 })),
217 })
218 .pipe(
219 Effect.catchTag("UserAlreadyExistsFault", () => Effect.void),
220 Effect.catchTag("DuplicateUserNameFault", () => Effect.void),
221 );
222 observed = yield* waitUntilActive(name);
223 }
224 if (observed === undefined) {
225 return yield* Effect.fail(
226 new Error(`User '${name}' not found after create`),
227 );
228 }
229
230 // 3. Sync — access string is observable; auth passwords are not, so
231 // only re-apply the auth mode when its observable TYPE changed.
232 const update: memorydb.UpdateUserRequest = { UserName: name };
233 let mutated = false;
234 if (props.accessString !== observed.AccessString) {
235 update.AccessString = props.accessString;
236 mutated = true;
237 }
238 if (props.authenticationMode.type !== observed.Authentication?.Type) {
239 update.AuthenticationMode = toWireAuth(props.authenticationMode);
240 mutated = true;
241 }
242 if (mutated) {
243 yield* memorydb.updateUser(update);
244 observed = (yield* waitUntilActive(name)) ?? observed;
245 }
246
247 // 3b. Sync tags — diff against OBSERVED cloud tags.
248 const arn = observed.ARN;
249 if (arn) {
250 const observedTags = yield* readMemoryDbTags(arn);
251 const { removed, upsert } = diffTags(observedTags, desiredTags);
252 if (upsert.length > 0) {
253 yield* memorydb.tagResource({ ResourceArn: arn, Tags: upsert });
254 }
255 if (removed.length > 0) {
256 yield* memorydb.untagResource({
257 ResourceArn: arn,
258 TagKeys: removed,
259 });
260 }
261 }
262
263 yield* session.note(name);
264 return yield* toAttrs(observed);
265 }),
266
267 delete: Effect.fn(function* ({ output }) {
268 const name = output.userName;
269 // A user mid-modify rejects deletion — settle first (bounded), then
270 // delete. NotFound is success (idempotent delete).
271 yield* waitUntilActive(name).pipe(Effect.catch(() => Effect.void));
272 yield* memorydb.deleteUser({ UserName: name }).pipe(
273 Effect.catchTag("UserNotFoundFault", () => Effect.void),
274 Effect.retry({
275 while: (e) => e._tag === "InvalidUserStateFault",
276 schedule: Schedule.max([
277 Schedule.fixed("5 seconds"),
278 Schedule.recurs(12),
279 ]),
280 }),
281 );
282 }),
283
284 list: () =>
285 memorydb.describeUsers.pages({}).pipe(
286 Stream.runCollect,
287 Effect.map((chunk) =>
288 Array.from(chunk).flatMap((page) =>
289 (page.Users ?? []).filter(
290 (user) =>
291 user.Name !== undefined &&
292 user.ARN !== undefined &&
293 // `default` is the AWS-managed default user that always
294 // exists and can never be deleted — keep it out of
295 // enumeration for account-wide teardown (nuke).
296 user.Name !== "default",
297 ),
298 ),
299 ),
300 Effect.flatMap(
301 Effect.forEach((user) => toAttrs(user), { concurrency: 4 }),
302 ),
303 ),
304 };
305 }),
306 );