diff --git a/README.md b/README.md index cbfa35e..832f83a 100644 --- a/README.md +++ b/README.md @@ -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 `/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 ` | 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 ` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. | -| `--client-id ` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). | -| `--profile `, `-p` | Profile to write to (default: `default`). | -| `--skip-verify` | Save without contacting the server (no probe, no cache). | +| Flag | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--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 ` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. | +| `--client-id ` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). | +| `--profile `, `-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 @@ -1487,6 +1490,64 @@ mb git-sync remove-collection 12 mb git-sync remove-collection 12 --json --profile prod ``` +## Workspaces + +Lifecycle for EE workspaces via `/api/ee/workspace-manager` (Metabase v62+, `workspaces` premium feature). A workspace attaches databases and provisions warehouse isolation — a temporary schema + user per database — so work runs against real data without touching prod schemas. Alias: `mb ws`. + +### `mb workspace list` + +```sh +mb workspace list +mb workspace list --json +``` + +### `mb workspace get ` + +Includes per-database provisioning status (`unprovisioned` | `provisioning` | `provisioned` | `deprovisioning`), input schemas, and the isolated output namespace. + +```sh +mb workspace get 1 --json +``` + +### `mb workspace create` + +Creates the workspace and provisions isolation for each database (blocking). Each database must be eligible for workspaces (driver support + the `database-enable-workspaces` database setting). + +```sh +mb workspace create --name ws-reports --database-ids 1 +mb workspace create --name ws-etl --database-ids 1,2 --json +mb workspace create --name transform-work --database-ids 1 --spawn +``` + +| Flag | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `--name ` | Workspace name. | +| `--database-ids ` | Database ids to attach, comma separated. | +| `--spawn` | Also spawn a workspace Metabase; the returned credential is saved to the `ws-` profile (never printed) and made the default. | +| `--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. + +With `--spawn` the parent asks its instance manager for a workspace Metabase (`spawn_instance: true`) and the CLI consumes the returned `url` + `api_key`: the credential is written straight to the profile store as `ws-` — never to stdout, `--json`, or an agent transcript — and that profile becomes the default, so bare `mb` targets the new workspace from then on (explicit `--profile`/`MB_PROFILE` still win). Destroying the workspace (or `mb auth logout` on the profile) unhooks the default again. Because an exported `MB_API_KEY` outranks stored profiles, `--spawn` refuses to run while it is set — it would silently shadow the profile it is about to install. + +### `mb workspace destroy ` + +Destroy is the only irreversible moment, so it closes the work-loss window first: when a local profile named `ws-` exists, the child is checked for unsynced work (`remote-sync is-dirty`) and a dirty workspace is auto-exported to its target branch before teardown. `--discard` skips the check and the export; a machine without the workspace's profile warns and proceeds (destroy is the billing-stop lever — ops cleanup must work from anywhere), while a profile whose child can't be reached refuses without `--discard`. + +Then each provisioned database's warehouse isolation is torn down and the workspace removed; the matching local profile is dropped on success. Prompts unless `--yes`. Refuses (409) while any database is still provisioning/deprovisioning unless `--ignore-pending`, which removes the records and leaves those warehouse objects behind; unreachable-warehouse leftovers are reported as `orphaned_resources`. + +```sh +mb workspace destroy 1 --yes +mb workspace destroy 1 --yes --discard +mb workspace destroy 1 --yes --ignore-pending +``` + +| Flag | Description | +| ------------------ | ----------------------------------------------------------------------- | +| `--yes` | Skip confirmation. | +| `--discard` | Destroy without exporting unsynced work (skips the is-dirty check). | +| `--ignore-pending` | Remove workspace records even while databases are mid-(de)provisioning. | + ## Instance setup Bootstrapping a fresh, not-yet-configured Metabase instance. @@ -1687,7 +1748,7 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MB_URL` | Default URL for `auth login` and config resolution. | | `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to the stored default pointer (set by `workspace create --spawn`), then `default`. | | `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | | `MB_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | | `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 41cc854..5773bc5 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -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"; @@ -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; @@ -54,6 +60,11 @@ const loginView: ResourceView = { { 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), + }, ], }; @@ -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); @@ -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) { @@ -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 { +async function probeOAuthSupport( + url: string, + requiredScope?: string, +): Promise { try { - return await tryDiscoverMetadata(url); + return await tryDiscoverMetadata(url, requiredScope); } catch (error) { if (error instanceof ConfigError) { throw error; @@ -213,10 +276,11 @@ async function completeLogin( ctx: CommonContext, persist: PersistCredential, ): Promise { + 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, @@ -238,6 +302,7 @@ 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, @@ -245,9 +310,10 @@ async function completeLogin( 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, ); } diff --git a/src/commands/auth/logout.test.ts b/src/commands/auth/logout.test.ts index 6c7a0bf..78ffde1 100644 --- a/src/commands/auth/logout.test.ts +++ b/src/commands/auth/logout.test.ts @@ -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 = { diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index ba440bf..1562b14 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -6,7 +6,7 @@ import { consumeKeychainResidualWarning, readProfileCredential, } from "../../core/auth/storage"; -import { resolveProfileName } from "../../core/config"; +import { resolveActiveProfileName } from "../../core/config"; import { errorMessage } from "../../core/errors"; import type { ResourceView } from "../../domain/view"; import { warn } from "../../output/notice"; @@ -42,7 +42,7 @@ export default defineMetabaseCommand({ outputSchema: LogoutResult, examples: ["mb auth logout --yes", "mb auth logout --profile staging --yes"], async run({ args, ctx }) { - const profileName = resolveProfileName(args.profile); + const profileName = await resolveActiveProfileName(args.profile); if (!args.yes && process.stdin.isTTY === true) { const ok = await promptConfirm({ diff --git a/src/commands/auth/status.test.ts b/src/commands/auth/status.test.ts index a73a570..e82b43a 100644 --- a/src/commands/auth/status.test.ts +++ b/src/commands/auth/status.test.ts @@ -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"] }); diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index ce395d7..37a175c 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { readProfileRecord } from "../../core/auth/storage"; -import { resolveProfileName } from "../../core/config"; +import { resolveActiveProfileName } from "../../core/config"; import { displayUrl } from "../../core/url"; import { ParsedVersionSchema } from "../../core/version/tag"; import { @@ -57,7 +57,7 @@ export default defineMetabaseCommand({ outputSchema: AuthStatus, examples: ["mb auth status --json", "mb auth status --profile staging"], async run({ args, ctx }) { - const profileName = resolveProfileName(args.profile); + const profileName = await resolveActiveProfileName(args.profile); const record = await readProfileRecord(profileName); if (record === null) { diff --git a/src/commands/git-sync/export.ts b/src/commands/git-sync/export.ts index 612a054..a8b9778 100644 --- a/src/commands/git-sync/export.ts +++ b/src/commands/git-sync/export.ts @@ -11,7 +11,7 @@ import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags"; import { formatSyncTask, pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "./poll-task"; -const SyncExportKickoff = z.object({ +export const SyncExportKickoff = z.object({ message: z.string(), task_id: z.number().int().positive(), }); diff --git a/src/commands/parse-id.test.ts b/src/commands/parse-id.test.ts new file mode 100644 index 0000000..b456f43 --- /dev/null +++ b/src/commands/parse-id.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../core/errors"; + +import { parseIdCsv } from "./parse-id"; + +describe("parseIdCsv", () => { + it("parses a comma-separated list, trimming whitespace", () => { + expect(parseIdCsv("1, 2,3", "database id")).toEqual([1, 2, 3]); + }); + + it("parses a single id", () => { + expect(parseIdCsv("7", "database id")).toEqual([7]); + }); + + it("throws ConfigError when the list is empty", () => { + expect(() => parseIdCsv("", "database id")).toThrow( + new ConfigError("expected at least one database id (comma separated)"), + ); + }); + + it("throws ConfigError when the list is only separators", () => { + expect(() => parseIdCsv(",,", "database id")).toThrow( + new ConfigError("expected at least one database id (comma separated)"), + ); + }); + + it("throws ConfigError on a non-integer entry, preserving the raw value", () => { + expect(() => parseIdCsv("1,abc", "database id")).toThrow( + new ConfigError(`invalid database id: "abc" (expected integer)`), + ); + }); + + it("throws ConfigError on a zero id (ids start at 1)", () => { + expect(() => parseIdCsv("0", "database id")).toThrow( + new ConfigError("invalid database id: 0 (must be ≥ 1)"), + ); + }); +}); diff --git a/src/commands/parse-id.ts b/src/commands/parse-id.ts index 1bcf31b..a791e1a 100644 --- a/src/commands/parse-id.ts +++ b/src/commands/parse-id.ts @@ -1,5 +1,16 @@ +import { ConfigError } from "../core/errors"; +import { parseCsv } from "../runtime/csv"; + import { parseInteger } from "./parse-integer"; export function parseId(value: string, name = "id"): number { return parseInteger(value, { name, min: 1 }); } + +export function parseIdCsv(value: string, name: string): number[] { + const ids = parseCsv(value).map((part) => parseId(part, name)); + if (ids.length === 0) { + throw new ConfigError(`expected at least one ${name} (comma separated)`); + } + return ids; +} diff --git a/src/commands/runtime.test.ts b/src/commands/runtime.test.ts index 14528c9..d10b41f 100644 --- a/src/commands/runtime.test.ts +++ b/src/commands/runtime.test.ts @@ -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(() => ({ @@ -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"); @@ -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 run content commands against the workspace itself via its ws- 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); + }); +}); diff --git a/src/commands/runtime.ts b/src/commands/runtime.ts index 25bcb2c..c51d314 100644 --- a/src/commands/runtime.ts +++ b/src/commands/runtime.ts @@ -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, @@ -114,6 +117,8 @@ export function defineMetabaseCommand( getResolvedConfig, getServerInfo, }); + } catch (error) { + throw enrichScopeForbiddenError(error, cachedConfig); } finally { emitPendingWarnings(); } @@ -133,6 +138,22 @@ export function defineMetabaseCommand( 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 run content commands against the workspace itself via its ws- profile.`, + ); +} + function emitPendingWarnings(): void { for (const message of consumeLegacyEnvWarnings()) { warn(message); diff --git a/src/commands/workspace/create.ts b/src/commands/workspace/create.ts new file mode 100644 index 0000000..8f5ddfa --- /dev/null +++ b/src/commands/workspace/create.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +import { keyringFallbackWarning, setDefaultProfile, writeProfile } from "../../core/auth/storage"; +import { readEnvCredentials } from "../../core/config"; +import { ENV_API_KEY } from "../../core/env"; +import { ConfigError } from "../../core/errors"; +import { normalizeUrl } from "../../core/url"; +import { Workspace, workspaceView } from "../../domain/workspace"; +import { warn } from "../../output/notice"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseIdCsv } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { enforceCredentialSweep, keepExistingAuthFlag } from "./credential-sweep"; +import { workspaceProfileName } from "./profile-name"; + +// The create response when spawn_instance is requested: the workspace plus the spawned +// child's coordinates. The api_key is a secret — it is written to the profile store and +// stripped before anything is rendered; it must never reach stdout or an agent transcript. +const SpawnedWorkspace = Workspace.extend({ + url: z.string().optional(), + api_key: z.string().optional(), +}); + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create and provision a workspace" }, + details: + "Attaches the given databases and provisions warehouse isolation (a temporary schema + user per database) before returning. Each database must be eligible for workspaces; provisioning is blocking, so the response carries the final per-database status. Before creating, the profile store is swept for broader same-server credentials (any API key, any OAuth grant wider than mb:workspace-manager): interactive runs offer to revoke them, non-interactive runs refuse — --keep-existing-auth is the human-only override. With --spawn the parent also asks its instance manager to spawn a workspace Metabase; the returned credential is saved to the ws- profile (never printed) and made the default, so bare `mb` targets the new workspace.", + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...keepExistingAuthFlag, + name: { type: "string", description: "Workspace name", required: true }, + "database-ids": { + type: "string", + description: "Database ids to attach, comma separated", + required: true, + }, + spawn: { + type: "boolean", + description: + "Spawn a workspace instance and save its credential as the default profile (key is never printed)", + default: false, + }, + }, + outputSchema: Workspace, + examples: [ + "mb workspace create --name ws-reports --database-ids 1", + "mb workspace create --name ws-etl --database-ids 1,2 --json", + "mb workspace create --name transform-work --database-ids 1 --spawn", + ], + async run({ args, ctx, getClient, getResolvedConfig }) { + const databaseIds = parseIdCsv(args["database-ids"], "database id"); + if (args.spawn) { + assertNoApiKeyEnvShadow(); + } + const resolved = await getResolvedConfig(); + await enforceCredentialSweep({ + url: resolved.url, + profile: resolved.profile, + keepExistingAuth: args.keepExistingAuth === true, + action: "create a workspace", + }); + const client = await getClient(); + const created = await client.requestParsed(SpawnedWorkspace, "/api/ee/workspace-manager/", { + method: "POST", + body: { + name: args.name, + database_ids: databaseIds, + spawn_instance: args.spawn ? true : undefined, + }, + }); + if (args.spawn) { + await saveSpawnedCredential(created); + } + const workspace = { ...created }; + delete workspace.api_key; + renderSummary( + workspace, + workspaceView, + `Created workspace ${created.id} "${created.name}".`, + ctx, + ); + }, +}); + +// An exported MB_API_KEY outranks stored profiles in credential resolution, so it would +// silently shadow the workspace default profile this command is about to install — bare +// `mb` would keep hitting whatever the env key points at. Same guard as `auth login --workspace`. +function assertNoApiKeyEnvShadow(): void { + if (readEnvCredentials().apiKey !== null) { + throw new ConfigError( + `--spawn saves the workspace credential as the default profile, but ${ENV_API_KEY} is set and would shadow it — unset ${ENV_API_KEY} first`, + ); + } +} + +type SpawnedWorkspaceResponse = z.infer; + +async function saveSpawnedCredential(created: SpawnedWorkspaceResponse): Promise { + if (created.url === undefined || created.api_key === undefined) { + throw new Error( + "the server did not return a spawned instance (no url/api_key in the create response) — this Metabase may not support spawn_instance", + ); + } + const profileName = workspaceProfileName(created.id); + const location = await writeProfile( + { url: normalizeUrl(created.url), apiKey: created.api_key }, + profileName, + ); + if (location.backend === "file") { + warn(keyringFallbackWarning(location)); + } + await setDefaultProfile(profileName); + warn( + `saved workspace credential to profile "${profileName}" and made it the default — bare \`mb\` now targets ${created.url}`, + ); +} diff --git a/src/commands/workspace/credential-sweep.ts b/src/commands/workspace/credential-sweep.ts new file mode 100644 index 0000000..24098d3 --- /dev/null +++ b/src/commands/workspace/credential-sweep.ts @@ -0,0 +1,95 @@ +import { revokeOAuthCredential } from "../../core/auth/oauth-session"; +import { + describeStaleCredential, + findStaleParentCredentials, + type StaleCredential, +} from "../../core/auth/stale-credentials"; +import { clearProfile, listProfileRecords, readProfileCredential } from "../../core/auth/storage"; +import { ConfigError, errorMessage } from "../../core/errors"; +import { warn } from "../../output/notice"; +import { promptConfirm } from "../../output/prompt"; + +export const keepExistingAuthFlag = { + keepExistingAuth: { + type: "boolean", + description: + "Proceed despite broader same-server credentials in the profile store (interactive only)", + alias: "keep-existing-auth", + default: false, + }, +} as const; + +export interface CredentialSweepInput { + url: string; + profile: string; + keepExistingAuth: boolean; + action: string; +} + +// Tier 2 of the containment ladder: a stale full-power credential for the same parent would let a +// confused agent bypass the workspace-scoped token, so workspace create/connect refuses to proceed +// while one exists. Interactive runs offer revocation; non-interactive (agent) runs hard-refuse — +// the override is deliberately human-only. +export async function enforceCredentialSweep(input: CredentialSweepInput): Promise { + const records = await listProfileRecords(); + const stale = findStaleParentCredentials(records, input.url, input.profile); + if (stale.length === 0) { + return; + } + const listing = stale.map(describeStaleCredential).join(", "); + const interactive = process.stdin.isTTY === true; + + if (input.keepExistingAuth) { + if (!interactive) { + throw new ConfigError( + "--keep-existing-auth requires an interactive terminal; refusing to proceed with broader credentials in a non-interactive context", + ); + } + warn(`proceeding with broader credentials left in the profile store: ${listing}`); + return; + } + + if (!interactive) { + throw new ConfigError( + `refusing to ${input.action}: broader credentials for this server exist in the profile store (${listing}) — revoke them with \`mb auth logout --profile \` first; a human can override with --keep-existing-auth`, + ); + } + + const ok = await promptConfirm({ + message: `Broader credentials for this server exist (${listing}). Revoke them before continuing?`, + initialValue: true, + }); + if (!ok) { + throw new ConfigError( + `aborted: revoke the broader credentials (${listing}) with \`mb auth logout --profile \` or pass --keep-existing-auth`, + ); + } + for (const credential of stale) { + await revokeStaleCredential(credential); + } +} + +// Mirrors logout: durable local clear first, then best-effort server-side revocation for OAuth. +// API keys cannot be revoked from here — the server-side key must be deleted in Admin settings. +async function revokeStaleCredential(stale: StaleCredential): Promise { + const resolved = await readProfileCredential(stale.profile); + await clearProfile(stale.profile); + if (resolved === null || resolved.credential.kind !== "oauth") { + warn( + `cleared profile "${stale.profile}"; its API key is still active server-side — delete it in Admin settings → Authentication → API keys`, + ); + return; + } + try { + const revoked = await revokeOAuthCredential(resolved.url, resolved.credential); + if (!revoked) { + warn( + `cleared profile "${stale.profile}", but the server advertises no revocation endpoint; its tokens remain valid until they expire`, + ); + } + } catch (error) { + warn( + `cleared profile "${stale.profile}", but revoking server-side failed: ${errorMessage(error)}`, + ); + } +} diff --git a/src/commands/workspace/destroy.ts b/src/commands/workspace/destroy.ts new file mode 100644 index 0000000..d4c47fc --- /dev/null +++ b/src/commands/workspace/destroy.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; + +import { clearProfile, readProfileCredential } from "../../core/auth/storage"; +import { ConfigError, errorMessage } from "../../core/errors"; +import { createClient, type Client } from "../../core/http/client"; +import type { ResourceView } from "../../domain/view"; +import { warn } from "../../output/notice"; +import { promptConfirm } from "../../output/prompt"; +import { renderSummary } from "../../output/render"; +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../../runtime/poll"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { SyncExportKickoff } from "../git-sync/export"; +import { IsDirtyResult } from "../git-sync/is-dirty"; +import { pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "../git-sync/poll-task"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { workspaceProfileName } from "./profile-name"; + +const WorkspaceOrphanedResource = z + .object({ + workspace_database_id: z.number().int(), + database_id: z.number().int(), + driver: z.string(), + schema: z.string(), + user: z.string(), + reason: z.string().nullable().optional(), + }) + .loose(); + +const WorkspaceDeleteResponse = z + .object({ + id: z.number().int(), + deleted: z.boolean(), + message: z.string().optional(), + orphaned_resources: z.array(WorkspaceOrphanedResource).optional(), + }) + .loose(); + +export const WorkspaceDestroyResult = WorkspaceDeleteResponse.extend({ + aborted: z.boolean(), +}); +export type WorkspaceDestroyResultJson = z.infer; + +const workspaceDestroyView: ResourceView = { + compactPick: WorkspaceDestroyResult, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "deleted", label: "Destroyed" }, + { key: "aborted", label: "Aborted" }, + { key: "message", label: "Message" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "destroy", description: "Destroy a workspace and its warehouse isolation" }, + details: + "Destroy is the only irreversible moment, so it closes the work-loss window first: when a local profile named ws- exists, the child is checked for unsynced work and a dirty workspace is auto-exported to its target branch before anything is torn down (--discard skips the check and the export). Then each provisioned database's warehouse isolation (temporary schema + user) is dropped and the workspace removed; the matching local profile is dropped on success. Refuses with a 409 while any database is still provisioning/deprovisioning unless --ignore-pending is passed, which removes the workspace records and leaves those warehouse objects behind. If the warehouse was unreachable, the result lists the leftover schema/user objects under orphaned_resources.", + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + yes: { type: "boolean", description: "Skip confirmation", default: false }, + discard: { + type: "boolean", + description: "Destroy without exporting unsynced work (skips the is-dirty check)", + default: false, + }, + "ignore-pending": { + type: "boolean", + description: "Remove workspace records even while databases are provisioning/deprovisioning", + default: false, + }, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: WorkspaceDestroyResult, + examples: [ + "mb workspace destroy 1 --yes", + "mb workspace destroy 1 --yes --discard", + "mb workspace destroy 1 --yes --ignore-pending", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + if (!args.yes) { + if (process.stdin.isTTY !== true) { + throw new ConfigError( + `refusing to destroy workspace ${id} without confirmation — pass --yes to proceed non-interactively`, + ); + } + const ok = await promptConfirm({ + message: `Destroy workspace ${id}? Its warehouse isolation (schema + user) is dropped.`, + initialValue: false, + }); + if (!ok) { + renderSummary( + { id, deleted: false, aborted: true }, + workspaceDestroyView, + `Aborted; workspace ${id} was not destroyed.`, + ctx, + ); + return; + } + } + const client = await getClient(); + const profileName = workspaceProfileName(id); + const hasProfile = await guardUnsyncedWork(profileName, args.discard); + const response = await client.requestParsed( + WorkspaceDeleteResponse, + `/api/ee/workspace-manager/${id}`, + { + method: "DELETE", + query: { "ignore-pending": args["ignore-pending"] ? true : undefined }, + }, + ); + if (response.deleted && hasProfile) { + await clearProfile(profileName); + warn(`dropped profile "${profileName}"`); + } + renderSummary( + { ...response, aborted: false }, + workspaceDestroyView, + response.message ?? `Destroyed workspace ${id}.`, + ctx, + ); + }, +}); + +// The dirty check needs the child's credential, which only exists on machines that created or +// connected to this workspace. Without one the check is impossible — destroy must still work +// (it is the billing-stop lever), so warn and proceed rather than block ops cleanup. +async function guardUnsyncedWork(profileName: string, discard: boolean): Promise { + const resolved = await readProfileCredential(profileName); + if (resolved === null) { + warn(`no local profile "${profileName}" for this workspace — skipping the unsynced-work check`); + return false; + } + if (discard) { + warn("--discard given — skipping the unsynced-work check"); + return true; + } + const child = createClient({ url: resolved.url, credential: resolved.credential }); + const dirty = await checkIsDirty(child, profileName); + if (!dirty) { + return true; + } + warn("workspace has unsynced work — exporting to the target branch before destroy"); + await exportUnsyncedWork(child); + return true; +} + +async function checkIsDirty(child: Client, profileName: string): Promise { + try { + const result = await child.requestParsed(IsDirtyResult, REMOTE_SYNC_PATHS.isDirty); + return result.is_dirty; + } catch (error) { + throw new ConfigError( + `could not check workspace profile "${profileName}" for unsynced work: ${errorMessage(error)} — pass --discard to destroy anyway`, + ); + } +} + +async function exportUnsyncedWork(child: Client): Promise { + const kickoff = await child.requestParsed(SyncExportKickoff, REMOTE_SYNC_PATHS.export, { + method: "POST", + body: {}, + }); + const final = await pollSyncTask(child, { + intervalMs: DEFAULT_INTERVAL_MS, + timeoutMs: DEFAULT_TIMEOUT_MS, + }); + throwIfFailedTask(final, "export"); + warn(`export task #${kickoff.task_id} completed; unsynced work is saved`); +} diff --git a/src/commands/workspace/get.ts b/src/commands/workspace/get.ts new file mode 100644 index 0000000..124a872 --- /dev/null +++ b/src/commands/workspace/get.ts @@ -0,0 +1,24 @@ +import { Workspace, workspaceView } from "../../domain/workspace"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a workspace by id" }, + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: Workspace, + examples: ["mb workspace get 1", "mb workspace get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const workspace = await client.requestParsed(Workspace, `/api/ee/workspace-manager/${id}`); + renderItem(workspace, workspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts new file mode 100644 index 0000000..732f728 --- /dev/null +++ b/src/commands/workspace/index.ts @@ -0,0 +1,11 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "workspace", description: "Manage Metabase workspaces", alias: "ws" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + destroy: () => import("./destroy").then((mod) => mod.default), + }, +}); diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts new file mode 100644 index 0000000..fcdee31 --- /dev/null +++ b/src/commands/workspace/list.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { Workspace, WorkspaceCompact, workspaceView } from "../../domain/workspace"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const WorkspaceApiList = z.array(Workspace); + +export const WorkspaceListEnvelope = listEnvelopeSchema(WorkspaceCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List workspaces" }, + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: WorkspaceListEnvelope, + examples: ["mb workspace list", "mb workspace list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const items = await client.requestParsed(WorkspaceApiList, "/api/ee/workspace-manager/"); + renderList(wrapList(items), workspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/profile-name.ts b/src/commands/workspace/profile-name.ts new file mode 100644 index 0000000..8fe9b39 --- /dev/null +++ b/src/commands/workspace/profile-name.ts @@ -0,0 +1,3 @@ +export function workspaceProfileName(workspaceId: number): string { + return `ws-${workspaceId}`; +} diff --git a/src/core/auth/credential.test.ts b/src/core/auth/credential.test.ts index cccd4d9..d667c04 100644 --- a/src/core/auth/credential.test.ts +++ b/src/core/auth/credential.test.ts @@ -18,6 +18,7 @@ function oauth(overrides: Partial = {}): OAuthCredential { refreshToken: "refresh-tok", expiresAt: "2026-01-01T00:00:00.000Z", clientId: "client-123", + scope: "mb:full", ...overrides, }; } diff --git a/src/core/auth/credential.ts b/src/core/auth/credential.ts index f30a7da..2b47058 100644 --- a/src/core/auth/credential.ts +++ b/src/core/auth/credential.ts @@ -11,6 +11,7 @@ export interface OAuthCredential { refreshToken: string; expiresAt: string; clientId: string; + scope: string; } export type Credential = ApiKeyCredential | OAuthCredential; @@ -68,6 +69,7 @@ export function oauthCredentialFromTokens( tokens: Pick, refreshToken: string, clientId: string, + scope: string, nowMs: number, ): OAuthCredential { return { @@ -76,5 +78,6 @@ export function oauthCredentialFromTokens( refreshToken, expiresAt: expiresAtFromNow(tokens.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, nowMs), clientId, + scope, }; } diff --git a/src/core/auth/oauth-login.test.ts b/src/core/auth/oauth-login.test.ts index b3a9fe3..5591e7f 100644 --- a/src/core/auth/oauth-login.test.ts +++ b/src/core/auth/oauth-login.test.ts @@ -3,12 +3,13 @@ import { createHash } from "node:crypto"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; import { ConfigError } from "../errors"; -import type { CodeExchange, OAuthTokens } from "../http/oauth"; +import type { ClientRegistration, CodeExchange, OAuthTokens } from "../http/oauth"; const hoisted = vi.hoisted<{ tokens: OAuthTokens; metadata: OAuthServerMetadata; registerCalls: number; + registeredScope: string | null; discoverCalls: number; exchange: CodeExchange | null; }>(() => ({ @@ -25,6 +26,7 @@ const hoisted = vi.hoisted<{ registration_endpoint: "https://mb.example.com/oauth/register", }, registerCalls: 0, + registeredScope: null, discoverCalls: 0, exchange: null, })); @@ -37,8 +39,9 @@ vi.mock("../http/oauth", async (importOriginal) => { hoisted.discoverCalls += 1; return hoisted.metadata; }, - registerClient: async () => { + registerClient: async (input: ClientRegistration) => { hoisted.registerCalls += 1; + hoisted.registeredScope = input.scope; return { client_id: "client-xyz" }; }, exchangeCode: async (input: CodeExchange) => { @@ -95,6 +98,7 @@ describe("oauthLogin", () => { hoisted.tokens = { ...DEFAULT_TOKENS }; hoisted.metadata = { ...DEFAULT_METADATA }; hoisted.registerCalls = 0; + hoisted.registeredScope = null; hoisted.discoverCalls = 0; hoisted.exchange = null; }); @@ -115,6 +119,7 @@ describe("oauthLogin", () => { refreshToken: "ref", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "client-xyz", + scope: "mb:full", }); expect(announced).toHaveLength(1); expect(announced[0]).toContain("https://mb.example.com/oauth/authorize?"); @@ -139,6 +144,30 @@ describe("oauthLogin", () => { ); }); + it("threads a narrowed scope through registration, the authorize URL, and the credential", async () => { + const announced: string[] = []; + const credential = await oauthLogin( + { baseUrl: "https://mb.example.com", scope: "mb:workspace-manager" }, + { + openBrowser: browserDriver(), + onAuthorizeUrl: (url) => announced.push(url), + now: () => NOW, + }, + ); + expect(credential).toEqual({ + kind: "oauth", + accessToken: "acc", + refreshToken: "ref", + expiresAt: "2026-06-08T13:00:00.000Z", + clientId: "client-xyz", + scope: "mb:workspace-manager", + }); + expect(hoisted.registeredScope).toBe("mb:workspace-manager"); + assert(announced[0] !== undefined); + const authorizeParams = new URL(announced[0]).searchParams; + expect(authorizeParams.get("scope")).toBe("mb:workspace-manager"); + }); + it("joins authorize params with & when the endpoint already carries a query", async () => { hoisted.metadata = { ...DEFAULT_METADATA, @@ -183,6 +212,7 @@ describe("oauthLogin", () => { refreshToken: "ref", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "client-xyz", + scope: "mb:full", }); }); diff --git a/src/core/auth/oauth-login.ts b/src/core/auth/oauth-login.ts index 1295270..2d23413 100644 --- a/src/core/auth/oauth-login.ts +++ b/src/core/auth/oauth-login.ts @@ -19,6 +19,7 @@ export interface OAuthLoginInput { // when omitted it is discovered here. metadata?: OAuthServerMetadata; clientId?: string; + scope?: string; timeoutMs?: number; } @@ -38,6 +39,7 @@ async function resolveClientId( registrationEndpoint: string | undefined, redirectUri: string, provided: string | undefined, + scope: string, ): Promise { if (provided !== undefined) { return provided; @@ -51,6 +53,7 @@ async function resolveClientId( registrationEndpoint, redirectUri, clientName: CLIENT_NAME, + scope, }); return registered.client_id; } @@ -59,7 +62,8 @@ export async function oauthLogin( input: OAuthLoginInput, deps: OAuthLoginDeps, ): Promise { - const metadata = input.metadata ?? (await discoverMetadata(input.baseUrl)); + const scope = input.scope ?? OAUTH_SCOPE; + const metadata = input.metadata ?? (await discoverMetadata(input.baseUrl, scope)); const pkce = generatePkce(); const state = randomState(); // The server validates state in-handler, so a forged callback can't consume the slot. @@ -69,6 +73,7 @@ export async function oauthLogin( metadata.registration_endpoint, server.redirectUri, input.clientId, + scope, ); const authorizeUrl = buildAuthorizeUrl(metadata.authorization_endpoint, { response_type: "code", @@ -77,7 +82,7 @@ export async function oauthLogin( code_challenge: pkce.challenge, code_challenge_method: "S256", state, - scope: OAUTH_SCOPE, + scope, }); const opened = await deps.openBrowser(authorizeUrl); @@ -97,7 +102,7 @@ export async function oauthLogin( throw new ConfigError("token endpoint did not return a refresh token"); } - return oauthCredentialFromTokens(tokens, tokens.refresh_token, clientId, deps.now()); + return oauthCredentialFromTokens(tokens, tokens.refresh_token, clientId, scope, deps.now()); } finally { server.close(); } diff --git a/src/core/auth/oauth-session.test.ts b/src/core/auth/oauth-session.test.ts index c094369..c063bf5 100644 --- a/src/core/auth/oauth-session.test.ts +++ b/src/core/auth/oauth-session.test.ts @@ -12,6 +12,7 @@ const CREDENTIAL: OAuthCredential = { refreshToken: "ref-1", expiresAt: "2026-06-08T12:00:00.000Z", clientId: "client-1", + scope: "mb:full", }; const METADATA: OAuthServerMetadata = { @@ -48,6 +49,7 @@ describe("refreshOAuthCredential", () => { refreshToken: "ref-2", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "client-1", + scope: "mb:full", }); }); @@ -63,6 +65,7 @@ describe("refreshOAuthCredential", () => { refreshToken: "ref-1", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "client-1", + scope: "mb:full", }); }); }); diff --git a/src/core/auth/oauth-session.ts b/src/core/auth/oauth-session.ts index 4d73840..6c246fd 100644 --- a/src/core/auth/oauth-session.ts +++ b/src/core/auth/oauth-session.ts @@ -9,16 +9,19 @@ export async function refreshOAuthCredential( credential: OAuthCredential, nowMs: number, ): Promise { - const metadata = await discoverMetadata(baseUrl); + const metadata = await discoverMetadata(baseUrl, credential.scope); const tokens = await refreshTokens({ tokenEndpoint: metadata.token_endpoint, refreshToken: credential.refreshToken, clientId: credential.clientId, }); + // The refresh request carries no scope parameter, so the new grant inherits the original + // scope — a refresh can never widen it. return oauthCredentialFromTokens( tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId, + credential.scope, nowMs, ); } @@ -32,7 +35,7 @@ export async function revokeOAuthCredential( baseUrl: string, credential: OAuthCredential, ): Promise { - const metadata = await discoverMetadata(baseUrl); + const metadata = await discoverMetadata(baseUrl, credential.scope); const revocationEndpoint = metadata.revocation_endpoint; if (revocationEndpoint === undefined) { return false; diff --git a/src/core/auth/profile-record.ts b/src/core/auth/profile-record.ts index b4be959..058dd6e 100644 --- a/src/core/auth/profile-record.ts +++ b/src/core/auth/profile-record.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TokenFeatures } from "../../domain/session-properties"; +import { OAUTH_SCOPE } from "../http/oauth"; import { ParsedVersionSchema } from "../version/tag"; export const ProbedUser = z.object({ @@ -37,6 +38,9 @@ export const ProfileOAuth = z refreshToken: z.string().nullable(), expiresAt: z.iso.datetime(), clientId: z.string(), + // Records written before scoped logins existed were all minted with the full-access scope, + // so defaulting an absent field to it is a fact, not a guess. + scope: z.string().default(OAUTH_SCOPE), }) .loose(); export type ProfileOAuth = z.infer; @@ -62,6 +66,10 @@ export type ProfileRecord = z.infer; export const ProfilesFile = z .object({ profiles: z.array(ProfileRecord), + // Which profile bare `mb` resolves when no --profile/MB_PROFILE is given. Set by + // `workspace create --spawn` so a fresh workspace becomes the default target; null + // falls back to the profile literally named "default". + defaultProfile: z.string().nullable().default(null), }) .loose(); export type ProfilesFile = z.infer; diff --git a/src/core/auth/stale-credentials.test.ts b/src/core/auth/stale-credentials.test.ts new file mode 100644 index 0000000..cd7f497 --- /dev/null +++ b/src/core/auth/stale-credentials.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ProfileRecord } from "./profile-record"; +import { describeStaleCredential, findStaleParentCredentials } from "./stale-credentials"; + +const PARENT_URL = "https://parent.example.com"; + +function apiKeyRecord(name: string, url: string): ProfileRecord { + return { name, url, apiKey: "mb_stale_key", oauth: null, lastProbe: null, lastFailure: null }; +} + +function oauthRecord(name: string, url: string, scope: string): ProfileRecord { + return { + name, + url, + apiKey: null, + oauth: { + accessToken: "acc", + refreshToken: "ref", + expiresAt: "2099-01-01T00:00:00.000Z", + clientId: "c1", + scope, + }, + lastProbe: null, + lastFailure: null, + }; +} + +describe("findStaleParentCredentials", () => { + beforeEach(() => { + process.env["MB_CLI_DISABLE_KEYRING"] = "1"; + }); + + afterEach(() => { + delete process.env["MB_CLI_DISABLE_KEYRING"]; + }); + + it("flags a full-scope OAuth profile and an api-key profile for the same server", () => { + const records = [ + oauthRecord("old-login", PARENT_URL, "mb:full"), + apiKeyRecord("ci-admin", PARENT_URL), + ]; + expect(findStaleParentCredentials(records, PARENT_URL, "agent")).toEqual([ + { profile: "old-login", kind: "oauth", scope: "mb:full" }, + { profile: "ci-admin", kind: "apiKey" }, + ]); + }); + + it("does not flag a workspace-scoped OAuth profile", () => { + const records = [oauthRecord("agent-safe", PARENT_URL, "mb:workspace-manager")]; + expect(findStaleParentCredentials(records, PARENT_URL, "agent")).toEqual([]); + }); + + it("ignores credentials for a different server", () => { + const records = [ + apiKeyRecord("other-host", "https://other.example.com"), + oauthRecord("other-login", "https://other.example.com", "mb:full"), + ]; + expect(findStaleParentCredentials(records, PARENT_URL, "agent")).toEqual([]); + }); + + it("matches the same server across trailing-slash differences", () => { + const records = [apiKeyRecord("slashed", `${PARENT_URL}/`)]; + expect(findStaleParentCredentials(records, PARENT_URL, "agent")).toEqual([ + { profile: "slashed", kind: "apiKey" }, + ]); + }); + + it("exempts the profile the command runs as", () => { + const records = [apiKeyRecord("agent", PARENT_URL)]; + expect(findStaleParentCredentials(records, PARENT_URL, "agent")).toEqual([]); + }); + + it("ignores a record whose credential was already cleared", () => { + const cleared: ProfileRecord = { + name: "logged-out", + url: PARENT_URL, + apiKey: null, + oauth: null, + lastProbe: null, + lastFailure: null, + }; + expect(findStaleParentCredentials([cleared], PARENT_URL, "agent")).toEqual([]); + }); +}); + +describe("describeStaleCredential", () => { + it("names an api-key offender", () => { + expect(describeStaleCredential({ profile: "ci-admin", kind: "apiKey" })).toBe( + "ci-admin (api key)", + ); + }); + + it("names an oauth offender with its scope", () => { + expect(describeStaleCredential({ profile: "old-login", kind: "oauth", scope: "mb:full" })).toBe( + "old-login (mb:full)", + ); + }); +}); diff --git a/src/core/auth/stale-credentials.ts b/src/core/auth/stale-credentials.ts new file mode 100644 index 0000000..38faf51 --- /dev/null +++ b/src/core/auth/stale-credentials.ts @@ -0,0 +1,51 @@ +import { WORKSPACE_MANAGER_SCOPE } from "../http/oauth"; +import { normalizeUrl } from "../url"; + +import type { ProfileRecord } from "./profile-record"; +import { resolveRecordCredential } from "./storage"; + +export interface StaleApiKeyCredential { + profile: string; + kind: "apiKey"; +} + +export interface StaleOAuthCredential { + profile: string; + kind: "oauth"; + scope: string; +} + +export type StaleCredential = StaleApiKeyCredential | StaleOAuthCredential; + +// A credential is "stale" for workspace purposes when it targets the same parent instance and is +// broader than workspace CRUD: any API key (keys are unscoped, so full power) or any OAuth grant +// wider than the workspace-manager scope. The profile the command itself runs as is exempt — it +// is in deliberate use, not lying around. +export function findStaleParentCredentials( + records: readonly ProfileRecord[], + targetUrl: string, + currentProfile: string, +): StaleCredential[] { + const target = normalizeUrl(targetUrl); + const stale: StaleCredential[] = []; + for (const record of records) { + if (record.name === currentProfile || normalizeUrl(record.url) !== target) { + continue; + } + const resolved = resolveRecordCredential(record); + if (resolved === null) { + continue; + } + if (resolved.credential.kind === "apiKey") { + stale.push({ profile: record.name, kind: "apiKey" }); + } else if (resolved.credential.scope !== WORKSPACE_MANAGER_SCOPE) { + stale.push({ profile: record.name, kind: "oauth", scope: resolved.credential.scope }); + } + } + return stale; +} + +export function describeStaleCredential(credential: StaleCredential): string { + const detail = credential.kind === "apiKey" ? "api key" : credential.scope; + return `${credential.profile} (${detail})`; +} diff --git a/src/core/auth/storage.test.ts b/src/core/auth/storage.test.ts index 0173506..b7fe069 100644 --- a/src/core/auth/storage.test.ts +++ b/src/core/auth/storage.test.ts @@ -31,8 +31,10 @@ const { listProfileNames, listProfileRecords, profilesFilePath, + readDefaultProfileName, readProfileCredential, readProfileRecord, + setDefaultProfile, writeOAuthProfile, writeProbeFailure, writeProbeResult, @@ -48,6 +50,7 @@ const OAUTH: OAuthCredential = { refreshToken: "refresh-1", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "client-1", + scope: "mb:full", }; import { join } from "node:path"; @@ -94,6 +97,7 @@ describe("profiles (keyring backend)", () => { lastFailure: null, }, ], + defaultProfile: null, }); }); @@ -447,6 +451,7 @@ describe("OAuth profiles (keyring backend)", () => { refreshToken: null, expiresAt: OAUTH.expiresAt, clientId: "client-1", + scope: "mb:full", }, lastProbe: null, lastFailure: null, @@ -545,6 +550,7 @@ describe("OAuth profiles (file fallback)", () => { refreshToken: "refresh-1", expiresAt: OAUTH.expiresAt, clientId: "client-1", + scope: "mb:full", }); expect(await readProfileCredential()).toEqual({ url: "https://m.example.com", @@ -552,6 +558,35 @@ describe("OAuth profiles (file fallback)", () => { }); }); + it("resolves a pre-scope profile record to the full-access scope", async () => { + const profilesPath = join(configDir(), "profiles.json"); + mkdirSync(dirname(profilesPath), { recursive: true }); + writeFileSync( + profilesPath, + JSON.stringify({ + profiles: [ + { + name: "default", + url: "https://m.example.com", + apiKey: null, + oauth: { + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: OAUTH.expiresAt, + clientId: "client-1", + }, + lastProbe: null, + lastFailure: null, + }, + ], + }), + ); + expect(await readProfileCredential()).toEqual({ + url: "https://m.example.com", + credential: OAUTH, + }); + }); + it("does not flag a residual secret for an inline (file-fallback) profile", async () => { await writeOAuthProfile("https://m.example.com", OAUTH); // inlined, never in the keyring expect(await clearProfile()).toBe(true); @@ -559,3 +594,44 @@ describe("OAuth profiles (file fallback)", () => { expect(consumeKeychainResidualWarning()).toBeNull(); }); }); + +describe("default profile pointer", () => { + let home: TempConfigHome; + + beforeEach(() => { + hoisted.store.clear(); + hoisted.controls.broken = false; + home = setupTempConfigHome(); + }); + + afterEach(() => { + home.cleanup(); + }); + + it("is null until set", async () => { + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-1"); + expect(await readDefaultProfileName()).toBeNull(); + }); + + it("round-trips through the profiles file", async () => { + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-1"); + await setDefaultProfile("ws-1"); + expect(await readDefaultProfileName()).toBe("ws-1"); + }); + + it("clearProfile unsets the pointer when it names the removed profile", async () => { + await writeProfile({ url: "https://parent.example.com", apiKey: "mb_parent" }, "parent"); + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-1"); + await setDefaultProfile("ws-1"); + expect(await clearProfile("ws-1")).toBe(true); + expect(await readDefaultProfileName()).toBeNull(); + }); + + it("clearProfile keeps the pointer when a different profile is removed", async () => { + await writeProfile({ url: "https://parent.example.com", apiKey: "mb_parent" }, "parent"); + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-1"); + await setDefaultProfile("ws-1"); + expect(await clearProfile("parent")).toBe(true); + expect(await readDefaultProfileName()).toBe("ws-1"); + }); +}); diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index 4182fbc..8c0b051 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -175,7 +175,7 @@ async function readProfilesFile(): Promise { } catch (error) { if (isNotFoundError(error)) { await detectLegacyArtifacts(); - return { profiles: [] }; + return { profiles: [], defaultProfile: null }; } throw error; } @@ -185,7 +185,7 @@ async function readProfilesFile(): Promise { } if (parsed.error instanceof ValidationError) { legacyWarningPending = true; - return { profiles: [] }; + return { profiles: [], defaultProfile: null }; } throw parsed.error; } @@ -341,6 +341,7 @@ export function resolveRecordCredential(record: ProfileRecord): ResolvedCredenti refreshToken, expiresAt: record.oauth.expiresAt, clientId: record.oauth.clientId, + scope: record.oauth.scope, }, }; } @@ -425,6 +426,7 @@ export async function writeOAuthProfile( refreshToken: onFile ? credential.refreshToken : null, expiresAt: credential.expiresAt, clientId: credential.clientId, + scope: credential.scope, }; const file = await readProfilesFile(); const existing = findRecord(file, name); @@ -510,6 +512,18 @@ export async function clearProfile(name: string = DEFAULT_PROFILE): Promise entry.name !== name), + // Removing the profile the default pointer names would leave bare `mb` targeting nothing. + defaultProfile: file.defaultProfile === name ? null : file.defaultProfile, }); return true; } + +export async function readDefaultProfileName(): Promise { + const file = await readProfilesFile(); + return file.defaultProfile; +} + +export async function setDefaultProfile(name: string): Promise { + const file = await readProfilesFile(); + await writeProfilesFile({ ...file, defaultProfile: name }); +} diff --git a/src/core/config.test.ts b/src/core/config.test.ts index df61d4d..8017137 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -39,6 +39,7 @@ vi.mock("./auth/oauth-session", () => ({ import type { OAuthCredential } from "./auth/credential"; import { readProfileCredential, + setDefaultProfile, writeOAuthProfile, writeProbeFailure, writeProbeResult, @@ -48,8 +49,8 @@ import { setupTempConfigHome, type TempConfigHome } from "./auth/temp-config-hom import { createCredentialRefresher, explicitProfileName, + resolveActiveProfileName, resolveConfig, - resolveProfileName, } from "./config"; import { ConfigError } from "./errors"; @@ -59,6 +60,7 @@ const STORED_OAUTH: OAuthCredential = { refreshToken: "old-refresh", expiresAt: "2000-01-01T00:00:00.000Z", clientId: "c1", + scope: "mb:full", }; const REFRESHED_OAUTH: OAuthCredential = { @@ -67,6 +69,7 @@ const REFRESHED_OAUTH: OAuthCredential = { refreshToken: "refreshed-refresh", expiresAt: "2099-01-01T00:00:00.000Z", clientId: "c1", + scope: "mb:full", }; function clearConfigEnv(): void { @@ -260,30 +263,46 @@ describe("resolveConfig", () => { }); }); -describe("resolveProfileName", () => { +describe("resolveActiveProfileName", () => { const originalEnv = { ...process.env }; + let home: TempConfigHome; beforeEach(() => { + hoisted.store.clear(); + home = setupTempConfigHome(); delete process.env["MB_PROFILE"]; delete process.env["METABASE_PROFILE"]; }); afterEach(() => { + home.cleanup(); process.env = { ...originalEnv }; }); - it("returns the flag value when provided", () => { + it("returns the flag value when provided", async () => { process.env["MB_PROFILE"] = "env-profile"; - expect(resolveProfileName("flag-profile")).toBe("flag-profile"); + await expect(resolveActiveProfileName("flag-profile")).resolves.toBe("flag-profile"); }); - it("falls back to MB_PROFILE when no flag", () => { + it("falls back to MB_PROFILE when no flag", async () => { process.env["MB_PROFILE"] = "env-profile"; - expect(resolveProfileName(undefined)).toBe("env-profile"); + await expect(resolveActiveProfileName(undefined)).resolves.toBe("env-profile"); + }); + + it("uses the stored default pointer when neither flag nor env is set", async () => { + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-7"); + await setDefaultProfile("ws-7"); + await expect(resolveActiveProfileName(undefined)).resolves.toBe("ws-7"); + }); + + it("explicit flag outranks the stored default pointer", async () => { + await writeProfile({ url: "https://child.example.com", apiKey: "mb_child" }, "ws-7"); + await setDefaultProfile("ws-7"); + await expect(resolveActiveProfileName("parent")).resolves.toBe("parent"); }); - it("falls back to default when neither flag nor env is set", () => { - expect(resolveProfileName(undefined)).toBe("default"); + it("falls back to default when nothing else is set", async () => { + await expect(resolveActiveProfileName(undefined)).resolves.toBe("default"); }); }); diff --git a/src/core/config.ts b/src/core/config.ts index 1d3c10e..193a3c3 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -7,6 +7,7 @@ import { import { refreshOAuthCredential } from "./auth/oauth-session"; import { DEFAULT_PROFILE, + readDefaultProfileName, readProfileCredential, readProfileRecord, writeOAuthProfile, @@ -51,8 +52,14 @@ interface CredentialResolution { source: "flag" | "env" | "stored"; } -export function resolveProfileName(profileFlag: string | undefined): string { - return explicitProfileName(profileFlag) ?? DEFAULT_PROFILE; +// Explicit --profile/MB_PROFILE wins; otherwise the stored default pointer (set by +// `workspace create --spawn`) decides what bare `mb` targets; "default" is the fallback name. +export async function resolveActiveProfileName(profileFlag: string | undefined): Promise { + const explicit = explicitProfileName(profileFlag); + if (explicit !== null) { + return explicit; + } + return (await readDefaultProfileName()) ?? DEFAULT_PROFILE; } export function explicitProfileName(profileFlag: string | undefined): string | null { @@ -67,7 +74,7 @@ export function readEnvCredentials(): EnvCredentials { } export async function resolveConfig(flags: ConfigFlags): Promise { - const profile = resolveProfileName(flags.profile); + const profile = await resolveActiveProfileName(flags.profile); const env = readEnvCredentials(); const hasUrl = Boolean(flags.url ?? env.url); const hasKey = Boolean(flags.apiKey ?? env.apiKey); diff --git a/src/core/http/client.test.ts b/src/core/http/client.test.ts index 57c647a..67b43be 100644 --- a/src/core/http/client.test.ts +++ b/src/core/http/client.test.ts @@ -479,6 +479,7 @@ const OAUTH: OAuthCredential = { refreshToken: "ref-1", expiresAt: "2026-06-08T13:00:00.000Z", clientId: "c1", + scope: "mb:full", }; function unauthorizedResponse(): Response { diff --git a/src/core/http/oauth.test.ts b/src/core/http/oauth.test.ts index 8c6c5e9..07962c6 100644 --- a/src/core/http/oauth.test.ts +++ b/src/core/http/oauth.test.ts @@ -13,6 +13,7 @@ import { refreshTokens, revokeToken, tryDiscoverMetadata, + WORKSPACE_MANAGER_SCOPE, } from "./oauth"; function installFetch(script: FetchScript): FetchCapture { @@ -82,6 +83,35 @@ describe("oauth HTTP boundary", () => { expect(await tryDiscoverMetadata("https://mb.example.com")).toBeNull(); }); + it("treats a server that does not advertise the requested narrow scope as unsupported", async () => { + installFetch([ + jsonResponse({ + issuer: "https://mb.example.com", + authorization_endpoint: "https://mb.example.com/oauth/authorize", + token_endpoint: TOKEN_ENDPOINT, + scopes_supported: ["agent:sql:read", OAUTH_SCOPE], + }), + ]); + expect(await tryDiscoverMetadata("https://mb.example.com", WORKSPACE_MANAGER_SCOPE)).toBeNull(); + }); + + it("accepts a server advertising the requested narrow scope", async () => { + installFetch([ + jsonResponse({ + issuer: "https://mb.example.com", + authorization_endpoint: "https://mb.example.com/oauth/authorize", + token_endpoint: TOKEN_ENDPOINT, + scopes_supported: [OAUTH_SCOPE, WORKSPACE_MANAGER_SCOPE], + }), + ]); + expect(await tryDiscoverMetadata("https://mb.example.com", WORKSPACE_MANAGER_SCOPE)).toEqual({ + issuer: "https://mb.example.com", + authorization_endpoint: "https://mb.example.com/oauth/authorize", + token_endpoint: TOKEN_ENDPOINT, + scopes_supported: [OAUTH_SCOPE, WORKSPACE_MANAGER_SCOPE], + }); + }); + it("accepts a discovery document that omits scopes_supported", async () => { installFetch([ jsonResponse({ diff --git a/src/core/http/oauth.ts b/src/core/http/oauth.ts index 20f1c75..dd33994 100644 --- a/src/core/http/oauth.ts +++ b/src/core/http/oauth.ts @@ -47,9 +47,10 @@ async function oauthFetch(request: OAuthRequest): Promise { } } -// The CLI always authorizes with the single full-access scope; it never requests anything narrower, -// so scope is a fixed constant rather than a per-call parameter. +// Default login requests the full-access scope; `mb auth login --workspace` narrows the grant to +// workspace CRUD so the token on an agent's machine structurally cannot mutate parent content. export const OAUTH_SCOPE = "mb:full"; +export const WORKSPACE_MANAGER_SCOPE = "mb:workspace-manager"; export const OAuthServerMetadata = z .object({ @@ -84,6 +85,7 @@ export interface ClientRegistration { registrationEndpoint: string; redirectUri: string; clientName: string; + scope: string; } export interface CodeExchange { @@ -177,7 +179,10 @@ export const OAUTH_UNSUPPORTED_MESSAGE = // The well-known path is appended after any subpath (base + /.well-known/...), not inserted // between host and path as RFC 8414 prescribes — Metabase routes it like /api/*, so a // subpath-hosted instance serves discovery under its prefix. -export async function tryDiscoverMetadata(baseUrl: string): Promise { +export async function tryDiscoverMetadata( + baseUrl: string, + requiredScope: string = OAUTH_SCOPE, +): Promise { const url = `${baseUrl}${DISCOVERY_PATH}`; const response = await oauthFetch({ url, @@ -190,10 +195,11 @@ export async function tryDiscoverMetadata(baseUrl: string): Promise { - const metadata = await tryDiscoverMetadata(baseUrl); +export async function discoverMetadata( + baseUrl: string, + requiredScope: string = OAUTH_SCOPE, +): Promise { + const metadata = await tryDiscoverMetadata(baseUrl, requiredScope); if (metadata === null) { throw new ConfigError(OAUTH_UNSUPPORTED_MESSAGE); } @@ -230,7 +239,7 @@ export async function registerClient(input: ClientRegistration): Promise; + +export const WorkspaceCompact = Workspace.pick({ + id: true, + name: true, + created_at: true, +}) + .strip() + .extend({ databases: z.array(WorkspaceDatabaseCompact).optional() }); +export type WorkspaceCompact = z.infer; + +export const workspaceView: ResourceView = { + compactPick: WorkspaceCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "created_at", label: "Created" }, + { key: "databases", label: "Databases", format: (value) => formatDatabases(value) }, + ], +}; + +function formatDatabases(value: unknown): string { + const parsed = z.array(WorkspaceDatabaseCompact).safeParse(value); + if (!parsed.success) { + return ""; + } + return parsed.data.map((db) => `${db.database_id}:${db.status}`).join(", "); +} diff --git a/src/main.ts b/src/main.ts index 220879b..d74399b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,6 +28,7 @@ const main: CommandDef = defineCommand({ setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), "git-sync": () => import("./commands/git-sync").then((mod) => mod.default), + workspace: () => import("./commands/workspace").then((mod) => mod.default), setup: () => import("./commands/setup").then((mod) => mod.default), snippet: () => import("./commands/snippet").then((mod) => mod.default), segment: () => import("./commands/segment").then((mod) => mod.default), diff --git a/src/runtime/command-help.test.ts b/src/runtime/command-help.test.ts index 565ea2f..7c14374 100644 --- a/src/runtime/command-help.test.ts +++ b/src/runtime/command-help.test.ts @@ -334,6 +334,10 @@ const ALL_COMMANDS = [ "git-sync create-branch", "git-sync add-collection", "git-sync remove-collection", + "workspace list", + "workspace get", + "workspace create", + "workspace destroy", "setup", "snippet list", "snippet get", diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 39b1cc3..9e54eee 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -416,4 +416,59 @@ describe("auth e2e", () => { aborted: false, }); }); + + it("login --workspace refuses without a TTY (browser OAuth is the only scoped path)", async () => { + const configHome = await makeIsolatedConfigHome(); + + const login = await runCli({ + args: ["auth", "login", "--workspace", "--url", bootstrap.baseUrl, "--json"], + configHome, + }); + + expect(login.exitCode).toBe(2); + expect(cliErrorMessage(login.stderr)).toContain( + "--workspace login opens a browser and requires a TTY", + ); + expect(login.stdout).toBe(""); + }); + + it("login --workspace refuses an explicit --api-key (a key cannot carry a scope)", async () => { + const configHome = await makeIsolatedConfigHome(); + + const login = await runCli({ + args: [ + "auth", + "login", + "--workspace", + "--url", + bootstrap.baseUrl, + "--api-key", + bootstrap.adminApiKey, + "--json", + ], + configHome, + }); + + expect(login.exitCode).toBe(2); + expect(cliErrorMessage(login.stderr)).toContain( + "--workspace login is browser-OAuth only; an API key cannot carry a scope — omit --api-key", + ); + expect(login.stdout).toBe(""); + }); + + it("login --workspace refuses when MB_API_KEY is set rather than silently ignoring it", async () => { + const configHome = await makeIsolatedConfigHome(); + + const login = await runCli({ + args: ["auth", "login", "--workspace", "--url", bootstrap.baseUrl, "--json"], + configHome, + env: { MB_API_KEY: bootstrap.adminApiKey }, + }); + + expect(login.exitCode).toBe(2); + expect(cliErrorMessage(login.stderr)).toContain( + "--workspace login is browser-OAuth only; unset MB_API_KEY so it cannot shadow the scoped login", + ); + expect(login.stdout).toBe(""); + }); }); diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 0fcf031..1dae06e 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -21,21 +21,24 @@ services: ports: - "${METABASE_E2E_PORT:-13000}:3000" environment: - MB_DB_TYPE: h2 - MB_DB_FILE: /metabase/metabase.db - MB_ENABLE_TEST_ENDPOINTS: "true" - MB_SITE_NAME: metabase-cli-e2e - JAVA_TIMEZONE: UTC - MB_PASSWORD_COMPLEXITY: weak - MB_DISABLE_SESSION_THROTTLE: "true" - MB_LOAD_SAMPLE_CONTENT: "false" - MB_CHECK_FOR_UPDATES: "false" - MB_ANON_TRACKING_ENABLED: "false" + - MB_DB_TYPE=h2 + - MB_DB_FILE=/metabase/metabase.db + - MB_ENABLE_TEST_ENDPOINTS=true + - MB_SITE_NAME=metabase-cli-e2e + - JAVA_TIMEZONE=UTC + - MB_PASSWORD_COMPLEXITY=weak + - MB_DISABLE_SESSION_THROTTLE=true + - MB_LOAD_SAMPLE_CONTENT=false + - MB_CHECK_FOR_UPDATES=false + - MB_ANON_TRACKING_ENABLED=false # `dev` so Metabase honors METASTORE_DEV_SERVER_URL for token checks; # the prod default is hard-pinned to https://token-check.metabase.com. - MB_RUN_MODE: dev - MB_PREMIUM_EMBEDDING_TOKEN: ${MB_PREMIUM_EMBEDDING_TOKEN:-} - METASTORE_DEV_SERVER_URL: ${METASTORE_DEV_SERVER_URL:-} + # Bare (valueless) entries pass through only when set in the invoking shell — + # a `${VAR:-}` default would inject an empty string, and Metabase treats an + # empty METASTORE_DEV_SERVER_URL as configured, breaking the staging fallback. + - MB_RUN_MODE=dev + - MB_PREMIUM_EMBEDDING_TOKEN + - METASTORE_DEV_SERVER_URL volumes: - mb-app-db:/metabase - ./snapshots:/e2e/snapshots diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts new file mode 100644 index 0000000..16f656e --- /dev/null +++ b/tests/e2e/workspace.e2e.test.ts @@ -0,0 +1,502 @@ +import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; + +import { AuthStatus } from "../../src/commands/auth/status"; +import { WorkspaceDestroyResult } from "../../src/commands/workspace/destroy"; +import { WorkspaceListEnvelope } from "../../src/commands/workspace/list"; +import { createClient, type Client } from "../../src/core/http/client"; +import { WorkspaceCompact } from "../../src/domain/workspace"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; +import { requireServer, serverVersionBelow } from "./server-gate"; + +const WORKSPACE_MIN_VERSION = 62; +const skipReason = requireServer({ + minVersion: WORKSPACE_MIN_VERSION, + tokenFeature: "workspaces", +}); + +const FIRST_WORKSPACE_ID = 1; +const WORKSPACE_NAME = "e2e_workspace"; +const MISSING_WORKSPACE_ID = 9999999; +// The seeded warehouse's active table schemas; the server derives input schemas as the distinct +// non-blank schema names, sorted. +const WAREHOUSE_INPUT_SCHEMAS = ["analytics", "public"]; +// Provisioning (schema + user DDL, blocking) makes create/destroy slower than plain CRUD calls. +const PROVISION_TIMEOUT_MS = 60_000; + +const WORKSPACE_COMPACT = { + id: FIRST_WORKSPACE_ID, + name: WORKSPACE_NAME, + created_at: expect.any(String), + databases: [ + { + database_id: SEEDED.warehouseDbId, + input_schemas: WAREHOUSE_INPUT_SCHEMAS, + output_namespace: expect.stringContaining("mb__isolation_"), + status: "provisioned", + }, + ], +}; + +describe.skipIf(skipReason !== null)("workspace e2e", () => { + let bootstrap: E2EBootstrap; + let adminClient: Client; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + adminClient = createClient({ + url: bootstrap.baseUrl, + credential: { kind: "apiKey", apiKey: bootstrap.adminApiKey }, + }); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + // `database-enable-workspaces` is a database-local setting wiped by the per-test snapshot + // restore, so every test that creates a workspace re-enables it first. + async function enableWorkspacesOnWarehouse(): Promise { + await adminClient.requestRaw(`/api/database/${SEEDED.warehouseDbId}`, { + method: "PUT", + body: { settings: { "database-enable-workspaces": true } }, + }); + } + + async function createSeedWorkspace(configHome?: string): Promise { + await enableWorkspacesOnWarehouse(); + const result = await runCli({ + args: [ + "workspace", + "create", + "--name", + WORKSPACE_NAME, + "--database-ids", + String(SEEDED.warehouseDbId), + "--json", + ], + configHome: configHome ?? (await makeIsolatedConfigHome()), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceCompact)).toEqual(WORKSPACE_COMPACT); + } + + it("list returns the just-created workspace as the only entry, databases hydrated", async () => { + await createSeedWorkspace(); + + const result = await runCli({ + args: ["workspace", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceListEnvelope)).toEqual({ + data: [WORKSPACE_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("create + get round-trip returns the provisioned workspace by id", async () => { + await createSeedWorkspace(); + + const result = await runCli({ + args: ["workspace", "get", String(FIRST_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceCompact)).toEqual(WORKSPACE_COMPACT); + }); + + it("destroy --yes without a matching local profile warns, skips the dirty check, and tears down", async () => { + await createSeedWorkspace(); + + const destroyResult = await runCli({ + args: ["workspace", "destroy", String(FIRST_WORKSPACE_ID), "--yes", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(destroyResult.exitCode, destroyResult.stderr).toBe(0); + expect(destroyResult.stderr).toContain( + `no local profile "ws-${FIRST_WORKSPACE_ID}" for this workspace — skipping the unsynced-work check`, + ); + expect(parseJson(destroyResult.stdout, WorkspaceDestroyResult)).toEqual({ + id: FIRST_WORKSPACE_ID, + deleted: true, + aborted: false, + }); + + const getResult = await runCli({ + args: ["workspace", "get", String(FIRST_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode).toBe(1); + expect(cliErrorMessage(getResult.stderr)).toContain( + `Not found: GET /api/ee/workspace-manager/${FIRST_WORKSPACE_ID}.`, + ); + expect(getResult.stdout).toBe(""); + }); + + it("get on a missing workspace id surfaces the server 404", async () => { + const result = await runCli({ + args: ["workspace", "get", String(MISSING_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(cliErrorMessage(result.stderr)).toContain( + `Not found: GET /api/ee/workspace-manager/${MISSING_WORKSPACE_ID}.`, + ); + expect(result.stdout).toBe(""); + }); + + // The ws- profile is seeded AFTER create: it targets the same host in this rig, so the + // stale-credential sweep would (correctly) refuse the create if it existed beforehand. + async function seedWorkspaceProfile(configHome: string, url: string): Promise { + const skipVerify = url === bootstrap.baseUrl ? [] : ["--skip-verify"]; + const login = await runCli({ + args: [ + "auth", + "login", + "--profile", + `ws-${FIRST_WORKSPACE_ID}`, + "--url", + url, + ...skipVerify, + "--json", + ], + configHome, + stdin: bootstrap.adminApiKey, + }); + expect(login.exitCode, login.stderr).toBe(0); + } + + it("destroy runs the dirty check through the ws- profile and drops it after teardown", async () => { + const configHome = await makeIsolatedConfigHome(); + await createSeedWorkspace(configHome); + await seedWorkspaceProfile(configHome, bootstrap.baseUrl); + + const destroyResult = await runCli({ + args: ["workspace", "destroy", String(FIRST_WORKSPACE_ID), "--yes", "--json"], + configHome, + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(destroyResult.exitCode, destroyResult.stderr).toBe(0); + expect(destroyResult.stderr).not.toContain("no local profile"); + expect(destroyResult.stderr).toContain(`dropped profile "ws-${FIRST_WORKSPACE_ID}"`); + expect(parseJson(destroyResult.stdout, WorkspaceDestroyResult)).toEqual({ + id: FIRST_WORKSPACE_ID, + deleted: true, + aborted: false, + }); + + const status = await runCli({ + args: ["auth", "status", "--profile", `ws-${FIRST_WORKSPACE_ID}`, "--json"], + configHome, + }); + expect(status.exitCode, status.stderr).toBe(0); + expect(parseJson(status.stdout, AuthStatus).present).toBe(false); + }); + + it("destroy refuses when the dirty check is impossible, and --discard overrides", async () => { + const configHome = await makeIsolatedConfigHome(); + await createSeedWorkspace(configHome); + await seedWorkspaceProfile(configHome, "https://unreachable.invalid"); + + const refused = await runCli({ + args: ["workspace", "destroy", String(FIRST_WORKSPACE_ID), "--yes", "--json"], + configHome, + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(refused.exitCode).toBe(2); + expect(cliErrorMessage(refused.stderr)).toContain( + `could not check workspace profile "ws-${FIRST_WORKSPACE_ID}" for unsynced work:`, + ); + expect(cliErrorMessage(refused.stderr)).toContain("— pass --discard to destroy anyway"); + expect(refused.stdout).toBe(""); + + const discarded = await runCli({ + args: ["workspace", "destroy", String(FIRST_WORKSPACE_ID), "--yes", "--discard", "--json"], + configHome, + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(discarded.exitCode, discarded.stderr).toBe(0); + expect(discarded.stderr).toContain("--discard given — skipping the unsynced-work check"); + expect(parseJson(discarded.stdout, WorkspaceDestroyResult)).toEqual({ + id: FIRST_WORKSPACE_ID, + deleted: true, + aborted: false, + }); + }); +}); + +describe("workspace arg validation e2e (no Metabase contact required)", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("create with a non-integer database id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", "1,abc", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + 'invalid database id: "abc" (expected integer)', + ); + expect(result.stdout).toBe(""); + }); + + it("create with only separators in --database-ids fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", ",,", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "expected at least one database id (comma separated)", + ); + expect(result.stdout).toBe(""); + }); + + it("destroy without --yes refuses non-interactively with ConfigError before any network call", async () => { + const result = await runCli({ + args: ["workspace", "destroy", "5", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "refusing to destroy workspace 5 without confirmation — pass --yes to proceed non-interactively", + ); + expect(result.stdout).toBe(""); + }); + + it("create --spawn refuses while MB_API_KEY is set, before any network call", async () => { + const result = await runCli({ + args: [ + "workspace", + "create", + "--name", + "shadowed", + "--database-ids", + "1", + "--spawn", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: { MB_API_KEY: "mb_shadowing_admin_key" }, + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "--spawn saves the workspace credential as the default profile, but MB_API_KEY is set and would shadow it — unset MB_API_KEY first", + ); + expect(result.stdout).toBe(""); + }); +}); + +describe("workspace credential sweep e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + async function seedStaleAdminProfile(configHome: string): Promise { + const login = await runCli({ + args: ["auth", "login", "--profile", "stale-admin", "--url", bootstrap.baseUrl, "--json"], + configHome, + stdin: bootstrap.adminApiKey, + }); + expect(login.exitCode, login.stderr).toBe(0); + } + + it("create hard-refuses non-interactively while a broader same-server credential exists", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedStaleAdminProfile(configHome); + + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", "1", "--json"], + configHome, + env: { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "refusing to create a workspace: broader credentials for this server exist in the profile store (stale-admin (api key)) — revoke them with `mb auth logout --profile ` first; a human can override with --keep-existing-auth", + ); + expect(result.stdout).toBe(""); + }); + + it("--keep-existing-auth is refused in a non-interactive context (human-only override)", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedStaleAdminProfile(configHome); + + const result = await runCli({ + args: [ + "workspace", + "create", + "--name", + "ws", + "--database-ids", + "1", + "--keep-existing-auth", + "--json", + ], + configHome, + env: { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "--keep-existing-auth requires an interactive terminal; refusing to proceed with broader credentials in a non-interactive context", + ); + expect(result.stdout).toBe(""); + }); + + it("a credential for a different server does not trip the sweep (fails later, not on the sweep)", async () => { + const configHome = await makeIsolatedConfigHome(); + const login = await runCli({ + args: [ + "auth", + "login", + "--profile", + "other-host", + "--url", + "https://other.example.com", + "--skip-verify", + "--json", + ], + configHome, + stdin: bootstrap.adminApiKey, + }); + expect(login.exitCode, login.stderr).toBe(0); + + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", "1", "--json"], + configHome, + env: { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }, + }); + + // The sweep passes; the create then fails server-side (ineligible database or missing + // endpoint depending on the booted image), so the only stable assertions are "not the + // sweep's refusal" and the HttpError exit code. + expect(result.exitCode).toBe(1); + expect(cliErrorMessage(result.stderr)).not.toContain("broader credentials"); + }); +}); + +describe.skipIf(!serverVersionBelow(WORKSPACE_MIN_VERSION))( + "workspace capability gate against a sub-v62 server", + () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("workspace list refuses with CapabilityError (exit 2) naming the v62 requirement", async () => { + const serverTag = bootstrap.server.version?.tag; + assert(serverTag !== undefined, "gate block requires a known cached server version"); + const configHome = await makeIsolatedConfigHome(); + const login = await runCli({ + args: [ + "auth", + "login", + "--url", + bootstrap.baseUrl, + "--api-key", + bootstrap.adminApiKey, + "--json", + ], + configHome, + }); + assert(login.exitCode === 0, login.stderr); + + const result = await runCli({ args: ["workspace", "list", "--json"], configHome }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + `This command requires Metabase v${WORKSPACE_MIN_VERSION}+ (this server is ${serverTag}). Upgrade Metabase or pin mb-cli to an older release.`, + ); + expect(result.stdout).toBe(""); + }); + }, +);