Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 42 additions & 41 deletions packages/cli/src/auth/credential-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ export interface FileCredentialManagerOptions {
}

/** Which credential this process acts as, decided at the first
* activeCredential() read. The decision is what is pinned; the
* activeCredential() read. The decision is what is held; the
* material behind it is re-read on every call. */
type Pin =
type ActingAs =
| { readonly kind: "unresolved" }
| { readonly kind: "environment" }
| { readonly kind: "session"; readonly workspaceId: string }
| { readonly kind: "none" };

type ResolvedPin = Exclude<Pin, { readonly kind: "unresolved" }>;
type ResolvedActingAs = Exclude<ActingAs, { readonly kind: "unresolved" }>;

/**
* The memory-backed storage, for a credential with no home record: a
Expand Down Expand Up @@ -106,7 +106,7 @@ function memoryBackedStorage(

/**
* The credential manager over one state file. Sessions are keyed by
* workspace id; which credential this process acts as is pinned once;
* workspace id; which credential this process acts as is decided once;
* every mutation takes a short file lock, re-reads, applies its slice,
* and writes atomically. Reads never write and take no lock.
*/
Expand All @@ -116,11 +116,11 @@ export class FileCredentialManager implements CredentialManager {
readonly #debug: DebugLog;
readonly #fetchWorkspaceName: FetchWorkspaceName | undefined;
readonly #refreshCredential: CredentialRefresher | undefined;
#pin: Pin = { kind: "unresolved" };
/** Built for one pinned credential. Every mutation that moves the
* pin discards it, so a command that mutates and then reaches for
* ctx.api cannot be handed storage for the credential it used to be
* acting as. */
#actingAs: ActingAs = { kind: "unresolved" };
/** Built for the credential the process acts as. Every mutation that
* changes that discards it, so a command that mutates and then
* reaches for ctx.api cannot be handed storage for the credential it
* used to be acting as. */
#activeStorage: TokenStorage | undefined;
#refreshLock: Promise<unknown> = Promise.resolve();

Expand All @@ -138,20 +138,20 @@ export class FileCredentialManager implements CredentialManager {
}

async activeCredential(): Promise<ActiveCredential | null> {
const pin = await this.#resolvePin();
const actingAs = await this.#resolveActingAs();

if (pin.kind === "environment") {
if (actingAs.kind === "environment") {
return environmentCredential(this.#requireEnvironmentToken());
}
const state = await readCredentialState(this.#filePath);
if (pin.kind === "none") {
if (actingAs.kind === "none") {
if (state.sessions.length > 0) {
throw credentialsRequiredError("sessions-held-none-selected");
}
return null;
}
const record = state.sessions.find(
(session) => session.workspaceId === pin.workspaceId,
(session) => session.workspaceId === actingAs.workspaceId,
);
if (record === undefined) {
throw credentialsRequiredError("session-ended");
Expand Down Expand Up @@ -204,7 +204,7 @@ export class FileCredentialManager implements CredentialManager {
});

if (!environmentInForce) {
this.#repin({ kind: "session", workspaceId });
this.#actAs({ kind: "session", workspaceId });
}

const name = await this.#lookUpWorkspaceName(credential, workspaceId);
Expand Down Expand Up @@ -238,7 +238,7 @@ export class FileCredentialManager implements CredentialManager {
return { state: next, result: toSession(record) };
});
if (!environmentInForce) {
this.#repin({ kind: "session", workspaceId });
this.#actAs({ kind: "session", workspaceId });
}
return selected;
}
Expand All @@ -254,8 +254,11 @@ export class FileCredentialManager implements CredentialManager {
: { result: undefined },
);

if (this.#pin.kind === "session" && this.#pin.workspaceId === workspaceId) {
this.#repin({ kind: "none" });
if (
this.#actingAs.kind === "session" &&
this.#actingAs.workspaceId === workspaceId
) {
this.#actAs({ kind: "none" });
}
}

Expand All @@ -270,7 +273,7 @@ export class FileCredentialManager implements CredentialManager {
await this.#reapLegacyContextFile();
await this.#reapOrphanedWrites();
if (!environmentInForce) {
this.#repin({ kind: "none" });
this.#actAs({ kind: "none" });
}
}

Expand All @@ -294,11 +297,12 @@ export class FileCredentialManager implements CredentialManager {
return readActiveAccessToken(storage, this.#refreshCredential, options);
}

/** §11.2: which storage is chosen once, when the pin resolves. Each
/** §11.2: which storage is chosen once, when the acting-as decision
* resolves. Each
* has exactly one source of truth — the file, or process memory. */
#buildActiveStorage(): TokenStorage {
const pin = this.#pin;
if (pin.kind === "environment") {
const actingAs = this.#actingAs;
if (actingAs.kind === "environment") {
return memoryBackedStorage(
{
token: this.#requireEnvironmentToken(),
Expand All @@ -308,8 +312,8 @@ export class FileCredentialManager implements CredentialManager {
(fn) => this.#withRefreshLock(fn),
);
}
if (pin.kind === "session") {
return this.#fileBackedStorage(pin.workspaceId);
if (actingAs.kind === "session") {
return this.#fileBackedStorage(actingAs.workspaceId);
}
throw new Error(
"@prisma/cli: activeCredentialStorage() is only valid once activeCredential() has returned non-null",
Expand Down Expand Up @@ -433,33 +437,30 @@ export class FileCredentialManager implements CredentialManager {
return run;
}

async #resolvePin(): Promise<ResolvedPin> {
const pinned = this.#pin;
if (pinned.kind !== "unresolved") return pinned;
async #resolveActingAs(): Promise<ResolvedActingAs> {
const decided = this.#actingAs;
if (decided.kind !== "unresolved") return decided;

if (this.#environmentToken() !== undefined) {
this.#debug("pinned to the environment credential");
return this.#pinTo({ kind: "environment" });
this.#debug("acting as the environment credential");
this.#actingAs = { kind: "environment" };
return { kind: "environment" };
}
const state = await readCredentialState(this.#filePath);
const selected = resolvedMarker(state);
this.#debug(`pinned to session ${selected ?? "(none)"}`);
return this.#pinTo(
this.#debug(`acting as session ${selected ?? "(none)"}`);
const resolved: ResolvedActingAs =
selected === null
? { kind: "none" }
: { kind: "session", workspaceId: selected },
);
}

#pinTo(pin: ResolvedPin): ResolvedPin {
this.#pin = pin;
return pin;
: { kind: "session", workspaceId: selected };
this.#actingAs = resolved;
return resolved;
}

/** Moves the pin after a mutation, discarding storage built for the
* credential this process was acting as before. */
#repin(pin: ResolvedPin): void {
this.#pin = pin;
/** Changes which credential the process acts as after a mutation,
* discarding storage built for the previous one. */
#actAs(next: ResolvedActingAs): void {
this.#actingAs = next;
this.#activeStorage = undefined;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/tests/credential-manager-processes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* The credential manager across real processes on a real filesystem:
* the short lock prevents lost updates, a crashed holder's lock is
* taken over, and a new process picks up the marker this one pinned
* taken over, and a new process picks up the marker this one moved
* away from.
*/
import { spawn } from "node:child_process";
Expand Down Expand Up @@ -291,7 +291,7 @@ describe("across processes", () => {
expect((await readCredentialState(stateFilePath)).sessions).toHaveLength(2);
}, 30_000);

it("gives a new process the marker this process pinned away from", async () => {
it("gives a new process the marker this process moved away from", async () => {
await runWorker("create", WORKSPACE_A, mintToken(WORKSPACE_A), "refresh-a");
await runWorker("create", WORKSPACE_B, mintToken(WORKSPACE_B), "refresh-b");

Expand Down
20 changes: 10 additions & 10 deletions packages/cli/tests/credential-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* The credential manager over its state file: the file format and its
* atomicity, process pinning, idempotent removal, what an environment
* atomicity, the acting-as decision, idempotent removal, what an environment
* credential can and cannot reach, and the two token storages.
*/
import nodeFs from "node:fs";
Expand Down Expand Up @@ -453,14 +453,14 @@ describe("the state file", () => {
});
});

describe("process pinning", () => {
it("pins the credential at the first read and keeps it when another process moves the selection", async () => {
describe("which credential the process acts as", () => {
it("decides at the first read and keeps acting as that credential when another process moves the selection", async () => {
await seedTwoSessions();
const manager = makeManager();
await makeManager().selectSession(WORKSPACE_A);

const pinned = await manager.activeCredential();
expect(pinned?.workspaceId).toBe(WORKSPACE_A);
const first = await manager.activeCredential();
expect(first?.workspaceId).toBe(WORKSPACE_A);

await makeManager().selectSession(WORKSPACE_B);

Expand All @@ -470,7 +470,7 @@ describe("process pinning", () => {
);
});

it("moves the pin on this process's own mutations", async () => {
it("acts as the new credential after this process's own mutations", async () => {
await seedTwoSessions();
const manager = makeManager();
expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_B);
Expand Down Expand Up @@ -503,7 +503,7 @@ describe("process pinning", () => {
);
});

it("fails with the session-ended error when another process ends the pinned session", async () => {
it("fails with the session-ended error when another process ends the session this one acts as", async () => {
await seedTwoSessions();
const manager = makeManager();
await manager.activeCredential();
Expand Down Expand Up @@ -578,7 +578,7 @@ describe("mutations while an environment credential is in force", () => {
/** Design §11.7 and §11.10 test 8: the refusals are gone. Selecting or
* ending a stored session changes stored state; this process keeps
* authenticating as the environment credential either way. */
it("lets every mutation through and leaves the pin on the environment credential", async () => {
it("lets every mutation through and keeps acting as the environment credential", async () => {
await seedTwoSessions();
const manager = makeManager({
env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_C) },
Expand Down Expand Up @@ -609,7 +609,7 @@ describe("mutations while an environment credential is in force", () => {
expect((await manager.activeCredential())?.workspaceId).toBe(WORKSPACE_C);
});

it("moves the stored selection without moving the pin", async () => {
it("moves the stored selection without changing what this process acts as", async () => {
await seedTwoSessions();
const manager = makeManager({
env: { PRISMA_SERVICE_TOKEN: mintToken(WORKSPACE_C) },
Expand Down Expand Up @@ -971,7 +971,7 @@ describe("the file-backed TokenStorage", () => {
expect(state.currentWorkspaceId).toBeNull();
});

it("clearTokens removes only the pinned record", async () => {
it("clearTokens removes only the record this process acts as", async () => {
const manager = makeManager();
await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A);
await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B);
Expand Down
Loading