Skip to content

feat(cli): add read-only profile flag to block write commands#1147

Open
dqn wants to merge 9 commits into
mainfrom
feat/profile-readonly
Open

feat(cli): add read-only profile flag to block write commands#1147
dqn wants to merge 9 commits into
mainfrom
feat/profile-readonly

Conversation

@dqn
Copy link
Copy Markdown
Contributor

@dqn dqn commented May 9, 2026

Adds a per-profile readonly flag so editor-permission users can default to a viewer-style workflow without dropping write capability project-wide. While a read-only profile is in scope, mutating CLI commands that operate on platform state via the operator's bearer token (apply, remove, workspace / secret / vault / folder / PAT mutations, executor trigger, staticwebsite / erd deploy, authconnection authorize / revoke, organization / workspace-user mutations, and direct api <endpoint> calls) fail fast with a PROFILE_READONLY error before any prompt or RPC.

Application-data operations that run under a machine user (query, workflow start / resume, function test-run) are not gated by this flag because the machine user's own permissions already govern those mutations.

Profile management itself stays open so the flag can always be cleared with tailor-sdk profile update <name> --no-readonly.

Usage

$ tailor-sdk profile create viewer --user me@example.com --workspace-id <uuid> --readonly
✔ Profile "viewer" created successfully.

$ TAILOR_PLATFORM_PROFILE=viewer tailor-sdk apply
Error [PROFILE_READONLY]: Profile "viewer" 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 viewer --no-readonly'.

$ tailor-sdk profile update viewer --no-readonly
✔ Profile "viewer" updated successfully

Main Changes

  • Add readonly?: boolean to the persisted profile schema; existing v2 configs round-trip unchanged.
  • New assertWritable({ profile }) helper resolves the active profile from --profile or TAILOR_PLATFORM_PROFILE and throws a CLIError { code: "PROFILE_READONLY" } when the profile is read-only.
  • Every platform-state write command's run() calls await assertWritable(...) at the top, before confirmations or token refreshes.
  • profile create / profile update accept --readonly (and --no-readonly to clear); workspace create accepts --readonly when used with --profile-name to bootstrap a read-only profile alongside the new workspace; profile list and the ProfileInfo JSON output expose the flag.
  • A fail-closed test enumerates every runnable command under cli/commands/ and requires anything not on a small read-only/local allowlist (which now includes the machine-user-driven app paths) to call await assertWritable(.

Notes

  • The guard activates only when a profile is in scope. TAILOR_PLATFORM_TOKEN direct injection and --workspace-id without a profile are out-of-band paths whose authorization is governed by the bearer token itself; documented in the assertWritable JSDoc and the changeset.

Profiles created with `--readonly` (or toggled via `profile update --readonly`)
refuse every mutating command — apply, remove, workspace/secret/vault/folder/PAT
mutations, workflow start/resume, executor trigger, staticwebsite/erd deploy,
function test-run, and authconnection authorize/revoke. The guard fires before
any prompt or RPC, so editor users can point `TAILOR_PLATFORM_PROFILE` at a
viewer-style profile and trip a clear `PROFILE_READONLY` error instead of an
accidental apply.

Coverage stays honest with a fail-closed test: every runnable command that is
not on the read-only/local allowlist must call `assertWritable`, and the matcher
anchors on `await assertWritable(` so dropping the call without dropping the
import still trips CI.

Refs platform-planning#1109.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 9, 2026

🦋 Changeset detected

Latest commit: 9a0f5a4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@tailor-platform/sdk Minor
@tailor-platform/create-sdk Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

⚡ pkg.pr.new

@tailor-platform/sdk

pnpm add https://pkg.pr.new/@tailor-platform/sdk@9a0f5a4
pnpm dlx https://pkg.pr.new/@tailor-platform/sdk@9a0f5a4 --help

@tailor-platform/create-sdk

pnpm add https://pkg.pr.new/@tailor-platform/create-sdk@9a0f5a4
pnpm dlx https://pkg.pr.new/@tailor-platform/create-sdk@9a0f5a4 my-app

commit: 9a0f5a4

@github-actions

This comment has been minimized.

dqn added 4 commits May 10, 2026 06:58
The fail-closed allowlist exempted the parent `api` command and missed
the top-level `query` command entirely. Both can mutate platform state:
`tailor-sdk api <endpoint>` invokes arbitrary OperatorService methods
including Create/Update/Delete; `tailor-sdk query` forwards SQL and
GraphQL through executeScript, which can run DELETE/UPDATE or GraphQL
mutations. Guard both with assertWritable and extend the coverage test
to walk command files outside src/cli/commands/.

Also let `profile update --readonly` / `--no-readonly` skip remote user
and workspace validation when neither user nor workspace is changing,
so the readonly flag can be cleared offline or with an expired token.
`assertWritable` used `??` to fall through to TAILOR_PLATFORM_PROFILE,
but `loadAccessToken` / `loadWorkspaceId` use `||`. With `--profile ""`
the guard saw an empty explicit profile and returned silently while
the loaders still picked up a readonly profile from the env var, so
mutating commands could run anyway. Use truthy fallback in the guard
to match the loaders.
Polish pass: drop em-dashes from comments and changeset, collapse the
two consecutive early returns in assertWritable into one, and reword
the rhetorical-question test name added in the previous commit.
Cover the key new guarantees: --readonly / --no-readonly skips remote
user / workspace validation, --no-readonly clears the field on disk,
and mixing --user with --readonly still validates remote.
@github-actions

This comment has been minimized.

@dqn dqn marked this pull request as ready for review May 9, 2026 22:49
@dqn dqn requested review from remiposo and toiroakr as code owners May 9, 2026 22:49
@claude
Copy link
Copy Markdown

claude Bot commented May 9, 2026

📖 Docs Consistency Check

✅ No inconsistencies found between documentation and implementation.

Checked areas:

  • CLI documentation (packages/sdk/docs/cli/workspace.md):

    • workspace create --readonly flag - ✅ documented and matches implementation
    • profile create --readonly flag - ✅ documented and matches implementation
    • profile update --readonly / --no-readonly flags - ✅ documented and matches implementation
  • Command implementations:

    • All three commands with new --readonly flags match their documented descriptions exactly
    • ProfileInfo interface correctly includes readonly: boolean field
    • Profile list/create/update commands all handle the readonly field correctly
  • Internal changes (no documentation needed):

    • 30+ commands added assertWritable() guard - these are internal changes that don't affect user-facing options
    • Commands include: apply, remove, api, authconnection authorize/revoke, deploy, executor trigger, secret create/update/delete, tailordb migrate set, tailordb truncate, staticwebsite deploy, organization/folder/PAT/workspace-user mutations
  • Other documentation:

    • CLI reference index correctly references profile commands
    • Configuration docs, quickstart, and CLAUDE.md don't require updates (no CLI profile mentions)
    • Example code doesn't use CLI profiles

Notes:

  • CLI documentation is auto-generated by politty from command definitions, ensuring consistency
  • All auto-generated sections in workspace.md are properly marked with <!-- politty:command:...:start/end --> markers
  • The comprehensive test suite in readonly-guard.test.ts enforces that all write commands call assertWritable(), preventing future inconsistencies

name: profileName,
user: profileUser,
workspaceId: workspace.id,
readonly: false,
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.

So your judgment is that there is no need to make the profile read-only at the time the workspace is created, correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in aca77b64.

Comment thread packages/sdk/src/cli/query/index.ts Outdated
Comment on lines +786 to +789
// SQL/GraphQL submitted here is forwarded as-is; we cannot reliably
// classify a statement as read-only, so block the whole command under a
// readonly profile.
await assertWritable({ profile: args.profile });
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.

Regarding the manipulation of application data, permission management is expected to be handled by machineUser, so I don't think it's necessary to call assertWritable here.

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.

The same applies to workflow execution and test runs.

It might be worth considering making it possible to set a default machine user in the profile separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 37f6ec73.

Comment on lines +106 to +108
// 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.
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.

👍

dqn added 2 commits May 11, 2026 21:03
Adds `--readonly` to `workspace create` so the profile written via
`--profile-name` can opt into read-only mode at creation time, matching
the option already exposed by `profile create`. Passing `--readonly`
without `--profile-name` warns and continues so the workspace is still
created.

Also documents that the existing `assertWritable()` call in
`workspace create` resolves the active profile from
`TAILOR_PLATFORM_PROFILE` only (the command does not accept `--profile`).
Application-data operations run through a configured machine user whose
own permissions already govern what the workflow / function / SQL or
GraphQL request can do, so gating them on the operator's local profile
flag duplicates a concern that lives on the platform side. Drop
`assertWritable` from `query`, `workflow start`, `workflow resume`, and
`function test-run`, and move those files (plus the previously omitted
`query/index.ts` external runnable path) into the read-or-local
allowlist so the fail-closed coverage test stays meaningful.

`executor trigger` stays guarded: it calls the production
`TriggerExecutor` RPC with the operator token and records a job on the
workspace, so it is a platform-state mutation regardless of the
machine user that ultimately executes the job.

Narrow the `PROFILE_READONLY` details text accordingly so it stops
implying that every write is blocked.
@dqn dqn force-pushed the feat/profile-readonly branch from 646e9d6 to 37f6ec7 Compare May 11, 2026 12:03
@dqn dqn requested a review from toiroakr May 11, 2026 12:07
@github-actions
Copy link
Copy Markdown

Code Metrics Report (packages/sdk)

main (ae7aa27) #1147 (dec95a3) +/-
Coverage 60.9% 61.3% +0.3%
Code to Test Ratio 1:0.4 1:0.4 +0.0
Details
  |                    | main (ae7aa27) | #1147 (dec95a3) |  +/-  |
  |--------------------|----------------|-----------------|-------|
+ | Coverage           |          60.9% |           61.3% | +0.3% |
  |   Files            |            358 |             359 |    +1 |
  |   Lines            |          12217 |           12256 |   +39 |
+ |   Covered          |           7448 |            7514 |   +66 |
+ | Code to Test Ratio |          1:0.4 |           1:0.4 |  +0.0 |
  |   Code             |          80092 |           80660 |  +568 |
+ |   Test             |          32936 |           33404 |  +468 |

Code coverage of files in pull request scope (22.2% → 27.6%)

Details
Files Coverage +/- Status
packages/sdk/src/cli/commands/api/index.ts 92.6% +0.1% modified
packages/sdk/src/cli/commands/authconnection/authorize.ts 3.9% -0.1% modified
packages/sdk/src/cli/commands/authconnection/revoke.ts 6.2% -0.5% modified
packages/sdk/src/cli/commands/deploy/index.ts 20.0% -5.0% modified
packages/sdk/src/cli/commands/executor/trigger.ts 12.5% -0.2% modified
packages/sdk/src/cli/commands/organization/folder/create.ts 12.5% -0.9% modified
packages/sdk/src/cli/commands/organization/folder/delete.ts 9.0% -0.5% modified
packages/sdk/src/cli/commands/organization/folder/update.ts 12.5% -0.9% modified
packages/sdk/src/cli/commands/organization/update.ts 12.5% -0.9% modified
packages/sdk/src/cli/commands/profile/create.ts 5.5% 0.0% modified
packages/sdk/src/cli/commands/profile/index.ts 50.0% 0.0% modified
packages/sdk/src/cli/commands/profile/list.ts 12.5% 0.0% modified
packages/sdk/src/cli/commands/profile/update.ts 83.8% +80.0% modified
packages/sdk/src/cli/commands/remove.ts 1.3% -0.1% modified
packages/sdk/src/cli/commands/secret/create.ts 5.0% -0.3% modified
packages/sdk/src/cli/commands/secret/delete.ts 5.2% -0.3% modified
packages/sdk/src/cli/commands/secret/update.ts 5.8% -0.4% modified
packages/sdk/src/cli/commands/secret/vault/create.ts 9.0% -1.0% modified
packages/sdk/src/cli/commands/secret/vault/delete.ts 5.2% -0.3% modified
packages/sdk/src/cli/commands/staticwebsite/deploy.ts 3.8% -0.1% modified
packages/sdk/src/cli/commands/tailordb/erd/deploy.ts 5.2% -0.3% modified
packages/sdk/src/cli/commands/tailordb/migrate/set.ts 1.8% -0.1% modified
packages/sdk/src/cli/commands/tailordb/truncate.ts 68.7% -1.1% modified
packages/sdk/src/cli/commands/user/pat/create.ts 8.3% -0.8% modified
packages/sdk/src/cli/commands/user/pat/delete.ts 11.1% -1.4% modified
packages/sdk/src/cli/commands/user/pat/update.ts 7.6% -0.7% modified
packages/sdk/src/cli/commands/workspace/create.ts 83.3% +76.0% modified
packages/sdk/src/cli/commands/workspace/delete.ts 6.4% -0.3% modified
packages/sdk/src/cli/commands/workspace/restore.ts 10.5% -0.6% modified
packages/sdk/src/cli/commands/workspace/transform.ts 75.0% +25.0% affected
packages/sdk/src/cli/commands/workspace/user/invite.ts 14.2% -1.1% modified
packages/sdk/src/cli/commands/workspace/user/remove.ts 10.5% -0.6% modified
packages/sdk/src/cli/commands/workspace/user/update.ts 14.2% -1.1% modified
packages/sdk/src/cli/shared/context.ts 58.3% 0.0% modified
packages/sdk/src/cli/shared/errors.ts 52.9% +5.8% affected
packages/sdk/src/cli/shared/readonly-guard.ts 100.0% +100.0% added

SDK Configure Bundle Size

main (ae7aa27) #1147 (dec95a3) +/-
configure-index-size 17.78KB 17.78KB 0KB
dependency-chunks-size 33.56KB 33.56KB 0KB
total-bundle-size 51.34KB 51.34KB 0KB

Runtime Performance

main (ae7aa27) #1147 (dec95a3) +/-
Generate Median 2,526ms 2,572ms 46ms
Generate Max 2,640ms 2,591ms -49ms
Apply Build Median 2,570ms 2,608ms 38ms
Apply Build Max 2,595ms 2,633ms 38ms

Type Performance (instantiations)

main (ae7aa27) #1147 (dec95a3) +/-
tailordb-basic 35,130 35,130 0
tailordb-optional 3,841 3,841 0
tailordb-relation 7,428 7,428 0
tailordb-validate 2,566 2,566 0
tailordb-hooks 5,767 5,767 0
tailordb-object 12,136 12,136 0
tailordb-enum 2,462 2,462 0
resolver-basic 9,424 9,424 0
resolver-nested 26,111 26,111 0
resolver-array 18,187 18,187 0
executor-schedule 4,234 4,234 0
executor-webhook 873 873 0
executor-record 8,166 8,166 0
executor-resolver 4,369 4,369 0
executor-operation-function 869 869 0
executor-operation-gql 869 869 0
executor-operation-webhook 888 888 0
executor-operation-workflow 1,714 1,714 0

Reported by octocov

Comment thread .changeset/profile-readonly.md Outdated
"@tailor-platform/sdk": minor
---

Add `--readonly` 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. Read-only profiles 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> --no-readonly` 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 `--readonly` / `--no-readonly` is changing, so the flag can be cleared offline or with an expired token.
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.

I just noticed that it feels a bit strange that profile update results in an error and you have to use profile update --no-readonly.
How about adding a "writable" flag that is mutually exclusive with "readonly"?

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.

I'm just working on a feature for renaming negation flags.
toiroakr/politty#393

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.

tailor-sdk profile update --permission write/read ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to --permission <write|read> on profile create, profile update, and workspace create in 9a0f5a4. The persisted readonly: true field stays so existing v2 configs round-trip unchanged; only the CLI surface (and the ProfileInfo JSON output, now permission) moves to the new vocabulary.

Per review feedback (#1147), the boolean negation pair
`--readonly` / `--no-readonly` is awkward to use in `profile update`.
Switch the user-facing API on `profile create`, `profile update`, and
`workspace create` to an explicit `--permission <write|read>` enum so
both directions are positive and self-describing. The persisted YAML
field stays `readonly: true` so existing v2 configs round-trip
unchanged; only the CLI surface and the `ProfileInfo` JSON output
(now `permission: "write" | "read"`) move to the new vocabulary.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants