diff --git a/.changeset/profile-readonly.md b/.changeset/profile-readonly.md new file mode 100644 index 000000000..d6d8473d2 --- /dev/null +++ b/.changeset/profile-readonly.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `--permission ` flag to `profile create`, `profile update`, and `workspace create` (when `--profile-name` is given) so editor users can use a viewer-style profile by default. Profiles created with `--permission read` block platform-state mutations driven by the operator's bearer token (`apply`, `remove`, `workspace create/delete/restore`, `secret create/update/delete`, `tailordb migrate set`, `tailordb truncate`, `tailordb erd deploy`, `executor trigger`, `staticwebsite deploy`, `authconnection authorize/revoke`, organization / folder / PAT / workspace-user mutations, and direct `api ` calls) with a `PROFILE_READONLY` error. Application-data operations executed under a machine user (`query`, `workflow start/resume`, `function test-run`) are not gated because the machine user's own permissions already govern those mutations. Switch profile or run `profile update --permission write` to lift the restriction. Profile management itself stays available so the flag can always be cleared. `profile update` skips remote user / workspace validation when only `--permission` is changing, so the flag can be cleared offline or with an expired token. + +The guard activates only when a profile is in scope: pass `--profile ` or set `TAILOR_PLATFORM_PROFILE`. `TAILOR_PLATFORM_TOKEN` and `--workspace-id` direct access bypass the guard by design; they are intended for machine-user / CI flows where the platform token already encodes the permitted scope. diff --git a/packages/sdk/docs/cli/workspace.md b/packages/sdk/docs/cli/workspace.md index 0a7179dae..fe481982c 100644 --- a/packages/sdk/docs/cli/workspace.md +++ b/packages/sdk/docs/cli/workspace.md @@ -71,15 +71,16 @@ tailor-sdk workspace create [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------------- | ----- | ----------------------------------------------------- | -------- | ------- | --------------------------------- | -| `--name ` | `-n` | Workspace name | Yes | - | - | -| `--region ` | `-r` | Workspace region (us-west, asia-northeast) | Yes | - | - | -| `--delete-protection` | `-d` | Enable delete protection | No | `false` | - | -| `--organization-id ` | `-o` | Organization ID to workspace associate with | No | - | `TAILOR_PLATFORM_ORGANIZATION_ID` | -| `--folder-id ` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` | -| `--profile-name ` | `-p` | Profile name to create | No | - | - | -| `--profile-user ` | - | User email for the profile (defaults to current user) | No | - | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------- | -------- | --------- | --------------------------------- | +| `--name ` | `-n` | Workspace name | Yes | - | - | +| `--region ` | `-r` | Workspace region (us-west, asia-northeast) | Yes | - | - | +| `--delete-protection` | `-d` | Enable delete protection | No | `false` | - | +| `--organization-id ` | `-o` | Organization ID to workspace associate with | No | - | `TAILOR_PLATFORM_ORGANIZATION_ID` | +| `--folder-id ` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` | +| `--profile-name ` | `-p` | Profile name to create | No | - | - | +| `--profile-user ` | - | User email for the profile (defaults to current user) | No | - | - | +| `--permission ` | - | Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active. | No | `"write"` | - | @@ -240,10 +241,11 @@ tailor-sdk profile create [options] **Options** -| Option | Alias | Description | Required | Default | -| ------------------------------- | ----- | ------------ | -------- | ------- | -| `--user ` | `-u` | User email | Yes | - | -| `--workspace-id ` | `-w` | Workspace ID | Yes | - | +| Option | Alias | Description | Required | Default | +| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | --------- | +| `--user ` | `-u` | User email | Yes | - | +| `--workspace-id ` | `-w` | Workspace ID | Yes | - | +| `--permission ` | - | Profile permission. 'read' blocks all write commands while the profile is active. | No | `"write"` | @@ -318,10 +320,11 @@ tailor-sdk profile update [options] **Options** -| Option | Alias | Description | Required | Default | -| ------------------------------- | ----- | ---------------- | -------- | ------- | -| `--user ` | `-u` | New user email | No | - | -| `--workspace-id ` | `-w` | New workspace ID | No | - | +| Option | Alias | Description | Required | Default | +| ------------------------------- | ----- | ------------------------------------------------------------------------------------ | -------- | ------- | +| `--user ` | `-u` | New user email | No | - | +| `--workspace-id ` | `-w` | New workspace ID | No | - | +| `--permission ` | - | Profile permission. 'read' blocks all write commands; 'write' lifts the restriction. | No | - | diff --git a/packages/sdk/src/cli/commands/api/index.ts b/packages/sdk/src/cli/commands/api/index.ts index 6a9bb54ab..72410f8b4 100644 --- a/packages/sdk/src/cli/commands/api/index.ts +++ b/packages/sdk/src/cli/commands/api/index.ts @@ -5,6 +5,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadConfig } from "@/cli/shared/config-loader"; import { loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { apiCall } from "./api-call"; import { inspectCommand } from "./inspect"; import { listCommand } from "./list"; @@ -102,6 +103,10 @@ Values already present in \`--body\` are never overridden. If a value cannot be }) .strict(), run: async (args) => { + // Direct API calls can target any OperatorService method, including + // Create/Update/Delete. Block all of them under a readonly profile rather + // than try to classify endpoints by name. + await assertWritable({ profile: args.profile }); const methodName = extractMethodName(args.endpoint); const method = getMethodDescriptor(methodName); diff --git a/packages/sdk/src/cli/commands/authconnection/authorize.ts b/packages/sdk/src/cli/commands/authconnection/authorize.ts index 81d2de90b..1826d46ae 100644 --- a/packages/sdk/src/cli/commands/authconnection/authorize.ts +++ b/packages/sdk/src/cli/commands/authconnection/authorize.ts @@ -7,6 +7,7 @@ import { fetchAll, initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { connectionNameArgs } from "./args"; const defaultPort = 8080; @@ -59,6 +60,7 @@ export const authorizeAuthConnectionCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/authconnection/revoke.ts b/packages/sdk/src/cli/commands/authconnection/revoke.ts index a988c4915..2bd1100ac 100644 --- a/packages/sdk/src/cli/commands/authconnection/revoke.ts +++ b/packages/sdk/src/cli/commands/authconnection/revoke.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { connectionNameArgs } from "./args"; export const revokeAuthConnectionCommand = defineAppCommand({ @@ -19,6 +20,7 @@ export const revokeAuthConnectionCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/deploy/index.ts b/packages/sdk/src/cli/commands/deploy/index.ts index a56c9f9fd..191f9b75f 100644 --- a/packages/sdk/src/cli/commands/deploy/index.ts +++ b/packages/sdk/src/cli/commands/deploy/index.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { deploy } from "@/cli/commands/deploy/deploy"; import { confirmationArgs, deploymentArgs } from "@/cli/shared/args"; import { defineAppCommand } from "@/cli/shared/command"; +import { assertWritable } from "@/cli/shared/readonly-guard"; export const deployCommand = defineAppCommand({ name: "deploy", @@ -28,6 +29,7 @@ export const deployCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const { initTelemetry } = await import("@/cli/telemetry"); await initTelemetry(); await deploy({ diff --git a/packages/sdk/src/cli/commands/executor/trigger.ts b/packages/sdk/src/cli/commands/executor/trigger.ts index 00d6d8bf8..6f9699cf1 100644 --- a/packages/sdk/src/cli/commands/executor/trigger.ts +++ b/packages/sdk/src/cli/commands/executor/trigger.ts @@ -7,6 +7,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger, styles } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { watchExecutorJob } from "./jobs"; import { executorTriggerTypeToString } from "./status"; import type { IncomingWebhookTrigger, ScheduleTriggerInput } from "@/types/executor.generated"; @@ -216,6 +217,7 @@ The \`--logs\` option displays logs from the downstream execution when available }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); // Validate trigger type before processing const accessToken = await loadAccessToken({ useProfile: true, diff --git a/packages/sdk/src/cli/commands/organization/folder/create.ts b/packages/sdk/src/cli/commands/organization/folder/create.ts index 8ab5484a4..0034132c0 100644 --- a/packages/sdk/src/cli/commands/organization/folder/create.ts +++ b/packages/sdk/src/cli/commands/organization/folder/create.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { folderInfo, type FolderInfo } from "../transform"; const createFolderOptionsSchema = z.object({ @@ -58,6 +59,7 @@ export const createCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const folder = await createFolder({ organizationId: args["organization-id"], parentFolderId: args["parent-folder-id"], diff --git a/packages/sdk/src/cli/commands/organization/folder/delete.ts b/packages/sdk/src/cli/commands/organization/folder/delete.ts index 7c651751c..157e74e9d 100644 --- a/packages/sdk/src/cli/commands/organization/folder/delete.ts +++ b/packages/sdk/src/cli/commands/organization/folder/delete.ts @@ -5,6 +5,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; const deleteFolderOptionsSchema = z.object({ organizationId: z.uuid({ message: "organization-id must be a valid UUID" }), @@ -44,6 +45,7 @@ export const deleteCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const accessToken = await loadAccessToken(); const client = await initOperatorClient(accessToken); diff --git a/packages/sdk/src/cli/commands/organization/folder/update.ts b/packages/sdk/src/cli/commands/organization/folder/update.ts index 48b8e6d00..23f3735e2 100644 --- a/packages/sdk/src/cli/commands/organization/folder/update.ts +++ b/packages/sdk/src/cli/commands/organization/folder/update.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { folderInfo, type FolderInfo } from "../transform"; const updateFolderOptionsSchema = z.object({ @@ -56,6 +57,7 @@ export const updateCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const folder = await updateFolder({ organizationId: args["organization-id"], folderId: args["folder-id"], diff --git a/packages/sdk/src/cli/commands/organization/update.ts b/packages/sdk/src/cli/commands/organization/update.ts index fdda997f9..dd7d6fbee 100644 --- a/packages/sdk/src/cli/commands/organization/update.ts +++ b/packages/sdk/src/cli/commands/organization/update.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { organizationInfo, type OrganizationInfo } from "./transform"; const updateOrganizationOptionsSchema = z.object({ @@ -55,6 +56,7 @@ export const updateCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const organization = await updateOrganization({ organizationId: args["organization-id"], name: args.name, diff --git a/packages/sdk/src/cli/commands/profile/create.ts b/packages/sdk/src/cli/commands/profile/create.ts index f8c0b7917..91963ea53 100644 --- a/packages/sdk/src/cli/commands/profile/create.ts +++ b/packages/sdk/src/cli/commands/profile/create.ts @@ -23,6 +23,10 @@ export const createCommand = defineAppCommand({ alias: "w", description: "Workspace ID", }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission. 'read' blocks all write commands while the profile is active.", + }), }) .strict(), run: async (args) => { @@ -55,6 +59,7 @@ export const createCommand = defineAppCommand({ config.profiles[args.name] = { user: args.user, workspace_id: args["workspace-id"], + ...(args.permission === "read" ? { readonly: true } : {}), }; writePlatformConfig(config); @@ -67,6 +72,7 @@ export const createCommand = defineAppCommand({ name: args.name, user: args.user, workspaceId: args["workspace-id"], + permission: args.permission, }; logger.out(profileInfo); }, diff --git a/packages/sdk/src/cli/commands/profile/index.ts b/packages/sdk/src/cli/commands/profile/index.ts index e24ed21fb..23c00222e 100644 --- a/packages/sdk/src/cli/commands/profile/index.ts +++ b/packages/sdk/src/cli/commands/profile/index.ts @@ -8,6 +8,7 @@ export interface ProfileInfo { name: string; user: string; workspaceId: string; + permission: "read" | "write"; } export const profileCommand = defineCommand({ diff --git a/packages/sdk/src/cli/commands/profile/list.ts b/packages/sdk/src/cli/commands/profile/list.ts index 09d8e45cb..db12dd344 100644 --- a/packages/sdk/src/cli/commands/profile/list.ts +++ b/packages/sdk/src/cli/commands/profile/list.ts @@ -25,6 +25,7 @@ export const listCommand = defineAppCommand({ name, user: profile!.user, workspaceId: profile!.workspace_id, + permission: profile!.readonly === true ? "read" : "write", })); logger.out(profileInfos); }, diff --git a/packages/sdk/src/cli/commands/profile/update.test.ts b/packages/sdk/src/cli/commands/profile/update.test.ts new file mode 100644 index 000000000..e243d7546 --- /dev/null +++ b/packages/sdk/src/cli/commands/profile/update.test.ts @@ -0,0 +1,130 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { runCommand } from "politty"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAll, initOperatorClient } from "@/cli/shared/client"; +import { fetchLatestToken, readPlatformConfig, writePlatformConfig } from "@/cli/shared/context"; +import { resetKeyringState } from "@/cli/shared/token-store"; +import { updateCommand } from "./update"; + +const xdgTempDir = vi.hoisted(() => `/tmp/tailor-profile-update-${Date.now()}-${Math.random()}`); + +vi.mock("xdg-basedir", () => ({ + xdgConfig: xdgTempDir, +})); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + setPassword() {} + getPassword(): string | null { + return null; + } + deletePassword() {} + }, +})); + +vi.mock("@/cli/shared/client", async (importOriginal) => ({ + ...(await importOriginal()), + initOperatorClient: vi.fn(), + fetchAll: vi.fn(), +})); + +// Mock fetchLatestToken without disturbing readPlatformConfig / writePlatformConfig, +// which the run handler also uses and which we want to round-trip on disk. +vi.mock("@/cli/shared/context", async (importOriginal) => ({ + ...(await importOriginal()), + fetchLatestToken: vi.fn(), +})); + +const validUUID = "12345678-1234-4abc-8def-123456789012"; + +beforeAll(() => { + fs.mkdirSync(xdgTempDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(xdgTempDir, { recursive: true, force: true }); +}); + +describe("profile update --permission", () => { + beforeEach(async () => { + vi.clearAllMocks(); + resetKeyringState(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + // Silence logger output during tests so the table renderer doesn't + // pollute stdout. + const { logger } = await import("@/cli/shared/logger"); + vi.spyOn(logger, "out").mockImplementation(() => {}); + vi.spyOn(logger, "success").mockImplementation(() => {}); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + rw: { user: "u@example.com", workspace_id: validUUID }, + ro: { user: "u@example.com", workspace_id: validUUID, readonly: true }, + }, + current_user: null, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + // Clean up the on-disk config between tests so prior writes don't leak. + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + it("sets readonly: true on disk and skips remote validation when only --permission read is passed", async () => { + await runCommand(updateCommand, ["rw", "--permission", "read"]); + + const config = await readPlatformConfig(); + expect(config.profiles.rw?.readonly).toBe(true); + + // Key behavioral guarantee: no token / workspace lookup happens for a + // pure permission toggle. Otherwise users could not lift readonly when + // their saved token has expired or the workspace has been removed. + expect(vi.mocked(fetchLatestToken)).not.toHaveBeenCalled(); + expect(vi.mocked(initOperatorClient)).not.toHaveBeenCalled(); + expect(vi.mocked(fetchAll)).not.toHaveBeenCalled(); + }); + + it("clears readonly when --permission write is passed and skips remote validation", async () => { + await runCommand(updateCommand, ["ro", "--permission", "write"]); + + const config = await readPlatformConfig(); + // We don't store readonly: false; the field should be absent. + expect(config.profiles.ro?.readonly).toBeUndefined(); + + expect(vi.mocked(fetchLatestToken)).not.toHaveBeenCalled(); + expect(vi.mocked(initOperatorClient)).not.toHaveBeenCalled(); + }); + + it("performs remote validation when --user is also passed (permission does not bypass it)", async () => { + vi.mocked(fetchLatestToken).mockResolvedValue("mock-token"); + vi.mocked(fetchAll).mockResolvedValue([{ id: validUUID }]); + vi.mocked(initOperatorClient).mockResolvedValue({ + listWorkspaces: vi.fn(), + } as unknown as Awaited>); + + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + rw: { user: "old@example.com", workspace_id: validUUID }, + }, + current_user: null, + }); + + await runCommand(updateCommand, ["rw", "--user", "new@example.com", "--permission", "read"]); + + expect(vi.mocked(fetchLatestToken)).toHaveBeenCalledTimes(1); + expect(vi.mocked(fetchLatestToken)).toHaveBeenCalledWith(expect.anything(), "new@example.com"); + expect(vi.mocked(initOperatorClient)).toHaveBeenCalledTimes(1); + + const config = await readPlatformConfig(); + expect(config.profiles.rw?.user).toBe("new@example.com"); + expect(config.profiles.rw?.readonly).toBe(true); + }); +}); diff --git a/packages/sdk/src/cli/commands/profile/update.ts b/packages/sdk/src/cli/commands/profile/update.ts index 4b4d1e718..425d2a619 100644 --- a/packages/sdk/src/cli/commands/profile/update.ts +++ b/packages/sdk/src/cli/commands/profile/update.ts @@ -23,6 +23,10 @@ export const updateCommand = defineAppCommand({ alias: "w", description: "New workspace ID", }), + permission: arg(z.enum(["write", "read"]).optional(), { + description: + "Profile permission. 'read' blocks all write commands; 'write' lifts the restriction.", + }), }) .strict(), run: async (args) => { @@ -34,7 +38,7 @@ export const updateCommand = defineAppCommand({ } // Check if at least one property is provided - if (!args.user && !args["workspace-id"]) { + if (!args.user && !args["workspace-id"] && args.permission === undefined) { throw new Error("Please provide at least one property to update."); } @@ -44,26 +48,37 @@ export const updateCommand = defineAppCommand({ const oldWorkspaceId = profile.workspace_id; const newWorkspaceId = args["workspace-id"] || oldWorkspaceId; - // Check if user exists - const token = await fetchLatestToken(config, newUser); + // Skip remote validation when neither user nor workspace is changing. + // This keeps `profile update --permission write|read` working + // offline and when the saved token is expired or the workspace has been + // removed, important so a user can always lift their own readonly flag. + if (args.user !== undefined || args["workspace-id"] !== undefined) { + // Check if user exists + const token = await fetchLatestToken(config, newUser); - // Check if workspace exists - const client = await initOperatorClient(token); - const workspaces = await fetchAll(async (pageToken, maxPageSize) => { - const { workspaces, nextPageToken } = await client.listWorkspaces({ - pageToken, - pageSize: maxPageSize, + // Check if workspace exists + const client = await initOperatorClient(token); + const workspaces = await fetchAll(async (pageToken, maxPageSize) => { + const { workspaces, nextPageToken } = await client.listWorkspaces({ + pageToken, + pageSize: maxPageSize, + }); + return [workspaces, nextPageToken]; }); - return [workspaces, nextPageToken]; - }); - const workspace = workspaces.find((ws) => ws.id === newWorkspaceId); - if (!workspace) { - throw new Error(`Workspace "${newWorkspaceId}" not found.`); + const workspace = workspaces.find((ws) => ws.id === newWorkspaceId); + if (!workspace) { + throw new Error(`Workspace "${newWorkspaceId}" not found.`); + } } // Update properties profile.user = newUser; profile.workspace_id = newWorkspaceId; + if (args.permission === "read") { + profile.readonly = true; + } else if (args.permission === "write") { + delete profile.readonly; + } writePlatformConfig(config); if (!args.json) { logger.success(`Profile "${args.name}" updated successfully`); @@ -74,6 +89,7 @@ export const updateCommand = defineAppCommand({ name: args.name, user: newUser, workspaceId: newWorkspaceId, + permission: profile.readonly === true ? "read" : "write", }; logger.out(profileInfo); }, diff --git a/packages/sdk/src/cli/commands/remove.ts b/packages/sdk/src/cli/commands/remove.ts index 52b75eed1..3f70a6e83 100644 --- a/packages/sdk/src/cli/commands/remove.ts +++ b/packages/sdk/src/cli/commands/remove.ts @@ -21,6 +21,7 @@ import { loadConfig, type LoadedConfig } from "@/cli/shared/config-loader"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import ml from "@/utils/multiline"; export interface RemoveOptions { @@ -172,6 +173,7 @@ export const removeCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const { client, workspaceId, application, config } = await loadOptions({ workspaceId: args["workspace-id"], profile: args.profile, diff --git a/packages/sdk/src/cli/commands/secret/create.ts b/packages/sdk/src/cli/commands/secret/create.ts index efcc33e33..7dbf42d13 100644 --- a/packages/sdk/src/cli/commands/secret/create.ts +++ b/packages/sdk/src/cli/commands/secret/create.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { secretValueArgs } from "./args"; import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed"; @@ -20,6 +21,7 @@ export const createSecretCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/secret/delete.ts b/packages/sdk/src/cli/commands/secret/delete.ts index 412cf4ac6..df586bf15 100644 --- a/packages/sdk/src/cli/commands/secret/delete.ts +++ b/packages/sdk/src/cli/commands/secret/delete.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { secretIdentifyArgs } from "./args"; import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed"; @@ -20,6 +21,7 @@ export const deleteSecretCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/secret/update.ts b/packages/sdk/src/cli/commands/secret/update.ts index d54e0d111..8893778af 100644 --- a/packages/sdk/src/cli/commands/secret/update.ts +++ b/packages/sdk/src/cli/commands/secret/update.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { secretValueArgs } from "./args"; import { checkVaultManaged, releaseVaultOwnership } from "./check-vault-managed"; @@ -20,6 +21,7 @@ export const updateSecretCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/secret/vault/create.ts b/packages/sdk/src/cli/commands/secret/vault/create.ts index e2fd623ca..f48071413 100644 --- a/packages/sdk/src/cli/commands/secret/vault/create.ts +++ b/packages/sdk/src/cli/commands/secret/vault/create.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { nameArgs } from "./args"; export const createCommand = defineAppCommand({ @@ -17,6 +18,7 @@ export const createCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/secret/vault/delete.ts b/packages/sdk/src/cli/commands/secret/vault/delete.ts index 2ee9b8db5..b597f7ede 100644 --- a/packages/sdk/src/cli/commands/secret/vault/delete.ts +++ b/packages/sdk/src/cli/commands/secret/vault/delete.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { checkVaultManaged } from "../check-vault-managed"; import { nameArgs } from "./args"; @@ -20,6 +21,7 @@ export const deleteCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const accessToken = await loadAccessToken({ useProfile: true, profile: args.profile, diff --git a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts index 371931343..92e9f9a59 100644 --- a/packages/sdk/src/cli/commands/staticwebsite/deploy.ts +++ b/packages/sdk/src/cli/commands/staticwebsite/deploy.ts @@ -11,6 +11,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { createProgress, withTimeout } from "@/cli/shared/progress"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import type { MessageInitShape } from "@bufbuild/protobuf"; import type { UploadFileRequestSchema } from "@tailor-proto/tailor/v1/staticwebsite_pb"; @@ -243,6 +244,7 @@ export const deployCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); logger.info(`Deploying static website "${args.name}" from directory: ${args.dir}`); const accessToken = await loadAccessToken({ useProfile: true, diff --git a/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts b/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts index 96ac5a7c0..2ea6b8725 100644 --- a/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts +++ b/packages/sdk/src/cli/commands/tailordb/erd/deploy.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { deploymentArgs } from "@/cli/shared/args"; import { defineAppCommand } from "@/cli/shared/command"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { deployStaticWebsite, logSkippedFiles } from "../../staticwebsite/deploy"; import { prepareErdBuilds } from "./export"; import { initErdContext } from "./utils"; @@ -21,6 +22,7 @@ export const erdDeployCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const { client, workspaceId, config } = await initErdContext(args); const buildResults = await prepareErdBuilds({ client, diff --git a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts index 5c82f9c16..c4297e044 100644 --- a/packages/sdk/src/cli/commands/tailordb/migrate/set.ts +++ b/packages/sdk/src/cli/commands/tailordb/migrate/set.ts @@ -10,6 +10,7 @@ import { loadConfig } from "@/cli/shared/config-loader"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger, styles } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { getNamespacesWithMigrations } from "./config"; import { formatMigrationNumber, isValidMigrationNumber } from "./snapshot"; import { parseMigrationLabelNumber } from "./types"; @@ -167,6 +168,7 @@ export const setCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); await set({ configPath: args.config, number: args.number, diff --git a/packages/sdk/src/cli/commands/tailordb/truncate.ts b/packages/sdk/src/cli/commands/tailordb/truncate.ts index f5c1355da..00ebd83cb 100644 --- a/packages/sdk/src/cli/commands/tailordb/truncate.ts +++ b/packages/sdk/src/cli/commands/tailordb/truncate.ts @@ -8,6 +8,7 @@ import { loadConfig } from "@/cli/shared/config-loader"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { resolveTypeNamespaces } from "@/cli/shared/tailordb-namespace"; export interface TruncateOptions { @@ -219,6 +220,7 @@ export const truncateCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); const types = args.types && args.types.length > 0 ? args.types : undefined; await $truncate({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/user/pat/create.ts b/packages/sdk/src/cli/commands/user/pat/create.ts index 3656b6098..438779171 100644 --- a/packages/sdk/src/cli/commands/user/pat/create.ts +++ b/packages/sdk/src/cli/commands/user/pat/create.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { fetchLatestToken, readPlatformConfig } from "@/cli/shared/context"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import ml from "@/utils/multiline"; import { getScopesFromWriteFlag, printCreatedToken } from "./transform"; @@ -22,6 +23,7 @@ export const createCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const config = await readPlatformConfig(); if (!config.current_user) { diff --git a/packages/sdk/src/cli/commands/user/pat/delete.ts b/packages/sdk/src/cli/commands/user/pat/delete.ts index cb16434e9..4baeecbff 100644 --- a/packages/sdk/src/cli/commands/user/pat/delete.ts +++ b/packages/sdk/src/cli/commands/user/pat/delete.ts @@ -4,6 +4,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { fetchLatestToken, readPlatformConfig } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import ml from "@/utils/multiline"; export const deleteCommand = defineAppCommand({ @@ -18,6 +19,7 @@ export const deleteCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const config = await readPlatformConfig(); if (!config.current_user) { diff --git a/packages/sdk/src/cli/commands/user/pat/update.ts b/packages/sdk/src/cli/commands/user/pat/update.ts index 8382a669d..52c298698 100644 --- a/packages/sdk/src/cli/commands/user/pat/update.ts +++ b/packages/sdk/src/cli/commands/user/pat/update.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { fetchLatestToken, readPlatformConfig } from "@/cli/shared/context"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import ml from "@/utils/multiline"; import { getScopesFromWriteFlag, printCreatedToken } from "./transform"; @@ -22,6 +23,7 @@ export const updateCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const config = await readPlatformConfig(); if (!config.current_user) { diff --git a/packages/sdk/src/cli/commands/workspace/create.test.ts b/packages/sdk/src/cli/commands/workspace/create.test.ts new file mode 100644 index 000000000..d1270d8ce --- /dev/null +++ b/packages/sdk/src/cli/commands/workspace/create.test.ts @@ -0,0 +1,146 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { runCommand } from "politty"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { initOperatorClient } from "@/cli/shared/client"; +import { readPlatformConfig, writePlatformConfig } from "@/cli/shared/context"; +import { resetKeyringState } from "@/cli/shared/token-store"; +import { createCommand } from "./create"; + +const xdgTempDir = vi.hoisted(() => `/tmp/tailor-workspace-create-${Date.now()}-${Math.random()}`); + +vi.mock("xdg-basedir", () => ({ + xdgConfig: xdgTempDir, +})); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + setPassword() {} + getPassword(): string | null { + return null; + } + deletePassword() {} + }, +})); + +vi.mock("@/cli/shared/client", async (importOriginal) => ({ + ...(await importOriginal()), + initOperatorClient: vi.fn(), +})); + +const validUUID = "12345678-1234-4abc-8def-123456789012"; + +function seedConfig() { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: { + "u@example.com": { + storage: "file", + token_expires_at: "2099-12-31T00:00:00Z", + access_token: "mock-token", + refresh_token: undefined, + }, + }, + profiles: {}, + current_user: "u@example.com", + }); +} + +function stubClient() { + vi.mocked(initOperatorClient).mockResolvedValue({ + listAvailableWorkspaceRegions: vi.fn().mockResolvedValue({ regions: ["us-west"] }), + createWorkspace: vi.fn().mockResolvedValue({ + workspace: { + id: validUUID, + name: "test-ws", + region: "us-west", + createdAt: { seconds: 0n, nanos: 0 }, + }, + }), + } as unknown as Awaited>); +} + +beforeAll(() => { + fs.mkdirSync(xdgTempDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(xdgTempDir, { recursive: true, force: true }); +}); + +describe("workspace create --permission", () => { + beforeEach(async () => { + vi.clearAllMocks(); + resetKeyringState(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + vi.stubEnv("TAILOR_PLATFORM_TOKEN", "mock-token"); + const { logger } = await import("@/cli/shared/logger"); + vi.spyOn(logger, "out").mockImplementation(() => {}); + vi.spyOn(logger, "success").mockImplementation(() => {}); + vi.spyOn(logger, "warn").mockImplementation(() => {}); + seedConfig(); + stubClient(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + // Clean up the on-disk config between tests so prior writes don't leak. + const configPath = path.join(xdgTempDir, "tailor-platform", "config.yaml"); + if (fs.existsSync(configPath)) fs.rmSync(configPath); + }); + + it("persists readonly: true when --permission read is combined with --profile-name", async () => { + await runCommand(createCommand, [ + "--name", + "test-ws", + "--region", + "us-west", + "--profile-name", + "bootstrap", + "--profile-user", + "u@example.com", + "--permission", + "read", + ]); + + const config = await readPlatformConfig(); + expect(config.profiles.bootstrap?.readonly).toBe(true); + }); + + it("omits the readonly key when --profile-name is given without --permission read", async () => { + await runCommand(createCommand, [ + "--name", + "test-ws", + "--region", + "us-west", + "--profile-name", + "bootstrap", + "--profile-user", + "u@example.com", + ]); + + const config = await readPlatformConfig(); + expect(config.profiles.bootstrap).toBeDefined(); + // We do not store readonly: false; the field should be absent so the + // YAML output stays compatible with existing v2 configs. + expect(config.profiles.bootstrap?.readonly).toBeUndefined(); + }); + + it("creates no profile when --permission read is passed without --profile-name", async () => { + // Matches the existing --profile-user behavior: profile-only flags are + // silently inert when --profile-name is absent. We don't store the flag + // anywhere because no profile was created to attach it to. + await runCommand(createCommand, [ + "--name", + "test-ws", + "--region", + "us-west", + "--permission", + "read", + ]); + + const config = await readPlatformConfig(); + expect(Object.keys(config.profiles)).toHaveLength(0); + }); +}); diff --git a/packages/sdk/src/cli/commands/workspace/create.ts b/packages/sdk/src/cli/commands/workspace/create.ts index 4f6598d5d..3fd0cf7f9 100644 --- a/packages/sdk/src/cli/commands/workspace/create.ts +++ b/packages/sdk/src/cli/commands/workspace/create.ts @@ -4,6 +4,7 @@ import { initOperatorClient, type OperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, readPlatformConfig, writePlatformConfig } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { workspaceInfo, type WorkspaceInfo } from "./transform"; import type { ProfileInfo } from "../profile"; @@ -101,9 +102,16 @@ export const createCommand = defineAppCommand({ "profile-user": arg(z.string().optional(), { description: "User email for the profile (defaults to current user)", }), + permission: arg(z.enum(["write", "read"]).default("write"), { + description: + "Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active.", + }), }) .strict(), run: async (args) => { + // This command does not expose `--profile`, so the guard resolves the + // active profile from `TAILOR_PLATFORM_PROFILE` only. + await assertWritable(); // Execute workspace create logic const workspace = await createWorkspace({ name: args.name, @@ -136,12 +144,14 @@ export const createCommand = defineAppCommand({ config.profiles[profileName] = { user: profileUser, workspace_id: workspace.id, + ...(args.permission === "read" ? { readonly: true } : {}), }; writePlatformConfig(config); profileInfo = { name: profileName, user: profileUser, workspaceId: workspace.id, + permission: args.permission, }; if (!args.json) { diff --git a/packages/sdk/src/cli/commands/workspace/delete.ts b/packages/sdk/src/cli/commands/workspace/delete.ts index 04e8f133e..c3eb0a351 100644 --- a/packages/sdk/src/cli/commands/workspace/delete.ts +++ b/packages/sdk/src/cli/commands/workspace/delete.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, readPlatformConfig, writePlatformConfig } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; const deleteWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), @@ -57,6 +58,7 @@ export const deleteCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); // Load and validate options const { client, workspaceId } = await loadOptions({ workspaceId: args["workspace-id"], diff --git a/packages/sdk/src/cli/commands/workspace/restore.ts b/packages/sdk/src/cli/commands/workspace/restore.ts index d16d70965..98766b5c2 100644 --- a/packages/sdk/src/cli/commands/workspace/restore.ts +++ b/packages/sdk/src/cli/commands/workspace/restore.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; const restoreWorkspaceOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }), @@ -54,6 +55,7 @@ export const restoreCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable(); const { client, workspaceId } = await loadOptions({ workspaceId: args["workspace-id"], }); diff --git a/packages/sdk/src/cli/commands/workspace/user/invite.ts b/packages/sdk/src/cli/commands/workspace/user/invite.ts index 1f5dc0720..5c08dd64c 100644 --- a/packages/sdk/src/cli/commands/workspace/user/invite.ts +++ b/packages/sdk/src/cli/commands/workspace/user/invite.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { stringToRole, validRoles } from "./transform"; const inviteUserOptionsSchema = z.object({ @@ -68,6 +69,7 @@ export const inviteCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); await inviteUser({ workspaceId: args["workspace-id"], profile: args.profile, diff --git a/packages/sdk/src/cli/commands/workspace/user/remove.ts b/packages/sdk/src/cli/commands/workspace/user/remove.ts index 484f234e3..54de188dc 100644 --- a/packages/sdk/src/cli/commands/workspace/user/remove.ts +++ b/packages/sdk/src/cli/commands/workspace/user/remove.ts @@ -6,6 +6,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; import { prompt } from "@/cli/shared/prompt"; +import { assertWritable } from "@/cli/shared/readonly-guard"; const removeUserOptionsSchema = z.object({ workspaceId: z.uuid({ message: "workspace-id must be a valid UUID" }).optional(), @@ -62,6 +63,7 @@ export const removeCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); if (!args.yes) { const confirmation = await prompt.text({ message: `Are you sure you want to remove user "${args.email}" from the workspace? (yes/no):`, diff --git a/packages/sdk/src/cli/commands/workspace/user/update.ts b/packages/sdk/src/cli/commands/workspace/user/update.ts index 029ea5585..970fba5ef 100644 --- a/packages/sdk/src/cli/commands/workspace/user/update.ts +++ b/packages/sdk/src/cli/commands/workspace/user/update.ts @@ -5,6 +5,7 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger } from "@/cli/shared/logger"; +import { assertWritable } from "@/cli/shared/readonly-guard"; import { stringToRole, validRoles } from "./transform"; const updateUserOptionsSchema = z.object({ @@ -68,6 +69,7 @@ export const updateCommand = defineAppCommand({ }) .strict(), run: async (args) => { + await assertWritable({ profile: args.profile }); await updateUser({ workspaceId: args["workspace-id"], profile: args.profile, diff --git a/packages/sdk/src/cli/shared/context.test.ts b/packages/sdk/src/cli/shared/context.test.ts index 881f15c0d..7435e2f9b 100644 --- a/packages/sdk/src/cli/shared/context.test.ts +++ b/packages/sdk/src/cli/shared/context.test.ts @@ -472,6 +472,33 @@ describe("loadAccessToken", () => { }); }); +describe("profile readonly field", () => { + beforeEach(() => { + resetKeyringState(); + }); + + it("round-trips readonly: true through write/read", async () => { + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + ro: { + user: "u@example.com", + workspace_id: "12345678-1234-4abc-8def-123456789012", + readonly: true, + }, + rw: { user: "u@example.com", workspace_id: "12345678-1234-4abc-8def-123456789012" }, + }, + current_user: null, + }); + const { readPlatformConfig } = await import("./context"); + const config = await readPlatformConfig(); + expect(config.profiles.ro?.readonly).toBe(true); + expect(config.profiles.rw?.readonly).toBeUndefined(); + }); +}); + describe("V1 to V2 migration", () => { const futureDate = new Date(Date.now() + 3600 * 1000).toISOString(); diff --git a/packages/sdk/src/cli/shared/context.ts b/packages/sdk/src/cli/shared/context.ts index afd256f49..b1208e74d 100644 --- a/packages/sdk/src/cli/shared/context.ts +++ b/packages/sdk/src/cli/shared/context.ts @@ -21,6 +21,7 @@ import { const pfProfileSchema = z.object({ user: z.string(), workspace_id: z.string(), + readonly: z.boolean().optional(), }); const pfUserSchemaV1 = z.object({ diff --git a/packages/sdk/src/cli/shared/readonly-guard.test.ts b/packages/sdk/src/cli/shared/readonly-guard.test.ts new file mode 100644 index 000000000..14e514f95 --- /dev/null +++ b/packages/sdk/src/cli/shared/readonly-guard.test.ts @@ -0,0 +1,316 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { writePlatformConfig } from "./context"; +import { isCLIError } from "./errors"; +import { assertWritable } from "./readonly-guard"; +import { resetKeyringState } from "./token-store"; + +/** + * Runnable command modules that live outside `src/cli/commands/` whose `run` + * mutates platform state and so must call `assertWritable`. Add a path here + * when a new top-level command is wired up outside `commands/`. + * + * (Note: `query/index.ts` lives outside `commands/` but is exempt from the + * guard because it executes under a machine user, so it is not listed.) + */ +const ADDITIONAL_RUNNABLE_COMMAND_PATHS: string[] = []; + +/** + * Allowlist of command files that do NOT touch platform state, plus the + * subcommand routers that only delegate to children. Every other command + * file under `commands/` must call `assertWritable` so a readonly profile + * cannot accidentally drive a mutation. + * + * Adding a new command? If it is a pure read or local-only operation, add + * its path here. Otherwise, inject `await assertWritable(...)` at the top + * of its `run()` and leave this list alone. + */ +const READ_OR_LOCAL_COMMAND_PATHS = new Set([ + // Top-level local-only operations + "init.ts", + "login.ts", + "logout.ts", + "open.ts", + "show.ts", + // API introspection sub-commands (read-only). The parent `api/index.ts` + // is NOT here because its `run` calls arbitrary OperatorService methods + // (including Create*/Update*/Delete*) and must guard. + "api/inspect.ts", + "api/list.ts", + // Auth connections (read-only) + "authconnection/index.ts", + "authconnection/list.ts", + // Crash report (local file ops + reporting endpoint, not workspace state) + "crashreport/index.ts", + "crashreport/list.ts", + "crashreport/send.ts", + // Executor (read-only). `executor/trigger.ts` calls the production + // `TriggerExecutor` RPC with the operator token (it creates a platform-side + // job record), so it stays guarded. + "executor/index.ts", + "executor/get.ts", + "executor/jobs.ts", + "executor/list.ts", + "executor/webhook.ts", + // Function (read-only / local execution). `function/test-run.ts` is exempt + // because it runs under a machine user via `testExecScript` whose own + // permissions gate any application-data effects. + "function/index.ts", + "function/bundle.ts", + "function/get.ts", + "function/list.ts", + "function/logs.ts", + "function/test-run.ts", + // Generate (local code generation) + "generate/index.ts", + // Machine user (read-only; token retrieval only fetches, does not mutate) + "machineuser/index.ts", + "machineuser/list.ts", + "machineuser/token.ts", + // OAuth2 client (read-only) + "oauth2client/index.ts", + "oauth2client/get.ts", + "oauth2client/list.ts", + // Organization (read-only branches; folder/update is write, guarded separately) + "organization/index.ts", + "organization/get.ts", + "organization/list.ts", + "organization/tree.ts", + "organization/folder/index.ts", + "organization/folder/get.ts", + "organization/folder/list.ts", + // Profile management (local config only, never platform state) + "profile/index.ts", + "profile/create.ts", + "profile/delete.ts", + "profile/list.ts", + "profile/update.ts", + // Secret (read-only) + "secret/index.ts", + "secret/list.ts", + "secret/vault/index.ts", + "secret/vault/list.ts", + // Setup (local file generation) + "setup/index.ts", + "setup/github/index.ts", + // Skills (local file install) + "skills/index.ts", + "skills/install.ts", + // Static website (read-only) + "staticwebsite/index.ts", + "staticwebsite/get.ts", + "staticwebsite/list.ts", + // TailorDB (read-only / local ops) + "tailordb/index.ts", + "tailordb/erd/index.ts", + "tailordb/erd/export.ts", + "tailordb/erd/serve.ts", + "tailordb/migrate/index.ts", + "tailordb/migrate/generate.ts", + "tailordb/migrate/status.ts", + // Upgrade (local SDK upgrade) + "upgrade/index.ts", + // User (read-only / local switch) + "user/index.ts", + "user/current.ts", + "user/list.ts", + "user/switch.ts", + "user/pat/index.ts", + "user/pat/list.ts", + // Workflow (read-only branches). `workflow/start.ts` and `workflow/resume.ts` + // are exempt because their execution runs under a machine user whose own + // permissions gate any application-data effects. + "workflow/index.ts", + "workflow/executions.ts", + "workflow/get.ts", + "workflow/list.ts", + "workflow/resume.ts", + "workflow/start.ts", + // Workspace (read-only branches) + "workspace/index.ts", + "workspace/get.ts", + "workspace/list.ts", + "workspace/app/index.ts", + "workspace/app/health.ts", + "workspace/app/list.ts", + "workspace/user/index.ts", + "workspace/user/list.ts", +]); + +/** + * Recursively list `*.ts` files under `dir`, excluding tests and fixtures. + * Paths are returned relative to `dir` with forward slashes. + * @param dir - Root directory to walk + * @returns Relative file paths + */ +function listCommandSourceFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const out: string[] = []; + for (const entry of entries) { + if (entry.name === "__test_fixtures__") continue; + const child = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = listCommandSourceFiles(child).map((p) => `${entry.name}/${p}`); + out.push(...nested); + continue; + } + if (!entry.name.endsWith(".ts")) continue; + if (entry.name.endsWith(".test.ts")) continue; + out.push(entry.name); + } + return out; +} + +/** + * Decide whether a file defines a runnable CLI command. Helper modules that + * only export utilities are skipped because they are exercised through the + * commands that import them. + * @param source - File source code + * @returns Whether the file defines a runnable command + */ +function isRunnableCommandFile(source: string): boolean { + const definesCommand = source.includes("defineAppCommand(") || source.includes("defineCommand("); + if (!definesCommand) return false; + return /\brun\s*[:(]/.test(source); +} + +const xdgTempDir = vi.hoisted(() => `/tmp/tailor-readonly-${Date.now()}-${Math.random()}`); + +vi.mock("xdg-basedir", () => ({ + xdgConfig: xdgTempDir, +})); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + setPassword() {} + getPassword(): string | null { + return null; + } + deletePassword() {} + }, +})); + +beforeAll(() => { + fs.mkdirSync(xdgTempDir, { recursive: true }); +}); + +afterAll(() => { + fs.rmSync(xdgTempDir, { recursive: true, force: true }); +}); + +const validUUID = "12345678-1234-4abc-8def-123456789012"; + +describe("assertWritable", () => { + beforeEach(() => { + vi.resetModules(); + resetKeyringState(); + vi.stubEnv("TAILOR_PLATFORM_PROFILE", undefined); + writePlatformConfig({ + version: 2, + min_sdk_version: "1.29.0", + users: {}, + profiles: { + rw: { user: "u@example.com", workspace_id: validUUID }, + ro: { user: "u@example.com", workspace_id: validUUID, readonly: true }, + ro_false: { user: "u@example.com", workspace_id: validUUID, readonly: false }, + }, + current_user: null, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("resolves when no profile is in scope", async () => { + await expect(assertWritable()).resolves.toBeUndefined(); + }); + + it("resolves when explicit profile has readonly undefined", async () => { + await expect(assertWritable({ profile: "rw" })).resolves.toBeUndefined(); + }); + + it("resolves when explicit profile has readonly false", async () => { + await expect(assertWritable({ profile: "ro_false" })).resolves.toBeUndefined(); + }); + + it("resolves silently when profile not found (deferring error to caller)", async () => { + await expect(assertWritable({ profile: "missing" })).resolves.toBeUndefined(); + }); + + it("throws CLIError with PROFILE_READONLY when profile is readonly via opts", async () => { + const promise = assertWritable({ profile: "ro" }); + await expect(promise).rejects.toThrow('Profile "ro" is read-only.'); + await promise.catch((err) => { + expect(isCLIError(err)).toBe(true); + expect(err.code).toBe("PROFILE_READONLY"); + }); + }); + + it("throws when readonly profile is selected via TAILOR_PLATFORM_PROFILE env", async () => { + vi.stubEnv("TAILOR_PLATFORM_PROFILE", "ro"); + await expect(assertWritable()).rejects.toThrow('Profile "ro" is read-only.'); + }); + + it("opts.profile takes precedence over env (rw opts wins over ro env)", async () => { + vi.stubEnv("TAILOR_PLATFORM_PROFILE", "ro"); + await expect(assertWritable({ profile: "rw" })).resolves.toBeUndefined(); + }); + + it("opts.profile takes precedence over env (ro opts wins over rw env, throws)", async () => { + vi.stubEnv("TAILOR_PLATFORM_PROFILE", "rw"); + await expect(assertWritable({ profile: "ro" })).rejects.toThrow('Profile "ro" is read-only.'); + }); + + it("empty opts.profile falls through to env to match loader semantics", async () => { + // loadAccessToken / loadWorkspaceId use truthy fallback (||), so an empty + // --profile "" flag still ends up resolving to TAILOR_PLATFORM_PROFILE. + // The guard must mirror that or it leaves a bypass. + vi.stubEnv("TAILOR_PLATFORM_PROFILE", "ro"); + await expect(assertWritable({ profile: "" })).rejects.toThrow('Profile "ro" is read-only.'); + }); +}); + +describe("write command coverage", () => { + const cliDir = path.resolve(__dirname, ".."); + const commandsDir = path.join(cliDir, "commands"); + + it("every runnable command not on the read-only allowlist calls assertWritable", () => { + // Must match an actual call site, not just the import statement, so that + // deleting the call (while leaving the import) still fails this test. + const callPattern = /\bawait\s+assertWritable\s*\(/; + const offenders: string[] = []; + for (const relativePath of listCommandSourceFiles(commandsDir)) { + if (READ_OR_LOCAL_COMMAND_PATHS.has(relativePath)) continue; + const source = fs.readFileSync(path.join(commandsDir, relativePath), "utf-8"); + if (!isRunnableCommandFile(source)) continue; + if (!callPattern.test(source)) { + offenders.push(`commands/${relativePath}`); + } + } + for (const relativePath of ADDITIONAL_RUNNABLE_COMMAND_PATHS) { + const source = fs.readFileSync(path.join(cliDir, relativePath), "utf-8"); + if (!isRunnableCommandFile(source)) continue; + if (!callPattern.test(source)) { + offenders.push(relativePath); + } + } + expect(offenders).toEqual([]); + }); + + it("read-only allowlist entries reference real files", () => { + const missing: string[] = []; + for (const relativePath of READ_OR_LOCAL_COMMAND_PATHS) { + if (!fs.existsSync(path.join(commandsDir, relativePath))) { + missing.push(relativePath); + } + } + for (const relativePath of ADDITIONAL_RUNNABLE_COMMAND_PATHS) { + if (!fs.existsSync(path.join(cliDir, relativePath))) { + missing.push(relativePath); + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/packages/sdk/src/cli/shared/readonly-guard.ts b/packages/sdk/src/cli/shared/readonly-guard.ts new file mode 100644 index 000000000..dfabff37e --- /dev/null +++ b/packages/sdk/src/cli/shared/readonly-guard.ts @@ -0,0 +1,43 @@ +import { readPlatformConfig } from "./context"; +import { CLIError } from "./errors"; + +interface AssertWritableOptions { + /** Explicit profile name from command args. Falls back to TAILOR_PLATFORM_PROFILE. */ + profile?: string; +} + +/** + * Throw a CLIError if the active profile has `readonly: true`. + * + * Resolves the active profile in this order: + * 1. `opts.profile` (CLI flag) + * 2. `process.env.TAILOR_PLATFORM_PROFILE` + * + * If neither is set, no profile is in scope so the call is allowed. This is + * intentional: `TAILOR_PLATFORM_TOKEN` direct access (CI / machine user) and + * `--workspace-id` without a profile are out-of-band paths whose authorization + * is governed by the bearer token itself, not by the local profile flag. + * + * If the resolved profile cannot be found in the config, this function returns + * silently and lets downstream loaders surface the not-found error. + * @param opts - Options + * @param opts.profile - Optional explicit profile name from command args + */ +export async function assertWritable(opts?: AssertWritableOptions): Promise { + // Truthy fallback (||, not ??) so an empty `--profile ""` flag falls + // through to TAILOR_PLATFORM_PROFILE, matching loadAccessToken / + // loadWorkspaceId. Otherwise the loaders would still resolve a readonly + // profile from the env var while this guard returns silently. + const profileName = opts?.profile || process.env.TAILOR_PLATFORM_PROFILE; + if (!profileName) return; + const config = await readPlatformConfig(); + const profile = config.profiles[profileName]; + if (!profile || profile.readonly !== true) return; + throw CLIError({ + code: "PROFILE_READONLY", + message: `Profile "${profileName}" is read-only.`, + details: + "This profile blocks platform-state mutations (apply, create/update/delete, deploy, etc.). Application-data operations remain available because their permissions are governed by the machine user.", + suggestion: `Use a different profile, unset TAILOR_PLATFORM_PROFILE, or run 'tailor-sdk profile update ${profileName} --permission write'.`, + }); +}