1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import { isResolved } from "../Diff.ts";
6import { createPhysicalName } from "../PhysicalName.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import { Unowned } from "../AdoptPolicy.ts";
10import {
11 resolveBranchScope,
12 type BranchScope,
13 type ResolvedBranchScope,
14} from "./BranchScope.ts";
15import type { Providers } from "./Providers.ts";
16
17export type CredentialScope = Neon.CredentialScope;
18
19export type CredentialProps = BranchScope & {
20 /** Stable customer label. Omit to generate an ownership-qualified name. Explicit labels require delete-first replacement when scopes change. */
21 name?: string;
22 /** Branch-lineage permissions. Request storage:read explicitly for S3 reads; current data-plane behavior differs from the documented write-implies-read contract. Changes replace the credential. */
23 scopes: CredentialScope[];
24};
25
26export interface CredentialAttributes extends ResolvedBranchScope {
27 /** Opaque credential identifier, also the S3 access-key ID. */
28 tokenId: string;
29 /** Stable customer label. */
30 name: string;
31 /** Granted scopes, including any platform-added capabilities. */
32 scopes: string[];
33 /** Branch service bearer token, never the deployment account key. */
34 apiToken: Redacted.Redacted<string>;
35 /** S3 signing secret. */
36 s3SecretAccessKey: Redacted.Redacted<string>;
37 /** Issuance time. */
38 createdAt: string;
39}
40
41export interface Credential extends Resource<
42 "Neon.Credential",
43 CredentialProps,
44 CredentialAttributes,
45 never,
46 Providers
47> {}
48
49/**
50 * A customer-managed branch service credential. Credentials authorize their branch
51 * and descendants, not individual buckets. Reconciliation reveals existing secrets;
52 * it never rotates a credential. Unrecoverable legacy secrets require explicit action.
53 *
54 * ### Creating a Credential
55 * **Example:** Read-only storage access from an external application
56 * ```typescript
57 * const credential = yield* Neon.Credential("Reader", {
58 * branch,
59 * scopes: ["storage:read"],
60 * });
61 * ```
62 *
63 * @resource
64 * @product Credential
65 */
66export const Credential = Resource<Credential>("Neon.Credential");
67
68export class CredentialRecoveryError extends Data.TaggedError(
69 "CredentialRecoveryError",
70)<{
71 message: string;
72}> {}
73
74const sameScopes = (a: readonly string[], b: readonly string[]) =>
75 [...new Set(a)].sort().join("\n") === [...new Set(b)].sort().join("\n");
76
77const requestScope = (scope: ResolvedBranchScope) => ({
78 project_id: scope.projectId,
79 branch_id: scope.branchId,
80});
81
82const findCredential = Effect.fn(function* (
83 scope: ResolvedBranchScope,
84 name: string,
85 tokenId?: string,
86) {
87 const { credentials } = yield* Neon.listCredentials(requestScope(scope));
88 const matches = credentials.filter(
89 (credential) =>
90 !credential.revoked_at &&
91 credential.principal_type === "user" &&
92 (credential.branch_id === undefined ||
93 credential.branch_id === scope.branchId) &&
94 (tokenId ? credential.token_id === tokenId : credential.name === name),
95 );
96 if (matches.length > 1) {
97 return yield* new CredentialRecoveryError({
98 message: `Ambiguous credential label ${name}; refusing to choose a token`,
99 });
100 }
101 return matches[0];
102});
103
104const hydrateCredential = Effect.fn(function* (
105 scope: ResolvedBranchScope,
106 metadata: Neon.CredentialMeta,
107) {
108 const secret = yield* Neon.revealCredential({
109 ...requestScope(scope),
110 token_id: metadata.token_id,
111 }).pipe(
112 Effect.catchTag("Conflict", () =>
113 Effect.fail(
114 new CredentialRecoveryError({
115 message:
116 "Credential secrets cannot be revealed; explicitly rotate or replace this legacy credential",
117 }),
118 ),
119 ),
120 );
121 return {
122 ...scope,
123 tokenId: metadata.token_id,
124 name: metadata.name ?? "",
125 scopes: metadata.scopes,
126 apiToken: Redacted.isRedacted(secret.api_token)
127 ? secret.api_token
128 : Redacted.make(secret.api_token),
129 s3SecretAccessKey: Redacted.isRedacted(secret.s3_secret_access_key)
130 ? secret.s3_secret_access_key
131 : Redacted.make(secret.s3_secret_access_key),
132 createdAt: metadata.created_at,
133 } satisfies CredentialAttributes;
134});
135
136export const CredentialProvider = () =>
137 Provider.succeed(Credential, {
138 stables: ["projectId", "branchId", "name"],
139 diff: Effect.fn(function* ({ news, output }) {
140 if (!isResolved(news) || !output) return;
141 const scope = yield* resolveBranchScope(news);
142 if (
143 scope.projectId !== output.projectId ||
144 scope.branchId !== output.branchId ||
145 (news.name !== undefined && news.name !== output.name) ||
146 !sameScopes(news.scopes, output.scopes)
147 ) {
148 return {
149 action: "replace",
150 deleteFirst:
151 news.name !== undefined &&
152 news.name === output.name &&
153 scope.projectId === output.projectId &&
154 scope.branchId === output.branchId,
155 };
156 }
157 }),
158 read: Effect.fn(function* ({ id, olds, output }) {
159 if (!output && !olds?.branch && !olds?.project) return undefined;
160 const scope = output ?? (yield* resolveBranchScope(olds!));
161 const name =
162 output?.name ??
163 olds?.name ??
164 (yield* createPhysicalName({ id, maxLength: 100 }));
165 const metadata = yield* findCredential(scope, name, output?.tokenId);
166 if (!metadata) return undefined;
167 const attrs = yield* hydrateCredential(scope, metadata);
168 return !output && olds?.name !== undefined ? Unowned(attrs) : attrs;
169 }),
170 reconcile: Effect.fn(function* ({ id, news, output }) {
171 const scope = yield* resolveBranchScope(news);
172 const name =
173 news.name ??
174 output?.name ??
175 (yield* createPhysicalName({ id, maxLength: 100 }));
176 let metadata = yield* findCredential(scope, name, output?.tokenId);
177 if (!metadata && output) metadata = yield* findCredential(scope, name);
178 if (!metadata) {
179 const created = yield* Neon.createCredential({
180 ...requestScope(scope),
181 name,
182 scopes: news.scopes,
183 principal_type: "user",
184 });
185 return {
186 ...scope,
187 name,
188 tokenId: created.token_id,
189 scopes: created.scopes,
190 apiToken: Redacted.isRedacted(created.api_token)
191 ? created.api_token
192 : Redacted.make(created.api_token),
193 s3SecretAccessKey: Redacted.isRedacted(created.s3_secret_access_key)
194 ? created.s3_secret_access_key
195 : Redacted.make(created.s3_secret_access_key),
196 createdAt: created.created_at,
197 };
198 }
199 if (!sameScopes(metadata.scopes, news.scopes)) {
200 return yield* new CredentialRecoveryError({
201 message:
202 "Observed credential scopes differ from desired scopes; replace rather than rotate or adopt broader access",
203 });
204 }
205 return yield* hydrateCredential(scope, metadata);
206 }),
207 delete: Effect.fn(function* ({ output }) {
208 yield* Neon.revokeCredential({
209 ...requestScope(output),
210 token_id: output.tokenId,
211 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
212 }),
213 });
214
215/** Validate an override against the target branch's actual ancestry and required scope. */
216export const validateCredential = Effect.fn(function* (
217 credential: Pick<CredentialAttributes, "projectId" | "branchId" | "scopes">,
218 target: ResolvedBranchScope,
219 required: CredentialScope,
220) {
221 if (
222 credential.projectId !== target.projectId ||
223 !credential.scopes.includes(required)
224 ) {
225 return yield* new CredentialRecoveryError({
226 message: "Credential project or scope does not authorize this binding",
227 });
228 }
229 let branchId: string | undefined = target.branchId;
230 const seen = new Set<string>();
231 while (branchId) {
232 if (branchId === credential.branchId) return;
233 if (seen.has(branchId)) break;
234 seen.add(branchId);
235 const response: Neon.GetProjectBranchResponse =
236 yield* Neon.getProjectBranch({
237 project_id: target.projectId,
238 branch_id: branchId,
239 });
240 branchId = response.branch.parent_id;
241 }
242 return yield* new CredentialRecoveryError({
243 message: "Credential branch is not the target branch or an ancestor",
244 });
245});