Skip to content
Source
Sign inSign up
Open History to see the last commit

Role.ts

200 lines7.8 KB

1import * as Neon from "@distilled.cloud/neon";
2import { Unowned } from "alchemy/AdoptPolicy";
3import { isResolved } from "alchemy/Diff";
4import { createPhysicalName } from "alchemy/PhysicalName";
5import * as Provider from "alchemy/Provider";
6import { Resource } from "alchemy/Resource";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
9import { resolveBranchScope, type BranchScope, type ResolvedBranchScope } from "./BranchScope.ts";
10import { waitForOperations } from "./Project.ts";
11import type { Providers } from "./Providers.ts";
12​
13export type RoleProps = BranchScope & {
14 /**
15 * Postgres role name, at most 63 bytes. Omit to generate a unique,
16 * lowercase name. Changing an explicit name replaces the role.
17 */
18 name?: string;
19 /**
20 * Create a role that cannot log in, for example a group role that owns
21 * objects and is granted to login roles with SQL. Changes replace the role.
22 * @default false
23 */
24 noLogin?: boolean;
25};
26​
27export interface RoleAttributes extends ResolvedBranchScope {
28 /** Postgres role name. */
29 roleName: string;
30 /** Role password; `undefined` for a role created with `noLogin`. */
31 password: Redacted.Redacted<string> | undefined;
32 /** Whether the role cannot log in. */
33 noLogin: boolean;
34 /** Authentication method reported by Neon: `password`, `oauth`, or `no_login`. */
35 authenticationMethod: string | undefined;
36 /** Creation time. */
37 createdAt: string;
39​
40export interface Role extends Resource<"Neon.Role", RoleProps, RoleAttributes, never, Providers> {}
41​
42/**
43 * A Postgres role on a Neon branch. Neon makes every role it creates a
44 * member of `neon_superuser`: it can read and write all data, create
45 * databases and roles, and bypass row-level security. Use roles for
46 * separate logins and passwords, not to restrict access.
47 *
48 * ### Creating a Role
49 * **Example:** A login role on a branch
50 * ```typescript
51 * const app = yield* Neon.Role("App", { branch });
52 * // app.roleName and app.password (Redacted) connect to branch.origin.host
53 * ```
54 *
55 * **Example:** A role on the project's default branch
56 * ```typescript
57 * const migrator = yield* Neon.Role("Migrator", { project, name: "migrator" });
58 * ```
59 *
60 * ### Group Roles
61 * **Example:** A role that cannot log in
62 * ```typescript
63 * const owner = yield* Neon.Role("Owner", { branch, name: "app_owner", noLogin: true });
64 * ```
65 *
66 * @resource
67 * @product Role
68 */
69export const Role = Resource<Role>("Neon.Role");
70​
71const requestScope = (scope: ResolvedBranchScope) => ({
72 project_id: scope.projectId,
73 branch_id: scope.branchId,
74});
75​
76const redact = (password: string | Redacted.Redacted<string>) =>
77 Redacted.isRedacted(password) ? password : Redacted.make(password);
78​
79// Underscores keep generated names usable unquoted in SQL (`GRANT ... TO app_xyz`).
80const createRoleName = (id: string, name: string | undefined) =>
81 Effect.gen(function* () {
82 if (name !== undefined) return name;
83 const generated = yield* createPhysicalName({ id, maxLength: 63, lowercase: true });
84 return generated.replaceAll("-", "_");
85 });
86​
87const observeRole = Effect.fn(function* (scope: ResolvedBranchScope, roleName: string) {
88 return yield* Neon.getProjectBranchRole({ ...requestScope(scope), role_name: roleName }).pipe(
89 Effect.map(({ role }) => role),
90 Effect.catchTag("NotFound", () => Effect.undefined),
91 );
92});
93​
94const isNoLogin = (role: Neon.Role) => role.authentication_method === "no_login";
95​
96/** Read a login role's stored password, resetting it when Neon no longer has it. */
97const revealPassword = Effect.fn(function* (scope: ResolvedBranchScope, role: Neon.Role) {
98 if (isNoLogin(role)) return undefined;
99 const request = { ...requestScope(scope), role_name: role.name };
100 const stored = yield* Neon.getProjectBranchRolePassword(request).pipe(
101 Effect.map(({ password }) => redact(password)),
102 Effect.catchTag("NotFound", () => Effect.undefined),
103 );
104 if (stored) return stored;
105 const reset = yield* Neon.resetProjectBranchRolePassword(request);
106 yield* waitForOperations(reset.operations);
107 return reset.role.password === undefined ? undefined : redact(reset.role.password);
108});
109​
110const toAttributes = (
111 scope: ResolvedBranchScope,
112 role: Neon.Role,
113 password: Redacted.Redacted<string> | undefined,
114): RoleAttributes => ({
115 ...scope,
116 roleName: role.name,
117 password,
118 noLogin: isNoLogin(role),
119 authenticationMethod: role.authentication_method,
120 createdAt: role.created_at,
121});
122​
123export const RoleProvider = () =>
124 Provider.succeed(Role, {
125 stables: ["projectId", "branchId", "roleName"],
126 diff: Effect.fn(function* ({ news, output }) {
127 if (!isResolved(news) || !output) return;
128 const scope = yield* resolveBranchScope(news);
129 const sameScope = scope.projectId === output.projectId && scope.branchId === output.branchId;
130 if (
131 !sameScope ||
132 (news.name !== undefined && news.name !== output.roleName) ||
133 (news.noLogin ?? false) !== output.noLogin
134 ) {
135 return {
136 action: "replace",
137 // A generated name changes with the new instance; only an explicit,
138 // unchanged name must free itself before the replacement is created.
139 deleteFirst: sameScope && news.name !== undefined && news.name === output.roleName,
140 };
141 }
142 }),
143 read: Effect.fn(function* ({ id, olds, output, previous }) {
144 if (!output && !olds?.branch && !olds?.project) return undefined;
145 const scope = output ?? (yield* resolveBranchScope(olds!));
146 const roleName = output?.roleName ?? (yield* createRoleName(id, olds?.name));
147 const role = yield* observeRole(scope, roleName);
148 if (!role) return undefined;
149 if (output) return toAttributes(scope, role, output.password);
150 // The generation a delete-first replacement is about to free.
151 if (
152 previous?.projectId === scope.projectId &&
153 previous.branchId === scope.branchId &&
154 previous.roleName === role.name
155 ) {
156 return toAttributes(scope, role, previous.password);
157 }
158 // Only an explicit name can collide with a role this stack did not create.
159 const attrs = toAttributes(scope, role, yield* revealPassword(scope, role));
160 return olds?.name !== undefined ? Unowned(attrs) : attrs;
161 }),
162 reconcile: Effect.fn(function* ({ id, news, output }) {
163 const scope = yield* resolveBranchScope(news);
164 const roleName = news.name ?? output?.roleName ?? (yield* createRoleName(id, undefined));
165 const existing = yield* observeRole(scope, roleName);
166 if (!existing) {
167 const created = yield* Neon.createProjectBranchRole({
168 ...requestScope(scope),
169 role: { name: roleName, ...(news.noLogin ? { no_login: true } : {}) },
170 }).pipe(Effect.catchTag("Conflict", () => Effect.undefined));
171 if (created) {
172 yield* waitForOperations(created.operations);
173 return toAttributes(
174 scope,
175 created.role,
176 created.role.password === undefined ? undefined : redact(created.role.password),
177 );
178 }
179 }
180 // An existing role: keep the password we already hold, otherwise reveal it.
181 const role =
182 existing ??
183 (yield* Neon.getProjectBranchRole({
184 ...requestScope(scope),
185 role_name: roleName,
186 })).role;
187 const password =
188 output?.roleName === role.name && output.password !== undefined
189 ? output.password
190 : yield* revealPassword(scope, role);
191 return toAttributes(scope, role, password);
192 }),
193 delete: Effect.fn(function* ({ output }) {
194 const deleted = yield* Neon.deleteProjectBranchRole({
195 ...requestScope(output),
196 role_name: output.roleName,
197 }).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
198 if (deleted) yield* waitForOperations(deleted.operations);
199 }),
200 });