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
6 changes: 3 additions & 3 deletions cli/src/commands/catalogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ const catalogsCreateCommand = defineHttpCommand({
},
name: { type: "string", description: "Catalog name", required: true },
},
parse({ args }): ManagementCatalogCreateInput {
parse({ args, execution }): ManagementCatalogCreateInput {
if (args.engine !== "altertable") {
throw new CliError(
`Only the 'altertable' engine is supported (got '${String(args.engine)}').`,
);
}

const env = requireManagementEnv();
const env = requireManagementEnv(execution.profile);
const name = String(args.name);
return {
env,
Expand Down Expand Up @@ -77,7 +77,7 @@ const catalogsListCommand = defineOperationCommand({
examples: ["altertable catalogs list", "altertable --json catalogs list"],
},
run(_input, context) {
const env = requireManagementEnv();
const env = requireManagementEnv(context.execution.profile);
return localPlan(() => fetchManagementCatalogRows(env, context));
},
present(rows) {
Expand Down
7 changes: 5 additions & 2 deletions cli/src/commands/duckdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,15 @@ async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise
throw new ConfigurationError(LOGIN_PROMPT);
}

const credentials = getLoginLakehouseCredentials();
const credentials = getLoginLakehouseCredentials(context.execution.profile);
if (!credentials) {
throw new ConfigurationError(LOGIN_PROMPT);
}

const rows = await fetchManagementCatalogRows(requireManagementEnv(), context);
const rows = await fetchManagementCatalogRows(
requireManagementEnv(context.execution.profile),
context,
);
const catalogs = selectCatalogsToAttach(rows, input.catalog);

const snippet = buildDuckdbAttachSnippet(credentials, catalogs);
Expand Down
67 changes: 51 additions & 16 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { defineLocalCommand } from "@/lib/operation-command-builders.ts";
import { ConfigurationError } from "@/lib/errors.ts";
import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts";
import { assertAllowedApiBase } from "@/lib/url-policy.ts";
import { getCliRuntime, refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts";
import { refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts";
import { httpSend } from "@/lib/http.ts";
import { resolveManagementApiBase, resolveOAuthBase } from "@/lib/config.ts";
import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts";
import { managementRequest } from "@/lib/management-transport.ts";
import { runLoginFlow, type TokenResponse } from "@/lib/oauth-flow.ts";
import { storeOAuthTokens } from "@/lib/oauth-profile.ts";
Expand All @@ -15,12 +18,11 @@ import {
deriveProfileName,
profileExists,
profileHasAnyAuthConfigured,
resolveProfileName,
resolveWorkingProfile,
setActiveProfile,
updateProfile,
} from "@/features/profile/model.ts";
import { terminalSuccess } from "@/ui/terminal/styles.ts";
import { createExecutionContext } from "@/lib/execution-context.ts";

function isInteractiveTerminal(): boolean {
return process.stdin.isTTY;
Expand Down Expand Up @@ -56,7 +58,7 @@ function selectLoginProfile(
environment: string,
replaceCurrentProfile: boolean,
): Pick<LoginProfileMetadata, "profileName" | "profileAction"> {
const currentProfile = resolveProfileName(getCliContext().profile);
const currentProfile = resolveWorkingProfile(getCliContext().profile);
if (replaceCurrentProfile) {
return { profileName: currentProfile, profileAction: "replaced" };
}
Expand Down Expand Up @@ -157,28 +159,61 @@ export function applyControlPlaneOverride(args: LoginArgs): void {
process.env.ALTERTABLE_MANAGEMENT_API_BASE = url;
}

async function fetchLoginWhoami(oauthResponse: TokenResponse): Promise<WhoamiResponse> {
const execution = createExecutionContext(getCliRuntime());
execution.auth.management = `Authorization: Bearer ${oauthResponse.access_token}`;
return JSON.parse(
await managementRequest("GET", "/whoami", undefined, execution),
) as WhoamiResponse;
// Profile-free on purpose: the minted token — not the current profile's stored
// auth, which may still be a different org's session — decides who we are.
export async function fetchLoginWhoami(
oauthResponse: TokenResponse,
managementApiBase: string,
): Promise<WhoamiResponse> {
const body = await httpSend({
method: "GET",
url: `${managementApiBase}${encodeManagementEndpoint("/whoami")}`,
authHeader: `Authorization: Bearer ${oauthResponse.access_token}`,
authPlane: "management",
});
return JSON.parse(body) as WhoamiResponse;
}

async function fetchCurrentProfileWhoami(): Promise<WhoamiResponse> {
return JSON.parse(await managementRequest("GET", "/whoami")) as WhoamiResponse;
}

export function sameWhoamiContext(a: WhoamiResponse, b: WhoamiResponse): boolean {
return (
a.principal?.type === b.principal?.type &&
a.principal?.slug === b.principal?.slug &&
a.principal?.email === b.principal?.email &&
a.organization?.slug === b.organization?.slug &&
a.environment_slug === b.environment_slug
);
}

async function runLogin(args: LoginArgs, sink: OutputSink): Promise<void> {
assertProfileHasNoEnvCredentials("altertable login");
assertInteractiveLogin();
applyControlPlaneOverride(args);

const oauthResponse = await runLoginFlow(sink);
const whoami = await fetchLoginWhoami(oauthResponse);
// Login succeeded — now persist whoami metadata and any control-plane override
// to the profile so later commands target it. The override is kept session-only
// until here so a failed login against a bad URL never writes it.
const currentProfile = resolveWorkingProfile(getCliContext().profile);
const oauthBase = resolveOAuthBase(currentProfile);
const managementApiBase = resolveManagementApiBase(currentProfile);

// Past this point the flow is profile-free so it can't accidentally read another org's stored session.
const oauthResponse = await runLoginFlow(sink, oauthBase);
const whoami = await fetchLoginWhoami(oauthResponse, managementApiBase);

// Login succeeded and we can now persist whoami metadata and any control-plane override to the profile so later commands target it.
const { environment, profileName, profileAction } = storeLoginProfileMetadata(whoami, args);
storeOAuthTokens(oauthResponse);
storeOAuthTokens(oauthResponse, profileName);
refreshCliRuntimeContext(getCliContext());

// Refresh whoami from the profile and check if the identity is the same as the one we just authenticated as.
const refreshedWhoami = await fetchCurrentProfileWhoami();
if (!sameWhoamiContext(whoami, refreshedWhoami)) {
throw new Error(
"Login failed: the identity before and after profile persistence do not match.",
);
}

const identity = formatWhoamiPrincipalLine(whoami);
const profileMessages = {
created: `created profile "${profileName}"`,
Expand Down
5 changes: 1 addition & 4 deletions cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { asCliArgString } from "@/lib/cli-args.ts";
import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts";
import { CliError, ConfigurationError } from "@/lib/errors.ts";
import { withConfigureProfileContext } from "@/lib/profile-configure-core.ts";
import type { ProfileInspect } from "@/features/profile/model.ts";
import type { ConfigureAuthPlane } from "@/lib/profile-status.ts";
import { configureVerify } from "@/lib/profile-status.ts";
Expand Down Expand Up @@ -132,9 +131,7 @@ const profileCreateCommand = defineLocalCommand({
},
async local(input, context) {
createEmptyProfile(input.name);
await withConfigureProfileContext(input.name, () =>
runProfileConfigure(input.configure, context.sink),
);
await runProfileConfigure(input.configure, context.sink, input.name);
setActiveProfile(input.name);
return inspectProfile(input.name);
},
Expand Down
Loading
Loading