Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/profile-readonly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tailor-platform/sdk": minor
---

Add `--permission <write|read>` 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 <endpoint>` 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 <name> --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 <name>` 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.
37 changes: 20 additions & 17 deletions packages/sdk/docs/cli/workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,16 @@ tailor-sdk workspace create [options]

**Options**

| Option | Alias | Description | Required | Default | Env |
| ------------------------------------- | ----- | ----------------------------------------------------- | -------- | ------- | --------------------------------- |
| `--name <NAME>` | `-n` | Workspace name | Yes | - | - |
| `--region <REGION>` | `-r` | Workspace region (us-west, asia-northeast) | Yes | - | - |
| `--delete-protection` | `-d` | Enable delete protection | No | `false` | - |
| `--organization-id <ORGANIZATION_ID>` | `-o` | Organization ID to workspace associate with | No | - | `TAILOR_PLATFORM_ORGANIZATION_ID` |
| `--folder-id <FOLDER_ID>` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` |
| `--profile-name <PROFILE_NAME>` | `-p` | Profile name to create | No | - | - |
| `--profile-user <PROFILE_USER>` | - | User email for the profile (defaults to current user) | No | - | - |
| Option | Alias | Description | Required | Default | Env |
| ------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------- | -------- | --------- | --------------------------------- |
| `--name <NAME>` | `-n` | Workspace name | Yes | - | - |
| `--region <REGION>` | `-r` | Workspace region (us-west, asia-northeast) | Yes | - | - |
| `--delete-protection` | `-d` | Enable delete protection | No | `false` | - |
| `--organization-id <ORGANIZATION_ID>` | `-o` | Organization ID to workspace associate with | No | - | `TAILOR_PLATFORM_ORGANIZATION_ID` |
| `--folder-id <FOLDER_ID>` | `-f` | Folder ID to workspace associate with | No | - | `TAILOR_PLATFORM_FOLDER_ID` |
| `--profile-name <PROFILE_NAME>` | `-p` | Profile name to create | No | - | - |
| `--profile-user <PROFILE_USER>` | - | User email for the profile (defaults to current user) | No | - | - |
| `--permission <PERMISSION>` | - | Profile permission (requires --profile-name). 'read' blocks all write commands while the profile is active. | No | `"write"` | - |

<!-- politty:command:workspace create:options:end -->

Expand Down Expand Up @@ -240,10 +241,11 @@ tailor-sdk profile create [options] <name>

**Options**

| Option | Alias | Description | Required | Default |
| ------------------------------- | ----- | ------------ | -------- | ------- |
| `--user <USER>` | `-u` | User email | Yes | - |
| `--workspace-id <WORKSPACE_ID>` | `-w` | Workspace ID | Yes | - |
| Option | Alias | Description | Required | Default |
| ------------------------------- | ----- | --------------------------------------------------------------------------------- | -------- | --------- |
| `--user <USER>` | `-u` | User email | Yes | - |
| `--workspace-id <WORKSPACE_ID>` | `-w` | Workspace ID | Yes | - |
| `--permission <PERMISSION>` | - | Profile permission. 'read' blocks all write commands while the profile is active. | No | `"write"` |

<!-- politty:command:profile create:options:end -->

Expand Down Expand Up @@ -318,10 +320,11 @@ tailor-sdk profile update [options] <name>

**Options**

| Option | Alias | Description | Required | Default |
| ------------------------------- | ----- | ---------------- | -------- | ------- |
| `--user <USER>` | `-u` | New user email | No | - |
| `--workspace-id <WORKSPACE_ID>` | `-w` | New workspace ID | No | - |
| Option | Alias | Description | Required | Default |
| ------------------------------- | ----- | ------------------------------------------------------------------------------------ | -------- | ------- |
| `--user <USER>` | `-u` | New user email | No | - |
| `--workspace-id <WORKSPACE_ID>` | `-w` | New workspace ID | No | - |
| `--permission <PERMISSION>` | - | Profile permission. 'read' blocks all write commands; 'write' lifts the restriction. | No | - |

<!-- politty:command:profile update:options:end -->

Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/cli/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

await assertWritable({ profile: args.profile });
const methodName = extractMethodName(args.endpoint);
const method = getMethodDescriptor(methodName);

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/authconnection/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/authconnection/revoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/executor/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/organization/folder/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/organization/folder/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down Expand Up @@ -44,6 +45,7 @@ export const deleteCommand = defineAppCommand({
})
.strict(),
run: async (args) => {
await assertWritable();
const accessToken = await loadAccessToken();
const client = await initOperatorClient(accessToken);

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/organization/folder/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/cli/commands/organization/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/src/cli/commands/profile/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);

Expand All @@ -67,6 +72,7 @@ export const createCommand = defineAppCommand({
name: args.name,
user: args.user,
workspaceId: args["workspace-id"],
permission: args.permission,
};
logger.out(profileInfo);
},
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/cli/commands/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface ProfileInfo {
name: string;
user: string;
workspaceId: string;
permission: "read" | "write";
}

export const profileCommand = defineCommand({
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/cli/commands/profile/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
Loading
Loading