1import * as Neon from "@distilled.cloud/neon";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../AdoptPolicy.ts";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import {
8 GovernanceRoleSafetyError,
9 governanceRoleTransition,
10 listGovernanceOrganizationMembers,
11 sameOrganizationRole,
12 requireGovernanceBaseline,
13 validateGovernanceActor,
14 validateGovernanceScope,
15 type GovernanceRoleBaseline,
16} from "./OrganizationMemberRole.ts";
17import type { Providers } from "./Providers.ts";
18
19/** Direct project roles supported by Neon. */
20export type ProjectGovernanceRole = "viewer" | "editor" | "admin";
21
22export interface ProjectMemberRoleProps {
23 /** Organization owning both the existing membership and project. */
24 orgId: string;
25 /** Existing organization membership ID, never a user ID or email. */
26 memberId: string;
27 /** Already-deployed organization project. Supply its resolved ID on first deployment. */
28 project: { projectId: string };
29 /** Direct project grant to manage; does not replace organization defaults. */
30 role: ProjectGovernanceRole;
31}
32
33export interface ProjectMemberRoleAttributes {
34 /** Project containing the direct grant. */
35 projectId: string;
36 /** Organization owning the project and member. */
37 orgId: string;
38 /** Existing membership receiving the direct grant. */
39 memberId: string;
40 /** Observed direct grant; null means no explicit grant, not no effective access. */
41 role: ProjectGovernanceRole | null;
42 /** Original direct grant and ownership evidence. Null originalRole means remove only our grant. */
43 baseline: GovernanceRoleBaseline<ProjectGovernanceRole | null>;
44}
45
46export interface ProjectMemberRole extends Resource<
47 "Neon.ProjectMemberRole",
48 ProjectMemberRoleProps,
49 ProjectMemberRoleAttributes,
50 never,
51 Providers
52> {}
53
54/**
55 * Own one explicit project grant for an existing organization member.
56 * Inherited/default access is not owned or removed. Adopting an existing direct
57 * grant requires explicit adoption and captures it for exact restoration.
58 * Deletion removes only a newly created direct grant, or restores the adopted one.
59 * Organization-admin overrides and self changes are refused. This resource never
60 * enables confirm_self_demotion or confirm_self_lockout and never invites anyone.
61 *
62 * Requires user credentials with observable ADMIN project permission. A missing
63 * member, ambiguous grant source, or list failure is not evidence of no grant.
64 * The list API's 404 can mean disabled permissions or insufficient access.
65 * Cleanup accepts a removed membership only after a complete organization listing.
66 * A missing project additionally requires an organization-admin scoped project
67 * listing with no unavailable entries; an ambiguous 404 alone never discards state.
68 *
69 * The engine durably saves the initial read snapshot before reconciliation.
70 * Deploy the project first, then supply its resolved ID and resolved settings.
71 * Missing baselines and ambiguous interrupted writes fail closed. Do not discard
72 * state or edit this grant concurrently: Neon has no conditional role-write API.
73 *
74 * ### Grant access to an existing member
75 * **Example:** Manage a direct grant on an already-deployed project
76 * ```typescript
77 * const access = yield* Neon.ProjectMemberRole("ReviewerAccess", {
78 * orgId: "org-example",
79 * memberId: authorizedMemberId,
80 * project: { projectId: deployedProjectId },
81 * role: "viewer",
82 * });
83 * ```
84 *
85 * ### Adopt a direct grant
86 * **Example:** Preserve and restore an existing explicit role
87 * ```typescript
88 * const access = yield* Neon.ProjectMemberRole("ExistingAccess", {
89 * orgId: "org-example",
90 * memberId: authorizedMemberId,
91 * project: { projectId: deployedProjectId },
92 * role: "editor",
93 * }).pipe(Alchemy.adopt(true));
94 * ```
95 *
96 * @resource
97 * @product Project
98 */
99export const ProjectMemberRole = Resource<ProjectMemberRole>(
100 "Neon.ProjectMemberRole",
101);
102
103/** Validate persisted or desired project grants. @internal */
104export const validateProjectGovernanceRole = (
105 role: ProjectGovernanceRole | null,
106) =>
107 role === null || role === "viewer" || role === "editor" || role === "admin"
108 ? Effect.void
109 : Effect.fail(
110 new GovernanceRoleSafetyError({
111 message: "Unsupported project grant role",
112 }),
113 );
114
115/** Distinguish direct access from the effective role and organization defaults. @internal */
116export const projectMemberDirectRole = (member: Neon.ProjectMember) => {
117 if (
118 member.org_role === "admin" ||
119 member.grant_source === "org_admin_override"
120 ) {
121 return Effect.fail(
122 new GovernanceRoleSafetyError({
123 message:
124 "Organization-admin project overrides cannot be managed as direct grants",
125 }),
126 );
127 }
128 const permission = member.explicit_project_permission;
129 if (member.grant_source === "explicit") {
130 if (permission === "VIEWER") return Effect.succeed("viewer" as const);
131 if (permission === "EDITOR") return Effect.succeed("editor" as const);
132 if (permission === "ADMIN") return Effect.succeed("admin" as const);
133 } else if (
134 (member.grant_source === "org_role_default" ||
135 member.grant_source === "unassigned") &&
136 permission === undefined
137 ) {
138 return Effect.succeed(null);
139 }
140 return Effect.fail(
141 new GovernanceRoleSafetyError({
142 message:
143 "Project grant source and explicit permission do not establish a recoverable direct grant",
144 }),
145 );
146};
147
148const validateProps = Effect.fn(function* (props: ProjectMemberRoleProps) {
149 yield* validateGovernanceScope(props);
150 if (
151 !props.project?.projectId ||
152 typeof props.project.projectId !== "string"
153 ) {
154 return yield* new GovernanceRoleSafetyError({
155 message: "An explicit resolved existing project ID is required",
156 });
157 }
158 yield* validateProjectGovernanceRole(props.role);
159 if (props.role === null)
160 return yield* new GovernanceRoleSafetyError({
161 message: "A desired direct project role is required",
162 });
163});
164
165const request = (scope: { projectId: string; memberId: string }) => ({
166 project_id: scope.projectId,
167 member_id: scope.memberId,
168});
169
170const observe = Effect.fn(function* (
171 scope: { orgId: string; memberId: string; projectId: string },
172 listedMembership?: Neon.Member,
173) {
174 yield* validateGovernanceScope(scope);
175 const { project } = yield* Neon.getProject({ project_id: scope.projectId });
176 if (
177 project.org_id !== scope.orgId ||
178 project.effective_project_permission !== "ADMIN"
179 ) {
180 return yield* new GovernanceRoleSafetyError({
181 message:
182 "Project organization and caller ADMIN permission must be observable before managing grants",
183 });
184 }
185 const membership =
186 listedMembership ??
187 (yield* Neon.getOrganizationMember({
188 org_id: scope.orgId,
189 member_id: scope.memberId,
190 }));
191 if (membership.org_id !== scope.orgId || membership.id !== scope.memberId) {
192 return yield* new GovernanceRoleSafetyError({
193 message: "Membership identity does not match the project organization",
194 });
195 }
196 let cursor: string | undefined;
197 const seen = new Set<string>();
198 for (let page = 0; page < 10; page++) {
199 const response = yield* Neon.listProjectMembers({
200 project_id: scope.projectId,
201 cursor,
202 limit: 500,
203 });
204 const member = response.project_members.find(
205 (member) => member.member_id === scope.memberId,
206 );
207 if (member) {
208 if (
209 member.user_id !== membership.user_id ||
210 member.org_role === undefined ||
211 !sameOrganizationRole(member.org_role, membership.role) ||
212 !member.user_id
213 ) {
214 return yield* new GovernanceRoleSafetyError({
215 message:
216 "Project member does not match the observed organization membership",
217 });
218 }
219 return {
220 role: yield* projectMemberDirectRole(member),
221 userId: member.user_id,
222 };
223 }
224 const next = response.pagination?.next;
225 if (!next) break;
226 if (seen.has(next))
227 return yield* new GovernanceRoleSafetyError({
228 message: "Project membership pagination repeated a cursor",
229 });
230 seen.add(next);
231 cursor = next;
232 }
233 return yield* new GovernanceRoleSafetyError({
234 message:
235 "Member is not visible in a complete bounded project-member listing; refusing to infer an absent grant",
236 });
237});
238
239const projectAbsenceProven = Effect.fn(function* (
240 scope: { orgId: string; projectId: string },
241 members: readonly Neon.Member[],
242) {
243 const actor = yield* Neon.getCurrentUserInfo({});
244 if (
245 !members.some(
246 (member) => member.user_id === actor.id && member.role === "admin",
247 )
248 ) {
249 return yield* new GovernanceRoleSafetyError({
250 message:
251 "Project absence requires an organization-admin listing; restricted project visibility is not proof of deletion",
252 });
253 }
254 const seen = new Set<string>();
255 let cursor: string | undefined;
256 for (let page = 0; page < 10; page++) {
257 const response = yield* Neon.listProjects({
258 org_id: scope.orgId,
259 cursor,
260 limit: 400,
261 });
262 if (response.unavailable_project_ids?.length) {
263 return yield* new GovernanceRoleSafetyError({
264 message:
265 "Project listing contains unavailable projects; absence is unproven",
266 });
267 }
268 if (
269 response.projects.some(
270 (project) =>
271 project.org_id !== undefined && project.org_id !== scope.orgId,
272 )
273 ) {
274 return yield* new GovernanceRoleSafetyError({
275 message: "Scoped project listing returned a different organization",
276 });
277 }
278 if (response.projects.some((project) => project.id === scope.projectId))
279 return false;
280 if (response.projects.length === 0) return true;
281 const next = response.pagination?.cursor;
282 if (next === undefined) return true;
283 if (!next || seen.has(next)) {
284 return yield* new GovernanceRoleSafetyError({
285 message:
286 "Scoped project listing returned an invalid or repeated cursor",
287 });
288 }
289 seen.add(next);
290 cursor = next;
291 }
292 return yield* new GovernanceRoleSafetyError({
293 message:
294 "Scoped project listing exceeded its bounded pagination limit; absence is unproven",
295 });
296});
297
298const verifyBaseline = Effect.fn(function* (
299 output: ProjectMemberRoleAttributes | undefined,
300 owner: { fqn: string; instanceId: string },
301) {
302 const baseline = yield* requireGovernanceBaseline(output?.baseline, owner);
303 yield* validateProjectGovernanceRole(baseline.originalRole);
304 yield* validateProjectGovernanceRole(baseline.managedRole);
305 return baseline;
306});
307
308export const ProjectMemberRoleProvider = () =>
309 Provider.succeed(ProjectMemberRole, {
310 nuke: { skip: true },
311 stables: ["orgId", "memberId", "projectId"],
312 diff: Effect.fn(function* ({ news, olds, output }) {
313 if (!isResolved(news)) return;
314 yield* validateProps(news);
315 if (
316 news.orgId !== (output?.orgId ?? olds.orgId) ||
317 news.memberId !== (output?.memberId ?? olds.memberId) ||
318 news.project.projectId !== (output?.projectId ?? olds.project.projectId)
319 )
320 return yield* new GovernanceRoleSafetyError({
321 message:
322 "Restore and remove the existing grant control before changing its project or membership identity; a new durable grant snapshot is required",
323 });
324 }),
325 read: Effect.fn(function* ({ olds, output, fqn, instanceId }) {
326 if (!output) yield* validateProps(olds);
327 const scope = output ?? {
328 orgId: olds.orgId,
329 memberId: olds.memberId,
330 projectId: olds.project.projectId,
331 };
332 const baseline = output
333 ? yield* verifyBaseline(output, { fqn, instanceId })
334 : undefined;
335 const observed = yield* observe(scope);
336 if (output && baseline) {
337 if (observed.userId !== baseline.userId)
338 return yield* new GovernanceRoleSafetyError({
339 message: "Membership user changed",
340 });
341 yield* governanceRoleTransition(baseline, observed.role, observed.role);
342 return { ...output, role: observed.role };
343 }
344 const attributes: ProjectMemberRoleAttributes = {
345 orgId: scope.orgId,
346 memberId: scope.memberId,
347 projectId: scope.projectId,
348 role: observed.role,
349 baseline: {
350 fqn,
351 instanceId,
352 userId: observed.userId,
353 originalRole: observed.role,
354 managedRole: observed.role,
355 },
356 };
357 return observed.role === null ? attributes : Unowned(attributes);
358 }),
359 reconcile: Effect.fn(function* ({ news, output, fqn, instanceId }) {
360 yield* validateProps(news);
361 const baseline = yield* verifyBaseline(output, { fqn, instanceId });
362 if (!output)
363 return yield* new GovernanceRoleSafetyError({
364 message:
365 "A persisted initial grant snapshot is required before changing project access",
366 });
367 if (
368 news.orgId !== output.orgId ||
369 news.memberId !== output.memberId ||
370 news.project.projectId !== output.projectId
371 ) {
372 return yield* new GovernanceRoleSafetyError({
373 message: "Project grant identity changed without replacement",
374 });
375 }
376 const observed = yield* observe(output);
377 if (observed.userId !== baseline.userId)
378 return yield* new GovernanceRoleSafetyError({
379 message: "Membership user changed",
380 });
381 if (yield* governanceRoleTransition(baseline, observed.role, news.role)) {
382 yield* validateGovernanceActor(
383 observed.userId,
384 (yield* Neon.getCurrentUserInfo({})).id,
385 );
386 yield* Neon.setProjectMemberRole({
387 ...request(output),
388 role: news.role,
389 confirm_self_demotion: false,
390 });
391 }
392 const current = yield* observe(output);
393 if (current.userId !== baseline.userId || current.role !== news.role) {
394 return yield* new GovernanceRoleSafetyError({
395 message: "Direct project grant did not converge",
396 });
397 }
398 return {
399 ...output,
400 role: current.role,
401 baseline: { ...baseline, managedRole: current.role },
402 };
403 }),
404 delete: Effect.fn(function* ({ output, fqn, instanceId }) {
405 const baseline = yield* verifyBaseline(output, { fqn, instanceId });
406 yield* validateGovernanceScope(output);
407 const members = yield* listGovernanceOrganizationMembers(output.orgId);
408 const membership = members.find(
409 (member) => member.id === output.memberId,
410 );
411 if (!membership) return;
412 if (membership.user_id !== baseline.userId) {
413 return yield* new GovernanceRoleSafetyError({
414 message: "Membership user changed",
415 });
416 }
417 const observed = yield* observe(output, membership).pipe(
418 Effect.catchTag("NotFound", (error) =>
419 Effect.gen(function* () {
420 if (yield* projectAbsenceProven(output, members)) return undefined;
421 return yield* Effect.fail(error);
422 }),
423 ),
424 );
425 if (!observed) return;
426 if (observed.userId !== baseline.userId)
427 return yield* new GovernanceRoleSafetyError({
428 message: "Membership user changed",
429 });
430 if (
431 yield* governanceRoleTransition(
432 baseline,
433 observed.role,
434 baseline.originalRole,
435 true,
436 )
437 ) {
438 yield* validateGovernanceActor(
439 observed.userId,
440 (yield* Neon.getCurrentUserInfo({})).id,
441 );
442 if (baseline.originalRole === null) {
443 yield* Neon.removeProjectMemberRole({
444 ...request(output),
445 confirm_self_lockout: false,
446 });
447 } else {
448 yield* Neon.setProjectMemberRole({
449 ...request(output),
450 role: baseline.originalRole,
451 confirm_self_demotion: false,
452 });
453 }
454 const restored = yield* observe(output);
455 if (
456 restored.userId !== baseline.userId ||
457 restored.role !== baseline.originalRole
458 ) {
459 return yield* new GovernanceRoleSafetyError({
460 message: "Original explicit project grant was not restored",
461 });
462 }
463 }
464 }),
465 });