Skip to content
Closed
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
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,19 @@ Against a server older than v62 the CLI detects the missing OAuth support and fa

On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `<configDir>/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry.

| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| Flag | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| `--workspace` | Request a workspace-manager-scoped login: the stored token can only manage workspaces — every other endpoint 403s server-side. Browser OAuth only (an API key cannot carry a scope), so it requires a TTY and refuses `--api-key`/`MB_API_KEY`. Requires a server advertising the `mb:workspace-manager` scope. |

Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `MB_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting.

A workspace-scoped profile that hits a 403 on a content command gets an error naming the scope and the fix (re-login without `--workspace`, or use a workspace profile) instead of a bare "Forbidden."

```sh
mb auth login # interactive: browser or API key
echo "$MB_KEY" | mb auth login --url https://m.example.com
Expand Down Expand Up @@ -1515,10 +1518,13 @@ mb workspace create --name ws-reports --database-ids 1
mb workspace create --name ws-etl --database-ids 1,2 --json
```

| Flag | Description |
| ---------------------- | ---------------------------------------- |
| `--name <name>` | Workspace name. |
| `--database-ids <ids>` | Database ids to attach, comma separated. |
| Flag | Description |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `--name <name>` | Workspace name. |
| `--database-ids <ids>` | Database ids to attach, comma separated. |
| `--keep-existing-auth` | Proceed despite broader same-server credentials in the profile store. Interactive only — refused in non-interactive contexts. |

Before creating, the profile store is swept for broader credentials against the same server: any API key profile, or any OAuth profile wider than `mb:workspace-manager`. Interactive runs offer to revoke the offenders (local clear + best-effort server-side token revocation; API keys must additionally be deleted in Admin settings); non-interactive runs hard-refuse so an agent can't proceed while a full-power credential is in reach.

### `mb workspace destroy <id>`

Expand Down
76 changes: 71 additions & 5 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { normalizeUrl } from "../../core/url";
import { ParsedVersionSchema } from "../../core/version/tag";
import { ProbedUser } from "../../core/auth/profile-record";
import type { ResourceView } from "../../domain/view";
import { tryDiscoverMetadata, type OAuthServerMetadata } from "../../core/http/oauth";
import {
OAUTH_SCOPE,
tryDiscoverMetadata,
WORKSPACE_MANAGER_SCOPE,
type OAuthServerMetadata,
} from "../../core/http/oauth";
import { warn } from "../../output/notice";
import { promptPassword, promptSelect, promptText } from "../../output/prompt";
import { renderSummary } from "../../output/render";
Expand All @@ -38,6 +43,7 @@ export const LoginResult = z.object({
authenticated: z.boolean(),
user: ProbedUser.nullable(),
version: ParsedVersionSchema.nullable(),
scope: z.string().nullable(),
});
export type LoginResultJson = z.infer<typeof LoginResult>;

Expand All @@ -54,6 +60,11 @@ const loginView: ResourceView<LoginResultJson> = {
{ key: "user", label: "Logged in as", format: (value) => renderUserName(value) },
{ key: "user", label: "Role", format: (value) => renderUserRole(value) },
{ key: "version", label: "Version", format: (value) => renderVersionTag(value) },
{
key: "scope",
label: "Scope",
format: (value) => (typeof value === "string" ? value : EMPTY_CELL),
},
],
};

Expand All @@ -76,12 +87,19 @@ export default defineMetabaseCommand({
default: false,
description: "Save without contacting the server",
},
workspace: {
type: "boolean",
default: false,
description:
"Request a workspace-manager-scoped login (workspace CRUD only, browser OAuth required)",
},
},
outputSchema: LoginResult,
examples: [
"mb auth login --url https://metabase.example.com",
"echo $MB_API_KEY | mb auth login --url https://metabase.example.com",
"mb auth login --profile staging --url https://staging.example.com",
"mb auth login --workspace --profile parent --url https://metabase.example.com",
],
async run({ args, ctx }) {
const profileName = await resolveLoginProfile(args.profile);
Expand All @@ -94,6 +112,30 @@ export default defineMetabaseCommand({
}

const url = await resolveUrl(args.url, env.url);

if (args.workspace) {
assertWorkspaceLoginPossible(args.apiKey, env.apiKey);
const metadata = await probeOAuthSupport(url, WORKSPACE_MANAGER_SCOPE);
if (metadata === null) {
throw new ConfigError(
`this Metabase does not support workspace-scoped login (no ${WORKSPACE_MANAGER_SCOPE} scope advertised)`,
);
}
const credential = await oauthLogin(
{
baseUrl: url,
metadata,
scope: WORKSPACE_MANAGER_SCOPE,
...(args.clientId !== undefined && { clientId: args.clientId }),
},
{ openBrowser, onAuthorizeUrl: announceAuthorizeUrl, now: () => Date.now() },
);
await completeLogin(profileName, url, credential, args["skip-verify"], ctx, () =>
writeOAuthProfile(url, credential, profileName),
);
return;
}

const apiKey = await nonInteractiveApiKey(args.apiKey, env.apiKey);

if (apiKey !== null) {
Expand Down Expand Up @@ -146,11 +188,32 @@ export default defineMetabaseCommand({

type LoginMethod = "oauth" | "apiKey";

// A workspace-scoped grant exists to keep content-mutating credentials off the machine; an API
// key can't carry a scope, so every key-shaped path is refused rather than silently ignored.
function assertWorkspaceLoginPossible(flagKey: string | undefined, envKey: string | null): void {
if (flagKey !== undefined) {
throw new ConfigError(
"--workspace login is browser-OAuth only; an API key cannot carry a scope — omit --api-key",
);
}
if (envKey !== null) {
throw new ConfigError(
"--workspace login is browser-OAuth only; unset MB_API_KEY so it cannot shadow the scoped login",
);
}
if (!process.stdin.isTTY) {
throw new ConfigError("--workspace login opens a browser and requires a TTY");
}
}

// Reaching the server but finding no CLI-capable OAuth server (pre-v63) degrades to the API key
// prompt; not reaching it at all is an error worth stopping on before any credential is collected.
async function probeOAuthSupport(url: string): Promise<OAuthServerMetadata | null> {
async function probeOAuthSupport(
url: string,
requiredScope?: string,
): Promise<OAuthServerMetadata | null> {
try {
return await tryDiscoverMetadata(url);
return await tryDiscoverMetadata(url, requiredScope);
} catch (error) {
if (error instanceof ConfigError) {
throw error;
Expand Down Expand Up @@ -213,10 +276,11 @@ async function completeLogin(
ctx: CommonContext,
persist: PersistCredential,
): Promise<void> {
const scope = credential.kind === "oauth" ? credential.scope : null;
if (skipVerify) {
await persistWithWarning(persist);
renderSummary(
{ profile: profileName, url, authenticated: false, user: null, version: null },
{ profile: profileName, url, authenticated: false, user: null, version: null, scope },
loginView,
`Saved credentials for profile "${profileName}" (${url}) without verifying.`,
ctx,
Expand All @@ -238,16 +302,18 @@ async function completeLogin(
const role = renderUserRole(result.user);
const versionTag = renderVersionTag(result.server.version);
const serverClause = versionTag === EMPTY_CELL ? "" : ` Server ${versionTag}.`;
const scopeClause = scope === null || scope === OAUTH_SCOPE ? "" : ` Scope: ${scope}.`;
renderSummary(
{
profile: profileName,
url,
authenticated: true,
user: result.user,
version: result.server.version,
scope,
},
loginView,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}`,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}${scopeClause}`,
ctx,
);
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const OAUTH_CRED: OAuthCredential = {
refreshToken: "ref-1",
expiresAt: "2099-01-01T00:00:00.000Z",
clientId: "client-1",
scope: "mb:full",
};

const METADATA: OAuthServerMetadata = {
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("auth status command", () => {
refreshToken: "ref",
expiresAt: "2026-06-08T13:00:00.000Z",
clientId: "c1",
scope: "mb:full",
});
const capture = captureStdout();
await runCommand(authStatusCommand, { rawArgs: ["--profile", "default", "--json"] });
Expand Down
71 changes: 70 additions & 1 deletion src/commands/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home";
import type { ResolvedConfig } from "../core/config";
import type { ServerInfo } from "../core/version/probe";

const hoisted = vi.hoisted(() => ({
Expand All @@ -14,7 +15,10 @@ vi.mock("@napi-rs/keyring", async () => {
return createKeyringMockModule(hoisted);
});

const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime");
const { defineMetabaseCommand, enrichScopeForbiddenError, SKIP_PREFLIGHT_ENV } =
await import("./runtime");
const { ConfigError, errorMessage } = await import("../core/errors");
const { HttpError } = await import("../core/http/errors");
const { connectionFlags, outputFlags, profileFlag } = await import("./flags");
const { writeProbeResult, writeProfile } = await import("../core/auth/storage");

Expand Down Expand Up @@ -316,3 +320,68 @@ describe("defineMetabaseCommand", () => {
expect(ran).toHaveBeenCalledOnce();
});
});

const FORBIDDEN = new HttpError({
status: 403,
statusText: "Forbidden",
method: "POST",
url: "https://m.example.com/api/card",
responseHeaders: {},
rawBody: null,
});

function oauthConfig(scope: string): ResolvedConfig {
return {
url: "https://m.example.com",
profile: "parent",
source: "stored",
credential: {
kind: "oauth",
accessToken: "acc",
refreshToken: "ref",
expiresAt: "2099-01-01T00:00:00.000Z",
clientId: "c1",
scope,
},
};
}

describe("enrichScopeForbiddenError", () => {
it("converts a 403 on a workspace-scoped profile into a ConfigError naming the scope", () => {
const result = enrichScopeForbiddenError(FORBIDDEN, oauthConfig("mb:workspace-manager"));
expect(result).toBeInstanceOf(ConfigError);
expect(errorMessage(result)).toBe(
`${FORBIDDEN.userMessage} This profile's login is scoped to mb:workspace-manager, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or point --profile at a workspace profile.`,
);
});

it("passes a 403 on a full-scope profile through untouched", () => {
expect(enrichScopeForbiddenError(FORBIDDEN, oauthConfig("mb:full"))).toBe(FORBIDDEN);
});

it("passes a 403 on an api-key profile through untouched", () => {
const config: ResolvedConfig = {
url: "https://m.example.com",
profile: "default",
source: "stored",
credential: { kind: "apiKey", apiKey: "mb_secret" },
};
expect(enrichScopeForbiddenError(FORBIDDEN, config)).toBe(FORBIDDEN);
});

it("passes a non-403 error through untouched even on a workspace-scoped profile", () => {
const notFound = new HttpError({
status: 404,
statusText: "Not Found",
method: "GET",
url: "https://m.example.com/api/card/1",
responseHeaders: {},
rawBody: null,
});
expect(enrichScopeForbiddenError(notFound, oauthConfig("mb:workspace-manager"))).toBe(notFound);
});

it("passes the error through when no config was resolved yet", () => {
expect(enrichScopeForbiddenError(FORBIDDEN, null)).toBe(FORBIDDEN);
});
});
21 changes: 21 additions & 0 deletions src/commands/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
type ResolvedConfig,
} from "../core/config";
import { consumeLegacyEnvWarnings } from "../core/env";
import { ConfigError } from "../core/errors";
import { createClient, type Client } from "../core/http/client";
import { HttpError } from "../core/http/errors";
import { OAUTH_SCOPE } from "../core/http/oauth";
import {
BASELINE_CAPABILITIES,
checkCapabilities,
Expand Down Expand Up @@ -114,6 +117,8 @@ export function defineMetabaseCommand<const A extends ArgsDef>(
getResolvedConfig,
getServerInfo,
});
} catch (error) {
throw enrichScopeForbiddenError(error, cachedConfig);
} finally {
emitPendingWarnings();
}
Expand All @@ -133,6 +138,22 @@ export function defineMetabaseCommand<const A extends ArgsDef>(
return cmd;
}

// A server-side 403 on a scope-narrowed profile is almost always the scope working as designed
// (the containment model 403s everything outside workspace CRUD), so name the real fix instead
// of letting the bare "Forbidden." suggest a permissions bug.
export function enrichScopeForbiddenError(error: unknown, config: ResolvedConfig | null): unknown {
if (!(error instanceof HttpError) || error.status !== 403 || config === null) {
return error;
}
const { credential } = config;
if (credential.kind !== "oauth" || credential.scope === OAUTH_SCOPE) {
return error;
}
return new ConfigError(
`${error.userMessage} This profile's login is scoped to ${credential.scope}, which only allows workspace commands against this server. Run \`mb auth login\` for a full-access login, or point --profile at a workspace profile.`,
);
}

function emitPendingWarnings(): void {
for (const message of consumeLegacyEnvWarnings()) {
warn(message);
Expand Down
Loading
Loading