Skip to content

Commit dee25a5

Browse files
feat(auth): add workspace env override
1 parent 3919db7 commit dee25a5

11 files changed

Lines changed: 1257 additions & 70 deletions

File tree

docs/product/command-spec.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,19 @@ Out of scope for the current beta:
6363

6464
## Authentication
6565

66-
The CLI accepts two authentication sources, in this fixed precedence:
66+
The CLI accepts these authentication inputs, in this fixed precedence:
6767

6868
1. `PRISMA_SERVICE_TOKEN` environment variable — long-lived service token, intended for CI and other headless contexts.
69-
2. Stored OAuth session — created by `prisma-cli auth login`, kept in the OS-appropriate credentials store, refreshed automatically.
69+
2. `PRISMA_CLI_WORKSPACE_ID` environment variable — process-local selector for one locally authenticated OAuth workspace.
70+
3. Stored active OAuth workspace — created by `prisma-cli auth login`, kept in the OS-appropriate credentials store, refreshed automatically.
7071

7172
Stored OAuth sessions include a short-lived access token and a refresh token. The local credentials store may contain OAuth grants for multiple workspaces. One local active workspace pointer selects which grant authenticated commands use. Commands refresh the selected access token automatically when the API rejects it, coordinate refreshes across concurrent CLI processes, and tolerate short refresh-token rotation races. If the selected session cannot be refreshed, commands fail with a structured `AUTH_REQUIRED` error instead of surfacing SDK stack traces or silently falling through to another workspace.
7273

7374
When `PRISMA_SERVICE_TOKEN` is set and non-empty, the token is fully sufficient for authenticated commands. If `PRISMA_SERVICE_TOKEN` is set but empty or only whitespace, commands fail with an auth configuration error instead of falling back to stored OAuth. The CLI does not read any locally stored OAuth session when a non-empty service token is present, so behavior is identical on a fresh runner and a developer machine that happens to be signed in. The active workspace is derived from the token's `sub` claim; no additional flag or environment variable is required for the common case where the token is scoped to a single workspace.
7475

75-
`auth login`, `auth logout`, and `auth workspace` operate on stored OAuth sessions. They do not affect the `PRISMA_SERVICE_TOKEN` environment variable. `auth login` stores the authorized workspace and makes it active. `auth logout` clears all local OAuth workspace sessions. `auth workspace logout` and `auth logout --workspace` clear one local OAuth workspace session, including while `PRISMA_SERVICE_TOKEN` is set, because they only clean local OAuth state. If that workspace was active, the CLI does not silently fall through to another cached workspace; the user must explicitly choose the next workspace with `auth workspace use`. `auth workspace use` changes only local CLI context and never mutates a remote resource. When `PRISMA_SERVICE_TOKEN` is set, workspace switching is unavailable because the token is the active auth source.
76+
When `PRISMA_CLI_WORKSPACE_ID` is set and `PRISMA_SERVICE_TOKEN` is not set, authenticated commands use the matching locally stored OAuth workspace for that process only. The value matches a workspace id or canonical workspace id from `auth workspace list`, including the same id with or without a `wksp_` prefix; it does not match workspace names. This environment variable does not mutate the stored active workspace pointer, which makes it suitable for parallel agents or scripts that should not fight over shared CLI state. If it is empty, ambiguous, or does not match a locally authenticated workspace, commands fail with a structured auth error instead of falling back to another workspace.
77+
78+
`auth login`, `auth logout`, and `auth workspace` operate on stored OAuth sessions. They do not affect the `PRISMA_SERVICE_TOKEN` environment variable. `auth login` stores the authorized workspace and makes it active. `auth logout` clears all local OAuth workspace sessions. `auth workspace logout` and `auth logout --workspace` clear one local OAuth workspace session, including while `PRISMA_SERVICE_TOKEN` is set, because they only clean local OAuth state. If that workspace was active, the CLI does not silently fall through to another cached workspace; the user must explicitly choose the next workspace with `auth workspace use`. `auth workspace use` changes only local CLI context and never mutates a remote resource; its output also includes a `PRISMA_CLI_WORKSPACE_ID=<id> prisma-cli project list` example for process-local use. When `PRISMA_SERVICE_TOKEN` is set, workspace switching is unavailable because the token is the active auth source.
7679

7780
## Context Resolution
7881

packages/cli/src/adapters/token-storage.ts

Lines changed: 145 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import fs from "node:fs/promises";
44
import path from "node:path";
55
import { CredentialsStore } from "@prisma/credentials-store";
66
import type { TokenStorage, Tokens } from "@prisma/management-api-sdk";
7-
import { getAuthFilePath } from "../lib/auth/client";
7+
import {
8+
getAuthFilePath,
9+
getWorkspaceIdOverride,
10+
WORKSPACE_ID_ENV_VAR,
11+
} from "../lib/auth/client";
812

913
interface StoredCredential {
1014
workspaceId?: unknown;
@@ -26,6 +30,10 @@ export interface StoredAuthWorkspaceLogout {
2630
activeWorkspace: StoredAuthWorkspace | null;
2731
}
2832

33+
interface ListWorkspacesOptions {
34+
migrateAuthContext?: boolean;
35+
}
36+
2937
interface AuthContextWorkspace {
3038
id?: unknown;
3139
name?: unknown;
@@ -162,7 +170,7 @@ export class FileTokenStorage implements TokenStorage {
162170
private readonly lockFilePath: string;
163171

164172
constructor(
165-
env: NodeJS.ProcessEnv = process.env,
173+
private readonly env: NodeJS.ProcessEnv = process.env,
166174
private readonly signal?: AbortSignal,
167175
private readonly options: FileTokenStorageOptions = {},
168176
) {
@@ -176,8 +184,18 @@ export class FileTokenStorage implements TokenStorage {
176184
async getTokens(): Promise<Tokens | null> {
177185
this.signal?.throwIfAborted();
178186
try {
187+
const workspaceIdOverride = getWorkspaceIdOverride(this.env);
179188
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
180189
const credentials = await this.readCredentialsFromDisk();
190+
191+
if (workspaceIdOverride) {
192+
const context = await this.readAuthContext();
193+
return this.selectWorkspaceTokens(credentials, context, {
194+
ref: workspaceIdOverride,
195+
matcher: workspaceMatchesIdRef,
196+
});
197+
}
198+
181199
const context = await this.readAuthContext();
182200

183201
if (context.state.activeWorkspaceId) {
@@ -199,8 +217,9 @@ export class FileTokenStorage implements TokenStorage {
199217
// state, usually after logging out the active workspace. Do not fall back
200218
// to another cached workspace without an explicit `auth workspace use`.
201219
return null;
202-
} catch (_error) {
220+
} catch (error) {
203221
if (this.signal?.aborted) throw this.signal.reason;
222+
if (isWorkspaceOverrideError(error)) throw error;
204223
return null;
205224
}
206225
}
@@ -268,38 +287,17 @@ export class FileTokenStorage implements TokenStorage {
268287
this.signal?.throwIfAborted();
269288
}
270289

271-
async listWorkspaces(): Promise<StoredAuthWorkspace[]> {
290+
async listWorkspaces(
291+
options: ListWorkspacesOptions = {},
292+
): Promise<StoredAuthWorkspace[]> {
272293
this.signal?.throwIfAborted();
273294
const credentials = await this.readCredentialsFromDisk();
274-
const context = await this.ensureMigratedAuthContext(credentials);
295+
const context =
296+
options.migrateAuthContext === false
297+
? await this.readAuthContext()
298+
: await this.ensureMigratedAuthContext(credentials);
275299

276-
return credentials
277-
.map((credential) => storedCredentialToTokens(credential))
278-
.filter((tokens): tokens is Tokens => tokens !== null)
279-
.map((tokens) => {
280-
const cached = context.state.workspaces[tokens.workspaceId];
281-
const id =
282-
typeof cached?.id === "string" && cached.id.trim().length > 0
283-
? cached.id.trim()
284-
: tokens.workspaceId;
285-
const name =
286-
typeof cached?.name === "string" && cached.name.trim().length > 0
287-
? workspaceDisplayName(cached.name.trim(), tokens.workspaceId)
288-
: UNKNOWN_WORKSPACE_NAME;
289-
const lastSeenAt =
290-
typeof cached?.lastSeenAt === "string" &&
291-
cached.lastSeenAt.trim().length > 0
292-
? cached.lastSeenAt.trim()
293-
: null;
294-
295-
return {
296-
id,
297-
name,
298-
credentialWorkspaceId: tokens.workspaceId,
299-
active: context.state.activeWorkspaceId === tokens.workspaceId,
300-
lastSeenAt,
301-
};
302-
});
300+
return this.buildStoredAuthWorkspaces(credentials, context);
303301
}
304302

305303
async listWorkspaceTokens(): Promise<Tokens[]> {
@@ -310,6 +308,21 @@ export class FileTokenStorage implements TokenStorage {
310308
.filter((tokens): tokens is Tokens => tokens !== null);
311309
}
312310

311+
async getTokensForWorkspaceId(workspaceId: string): Promise<Tokens | null> {
312+
this.signal?.throwIfAborted();
313+
const ref = workspaceId.trim();
314+
if (!ref) {
315+
throw new WorkspaceSelectionError("missing");
316+
}
317+
318+
const credentials = await this.readCredentialsFromDisk();
319+
const context = await this.readAuthContext();
320+
return this.selectWorkspaceTokens(credentials, context, {
321+
ref,
322+
matcher: workspaceMatchesIdRef,
323+
});
324+
}
325+
313326
async useWorkspace(workspaceRef: string): Promise<{
314327
previous: StoredAuthWorkspace | null;
315328
selected: StoredAuthWorkspace;
@@ -431,6 +444,14 @@ export class FileTokenStorage implements TokenStorage {
431444
workspace: { id: string; name: string },
432445
): Promise<void> {
433446
const context = await this.readAuthContext();
447+
if (
448+
!context.exists &&
449+
this.env[WORKSPACE_ID_ENV_VAR] !== undefined &&
450+
this.options.activateOnSetTokens !== true
451+
) {
452+
return;
453+
}
454+
434455
context.state.workspaces[credentialWorkspaceId] = {
435456
id: workspace.id,
436457
name: workspace.name,
@@ -546,6 +567,66 @@ export class FileTokenStorage implements TokenStorage {
546567
return (await this.credentialsStore.getCredentials()) as StoredCredential[];
547568
}
548569

570+
private selectWorkspaceTokens(
571+
credentials: StoredCredential[],
572+
context: AuthContextReadResult,
573+
options: {
574+
ref: string;
575+
matcher: (workspace: StoredAuthWorkspace, ref: string) => boolean;
576+
},
577+
): Tokens | null {
578+
const workspaces = this.buildStoredAuthWorkspaces(credentials, context);
579+
const matches = workspaces.filter((workspace) =>
580+
options.matcher(workspace, options.ref),
581+
);
582+
583+
if (matches.length === 0) {
584+
throw new WorkspaceSelectionError("not-found", options.ref);
585+
}
586+
587+
if (matches.length > 1) {
588+
throw new WorkspaceSelectionError("ambiguous", options.ref, matches);
589+
}
590+
591+
return findTokensForWorkspace(
592+
credentials,
593+
matches[0].credentialWorkspaceId,
594+
);
595+
}
596+
597+
private buildStoredAuthWorkspaces(
598+
credentials: StoredCredential[],
599+
context: AuthContextReadResult,
600+
): StoredAuthWorkspace[] {
601+
return credentials
602+
.map((credential) => storedCredentialToTokens(credential))
603+
.filter((tokens): tokens is Tokens => tokens !== null)
604+
.map((tokens) => {
605+
const cached = context.state.workspaces[tokens.workspaceId];
606+
const id =
607+
typeof cached?.id === "string" && cached.id.trim().length > 0
608+
? cached.id.trim()
609+
: tokens.workspaceId;
610+
const name =
611+
typeof cached?.name === "string" && cached.name.trim().length > 0
612+
? workspaceDisplayName(cached.name.trim(), tokens.workspaceId)
613+
: UNKNOWN_WORKSPACE_NAME;
614+
const lastSeenAt =
615+
typeof cached?.lastSeenAt === "string" &&
616+
cached.lastSeenAt.trim().length > 0
617+
? cached.lastSeenAt.trim()
618+
: null;
619+
620+
return {
621+
id,
622+
name,
623+
credentialWorkspaceId: tokens.workspaceId,
624+
active: context.state.activeWorkspaceId === tokens.workspaceId,
625+
lastSeenAt,
626+
};
627+
});
628+
}
629+
549630
private async ensureMigratedAuthContext(
550631
credentials: StoredCredential[],
551632
): Promise<AuthContextReadResult> {
@@ -584,8 +665,6 @@ export class FileTokenStorage implements TokenStorage {
584665
const context = await this.readAuthContext();
585666
if (
586667
this.options.activateOnSetTokens === false &&
587-
context.exists &&
588-
context.state.activeWorkspaceId &&
589668
context.state.activeWorkspaceId !== workspaceId
590669
) {
591670
return;
@@ -605,6 +684,14 @@ export class FileTokenStorage implements TokenStorage {
605684
options: { preserveActivePointer: boolean },
606685
): Promise<void> {
607686
const context = await this.readAuthContext();
687+
if (
688+
!context.exists &&
689+
this.env[WORKSPACE_ID_ENV_VAR] !== undefined &&
690+
options.preserveActivePointer
691+
) {
692+
return;
693+
}
694+
608695
delete context.state.workspaces[workspaceId];
609696
if (
610697
!options.preserveActivePointer &&
@@ -711,6 +798,30 @@ function workspaceMatchesRef(
711798
);
712799
}
713800

801+
export function workspaceMatchesIdRef(
802+
workspace: StoredAuthWorkspace,
803+
ref: string,
804+
): boolean {
805+
return (
806+
workspace.credentialWorkspaceId === ref ||
807+
workspace.id === ref ||
808+
stripWorkspacePrefix(workspace.credentialWorkspaceId) ===
809+
stripWorkspacePrefix(ref) ||
810+
stripWorkspacePrefix(workspace.id) === stripWorkspacePrefix(ref)
811+
);
812+
}
813+
814+
function isWorkspaceOverrideError(error: unknown): boolean {
815+
if (error instanceof WorkspaceSelectionError) {
816+
return true;
817+
}
818+
819+
return (
820+
error instanceof Error &&
821+
error.message.startsWith(`${WORKSPACE_ID_ENV_VAR} is set but empty`)
822+
);
823+
}
824+
714825
function stripWorkspacePrefix(value: string): string {
715826
return value.startsWith("wksp_") ? value.slice("wksp_".length) : value;
716827
}

0 commit comments

Comments
 (0)