From efa7902269a2ddeb81905a07fdd5bf5967a6d6b5 Mon Sep 17 00:00:00 2001 From: Cheese Date: Thu, 13 Aug 2026 12:28:55 +0800 Subject: [PATCH 1/2] feat(fs): manage file system tokens --- AGENTS.md | 41 + Makefile | 2 +- README.md | 27 + ...> 0031-homebrew-and-scoop-distribution.md} | 0 ...=> 0032-serverless-function-deployment.md} | 0 .../0012-install-and-update-distribution.md | 4 +- ...-file-system-token-lifecycle-management.md | 517 +++++++++++++ e2e/cli_test.go | 116 +++ e2e/live_test.go | 270 ++++++- internal/api/client.go | 15 +- internal/api/fs/token.go | 180 +++++ internal/api/fs/token_test.go | 176 +++++ internal/authz/authz.go | 12 + internal/cli/commands.go | 289 ++++++- internal/cli/root.go | 5 + internal/config/profile.go | 3 + internal/fs/drive9_companion.go | 1 + internal/fs/fscred/credential.go | 173 ++++- internal/fs/fscred/credential_test.go | 80 ++ internal/fs/mountlocator/locator.go | 54 +- internal/fs/mountlocator/locator_test.go | 39 + internal/fs/token_fingerprint.go | 15 + internal/fs/tokenmgmt/service.go | 728 ++++++++++++++++++ internal/fs/tokenmgmt/service_test.go | 360 +++++++++ 24 files changed, 3049 insertions(+), 58 deletions(-) rename docs/spec/{0030-homebrew-and-scoop-distribution.md => 0031-homebrew-and-scoop-distribution.md} (100%) rename docs/spec/{0031-serverless-function-deployment.md => 0032-serverless-function-deployment.md} (100%) create mode 100644 docs/spec/done/0030-file-system-token-lifecycle-management.md create mode 100644 internal/api/fs/token.go create mode 100644 internal/api/fs/token_test.go create mode 100644 internal/fs/token_fingerprint.go create mode 100644 internal/fs/tokenmgmt/service.go create mode 100644 internal/fs/tokenmgmt/service_test.go diff --git a/AGENTS.md b/AGENTS.md index a4fbfa4..e2827cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ Implemented: `docs/spec/done/0020-explicit-file-system-selection.md` - FS token authentication and configuration-free access from `docs/spec/done/0018-fs-token-auth-and-config-free-access.md` +- Server-backed Filesystem token lifecycle management from + `docs/spec/done/0030-file-system-token-lifecycle-management.md` - install and update distribution from `docs/spec/done/0012-install-and-update-distribution.md` - English PingCAP Preview documentation from @@ -96,6 +98,12 @@ Implemented: - `ti db execute-sql-statement` - `ti fs create-file-system` - `ti fs import-file-system-token` +- `ti fs generate-file-system-token` +- `ti fs list-file-system-tokens` +- `ti fs enable-file-system-token` +- `ti fs disable-file-system-token` +- `ti fs delete-file-system-token` +- `ti fs refresh-file-system-token` - `ti fs delete-file-system` - `ti fs list-file-systems` - `ti fs describe-file-system` @@ -264,6 +272,12 @@ vault grant reads, vault mount read on macOS/Linux hosts when available, journal create/append/read/search/verify, public Git clone/hydrate/worktree flows, mount and drain through the companion runtime, and explicit WebDAV fallback when the platform supports it. +The live FS family also generates one uniquely named finite token on the test +Filesystem, verifies secret-free list metadata and data-plane access, then +disables, enables, refreshes, and deletes only that token while tolerating the +documented authentication-cache convergence delay. It must verify that refresh +preserves token ID, invalidates the old plaintext, and never mutates a +provisioning or pre-existing token. If remote inventory has no resource with a local token, the suite creates one temporary ti fs resource, records the server-assigned ID, and deletes only that ID before the DB lifecycle needs the Starter slot or when the process @@ -325,6 +339,7 @@ internal/dryrun/ shared dry-run result envelope internal/fs/ ti fs control-plane, data-plane, and mount use cases internal/fs/fscred/ ID-keyed ti fs credentials, selection, and legacy migration internal/fs/mountlocator/ non-secret Drive9 background mount routing state +internal/fs/tokenmgmt/ Filesystem token lifecycle and local rotation safety internal/oplog/ local JSONL operation log writer internal/output/ structured JSON/text/raw rendering internal/query/ JMESPath query application @@ -388,6 +403,17 @@ Follow these rules unless `docs/priciples.md` is updated: resource and local credentials when waiting fails. - `ti fs delete-file-system` is asynchronous. After Drive9 accepts deletion, output status is `deleting`, not `deleted`. +- One remote Filesystem can have multiple tokens, but one profile stores at + most one selected local token per Filesystem. Remote token inventory and + lifecycle state are authoritative; local credentials are not a token wallet. +- Filesystem token generation returns plaintext once and does not change local + selection unless `--store-locally` is explicit. Replacing local selection + never revokes the previous remote token. +- Filesystem token refresh is bearer-only and non-idempotent. Do not retry it + after an ambiguous network failure. Generate, list, enable, disable, and + delete use only TiDB Cloud public/private keys. +- Reject refresh, disable, or deletion of a token correlated with a known + active local mount. The error must show exact drain and unmount commands. - Read-only commands reject `--dry-run`. - Apply `--query` after command execution and before rendering. - Users provide cloud placement as one canonical `region_code`, never as @@ -505,6 +531,13 @@ Implemented command behavior: - `ti fs create-file-system --wait` - `ti fs create-file-system --dry-run` - `ti fs import-file-system-token --from-file ./fs-token` +- `ti fs generate-file-system-token --file-system-id --token-name ci --ttl 24h` +- `ti fs generate-file-system-token --file-system-id --token-name local --no-expiration --store-locally --replace` +- `ti fs list-file-system-tokens --file-system-id ` +- `ti fs disable-file-system-token --file-system-id --token-id ` +- `ti fs enable-file-system-token --file-system-id --token-id ` +- `ti fs delete-file-system-token --file-system-id --token-id ` +- `ti fs refresh-file-system-token --file-system-id ` - `ti fs delete-file-system --file-system-id ` - `ti fs delete-file-system --file-system-id --dry-run` - `ti fs list-file-systems` @@ -879,6 +912,14 @@ profile. `ti fs create-file-system`, remote list/describe, and `ti fs delete-file-system` remain TiDB Cloud-authenticated. Delete requires an ID but does not require a locally stored owner token. +Filesystem token generate/list/enable/disable/delete commands also use only +TiDB Cloud public/private keys and always require an explicit File System ID. +Refresh instead resolves exactly one bearer token from `--fs-token`, +`TI_FS_TOKEN`, or the selected local credential. A local refresh atomically +replaces that selected credential; a flag or environment refresh returns the +new plaintext without writing local or external secret-manager state. Never +infer a missing token ID from names, list order, timestamps, or token claims. + The ID selector is available on ti fs data-plane/runtime commands and all `fs-git`, `fs-journal`, and `fs-vault` subcommands. Creation accepts no ID; description and deletion require an ID. Drain and unmount resolve an existing diff --git a/Makefile b/Makefile index f5e742f..b650c57 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ live-e2e-db: build $(LIVE_E2E_RUN) -run '^TestLiveDB' live-e2e-fs: build - $(LIVE_E2E_RUN) -run '^TestLive(FSRemoteInventoryLifecycle|FSCommandSurface|FSConfigurationFreeAccess|FSDataPlaneLifecycle|FSMountRuntime|FSWebDAVMountRuntime)$$' + $(LIVE_E2E_RUN) -run '^TestLive(FSRemoteInventoryLifecycle|FSCommandSurface|FSFileSystemTokenLifecycle|FSConfigurationFreeAccess|FSDataPlaneLifecycle|FSMountRuntime|FSWebDAVMountRuntime)$$' live-e2e-fs-git: build $(LIVE_E2E_RUN) -run '^TestLiveFSGit' diff --git a/README.md b/README.md index 49772c6..a7c41ba 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,27 @@ ti fs list-files `create-file-system` does not accept a user-defined name. Drive9 assigns the stable `file_system_id`, and the command returns the owner credential as `fs_token` once in its JSON result. Treat it as a secret. The example above captures both fields from one provisioning request and removes the temporary owner-only JSON file immediately. +One Filesystem can have multiple independently managed tokens for different machines, CI jobs, and sandboxes. Owner tokens authorize the complete Filesystem; path-and-operation-limited `fs_scoped` tokens can also appear in inventory, although this release does not issue new scoped tokens. The remote service is the source of truth for token inventory, while each local profile stores at most one selected token for each Filesystem. Generate an additional owner token and capture its one-time plaintext response: + +```shell +umask 077 +ti fs generate-file-system-token \ + --file-system-id "$FILE_SYSTEM_ID" \ + --token-name ci-deploy \ + --ttl 24h > ./ci-token.json +ti fs list-file-system-tokens --file-system-id "$FILE_SYSTEM_ID" --output text +``` + +Generation does not modify local credentials by default. Add `--store-locally` to select the new token locally; if a selected token already exists, add `--replace` explicitly. Replacing local selection does not revoke the previous remote token. Use immutable `token_id` values from the list response to disable, enable, or permanently revoke a token: + +```shell +ti fs disable-file-system-token --file-system-id "$FILE_SYSTEM_ID" --token-id +ti fs enable-file-system-token --file-system-id "$FILE_SYSTEM_ID" --token-id +ti fs delete-file-system-token --file-system-id "$FILE_SYSTEM_ID" --token-id +``` + +Rotate a locally selected token with `ti fs refresh-file-system-token --file-system-id "$FILE_SYSTEM_ID"`. To rotate a token supplied by a secret manager, set `TI_FS_TOKEN` and `TI_REGION_CODE`; `ti` returns the replacement plaintext but cannot update the external secret manager. Refresh is not safely retryable if the response is lost. For shared environments, generate and distribute a replacement first, validate it, then disable and delete the old token. Authentication state can take approximately 10 seconds to converge. Before refreshing, disabling, or deleting a token used by a local mount, run `drain-file-system` and `unmount-file-system`. + An agent sandbox can then use that existing file system without running `ti configure` or providing TiDB Cloud API keys: ```shell @@ -232,6 +253,12 @@ ti db execute-sql-statement ti fs create-file-system ti fs import-file-system-token +ti fs generate-file-system-token +ti fs list-file-system-tokens +ti fs enable-file-system-token +ti fs disable-file-system-token +ti fs delete-file-system-token +ti fs refresh-file-system-token ti fs delete-file-system ti fs list-file-systems ti fs describe-file-system diff --git a/docs/spec/0030-homebrew-and-scoop-distribution.md b/docs/spec/0031-homebrew-and-scoop-distribution.md similarity index 100% rename from docs/spec/0030-homebrew-and-scoop-distribution.md rename to docs/spec/0031-homebrew-and-scoop-distribution.md diff --git a/docs/spec/0031-serverless-function-deployment.md b/docs/spec/0032-serverless-function-deployment.md similarity index 100% rename from docs/spec/0031-serverless-function-deployment.md rename to docs/spec/0032-serverless-function-deployment.md diff --git a/docs/spec/done/0012-install-and-update-distribution.md b/docs/spec/done/0012-install-and-update-distribution.md index 641980f..e480889 100644 --- a/docs/spec/done/0012-install-and-update-distribution.md +++ b/docs/spec/done/0012-install-and-update-distribution.md @@ -2,7 +2,7 @@ ## Goal -Make `tdc` installable and updatable through deterministic GitHub Releases artifacts. The MVP channel is GoReleaser plus GitHub Releases, with shell and PowerShell installers. Homebrew and Scoop are intentionally deferred to `docs/spec/0030-homebrew-and-scoop-distribution.md`. +Make `tdc` installable and updatable through deterministic GitHub Releases artifacts. The MVP channel is GoReleaser plus GitHub Releases, with shell and PowerShell installers. Homebrew and Scoop are intentionally deferred to `docs/spec/0031-homebrew-and-scoop-distribution.md`. ## User-facing Commands @@ -266,7 +266,7 @@ Installer scripts: - Silent auto-update. - Updating TiDB Cloud credentials or DB SQL credentials. - Config migrations that modify user config during update. -- Homebrew tap and Scoop bucket publishing. See `0030-homebrew-and-scoop-distribution.md`. +- Homebrew tap and Scoop bucket publishing. See `0031-homebrew-and-scoop-distribution.md`. - Linux apt/yum repositories. - Winget publishing. - Notarization or binary signing beyond SHA-256 checksums for MVP. diff --git a/docs/spec/done/0030-file-system-token-lifecycle-management.md b/docs/spec/done/0030-file-system-token-lifecycle-management.md new file mode 100644 index 0000000..3b06dd8 --- /dev/null +++ b/docs/spec/done/0030-file-system-token-lifecycle-management.md @@ -0,0 +1,517 @@ +# File System Token Lifecycle Management + +## Goal + +Add explicit server-backed lifecycle management for TiDB Cloud Filesystem tokens. A single File System can have multiple owner and scoped tokens, while each local ti profile continues to select at most one token per File System for ordinary data-plane and mount commands. + +This spec uses the Drive9 `/v1/tokens` API exactly as it exists after `tidbcloud/fs#49`. The backend will not add token introspection, current-token identification, token fingerprints, unique token names, mount inventory, or additional recovery APIs for this work. The client must not infer information that the backend does not return. + +## Backend Contract And Constraints + +The hosted FS backend exposes one `/v1/tokens` resource family: + +| Method and path | Accepted identity | Behavior | +| --- | --- | --- | +| `POST /v1/tokens/generate` | TiDB Cloud API keys | Generate an owner token for one File System. | +| `GET /v1/tokens` | TiDB Cloud API keys or an owner token | List token metadata. | +| `POST /v1/tokens/{token_id}/activate` | TiDB Cloud API keys or an owner token | Change `disabled` to `active`. | +| `POST /v1/tokens/{token_id}/deactivate` | TiDB Cloud API keys or an owner token | Change `active` to `disabled`. | +| `DELETE /v1/tokens/{token_id}` | TiDB Cloud API keys or an owner token | Permanently change the token to `revoked`. | +| `POST /v1/tokens/refresh` | The current owner or `fs_scoped` bearer token | Rotate that same token and return the new plaintext once. | +| `POST /v1/tokens` | An owner token | Issue a path-and-operation-limited `fs_scoped` token. | + +The backend model has these constraints: + +- One File System can have multiple owner tokens and multiple `fs_scoped` tokens. +- The active/disabled non-expired token cap is 100 per File System. Expired and revoked tokens do not count against the cap under the backend rules. +- Stored states are `active`, `disabled`, and terminal `revoked`; `expired` is derived from `expires_at`. +- Revoked tokens are not returned by list. Single-FS list excludes expired tokens unless `include_expired` is requested. +- Token names are not unique. `token_id` is the only mutation identifier. +- Generate and refresh return plaintext only once. List never returns plaintext, ciphertext, or a token hash. +- The token JWT contains the File System ID as `tenant_id`, but it does not contain `token_id`, `key_name`, `scope_kind`, or path scopes. +- Disable, delete, and refresh can take up to the backend authentication-cache TTL, currently approximately 10 seconds, to become effective on every server process. +- Refresh is not idempotent. A committed refresh whose response is lost cannot be recovered with the old token. Recovery requires generating another owner token with TiDB Cloud API keys. +- The backend does not know where a token is mounted or which machines currently use it. + +## Product Decisions + +- `ti fs list-file-system-tokens` always requires `--file-system-id`. It does not implicitly list token metadata across the whole organization. +- All token mutations that identify a remote token by ID require both `--file-system-id` and `--token-id`. +- The CLI uses `file_system_id` in flags and output. It translates that value to the backend `tenant_id` field internally and does not expose a second tenant selector. +- The remote backend is the source of truth for token inventory and lifecycle state. +- Local state remains a selected operational credential, not a replica of all remote tokens and not a multi-token wallet. +- A profile may store one selected token for each File System. Different profiles may store different tokens for the same File System. +- Token management never changes which File System is selected implicitly. Existing explicit FS ID and token-derived ID rules remain in force. +- Control-plane token management uses TiDB Cloud public/private keys only. It must not also send the selected local FS token. +- Self-refresh uses one FS bearer token only. It must not also send TiDB Cloud public/private keys. +- ti never guesses a remote `token_id` from token name, issuance time, list ordering, profile, or the number of returned rows. +- An old local credential with no known `token_id` remains valid for data-plane use but cannot be correlated with one list row. +- No background refresh, automatic expiry renewal, token daemon, or automatic remote revocation is introduced. +- Owner token management is the first-phase creation surface. This spec does not add a ti command for issuing new path-level `fs_scoped` tokens. Existing scoped tokens can appear in list and can be managed by token ID through TiDB Cloud credentials. A separate spec can expose scoped issuance after its local import and scope-display contract is designed. + +## User-Facing Commands + +Add these commands: + +```text +ti fs generate-file-system-token +ti fs list-file-system-tokens +ti fs enable-file-system-token +ti fs disable-file-system-token +ti fs delete-file-system-token +ti fs refresh-file-system-token +``` + +Generate an owner token with a finite TTL: + +```bash +ti fs generate-file-system-token \ + --file-system-id \ + --token-name ci-deploy \ + --ttl 24h +``` + +Generate an explicitly non-expiring owner token: + +```bash +ti fs generate-file-system-token \ + --file-system-id \ + --token-name local-owner \ + --no-expiration +``` + +Exactly one of `--ttl` and `--no-expiration` is required. The CLI does not silently choose a lifetime. `--ttl` accepts a positive Go duration that resolves to whole seconds and does not exceed the backend maximum of 365 days. + +Store a newly generated token as the selected local credential: + +```bash +ti fs generate-file-system-token \ + --file-system-id \ + --token-name local-owner \ + --ttl 720h \ + --store-locally +``` + +If another local token already exists, `--store-locally` fails before the remote request. The user must explicitly add `--replace`. Replacing the local selection does not disable, delete, refresh, or otherwise change the previous remote token. + +List token metadata for exactly one File System: + +```bash +ti fs list-file-system-tokens --file-system-id +ti fs list-file-system-tokens --file-system-id --include-expired +``` + +The list command supports `--offset` and `--limit` using the backend single-tenant pagination contract. Offset defaults to 0, limit defaults to 50, and the maximum limit is 200. The response retains `next_offset` when another page might exist. It does not auto-fetch an unbounded expired-token history. + +Manage a known token by immutable ID: + +```bash +ti fs disable-file-system-token --file-system-id --token-id +ti fs enable-file-system-token --file-system-id --token-id +ti fs delete-file-system-token --file-system-id --token-id +``` + +These commands do not accept a token name as a selector and do not add confirm-name flags or prompts. + +Refresh the token supplied for the current invocation: + +```bash +ti fs refresh-file-system-token --file-system-id +TI_FS_TOKEN= TI_REGION_CODE=aws-us-east-1 ti fs refresh-file-system-token +``` + +For a supplied token, `--file-system-id` is an optional consistency assertion and must match the ID decoded from the token. When no flag or environment token is supplied, `--file-system-id` is required to load the selected local credential. An optional `--ttl` follows the backend refresh contract. Omitting it preserves the previous lifetime period; a non-expiring owner token remains non-expiring. + +All remote mutations support `--dry-run`. Dry-run validates credential availability, region, File System ID, token ID, TTL rules, local storage preconditions, and known mount conflicts without sending a mutation or printing secret material. + +## Authentication And Region Resolution + +Generate, list, enable, disable, and delete use the selected profile's TiDB Cloud API keys. Request credentials are sent through `X-TiDBCloud-Public-Key` and `X-TiDBCloud-Private-Key` headers, not copied into request JSON. These commands fail before the request if either key is missing. + +Refresh resolves its FS token in the existing order: + +1. Explicit non-empty `--fs-token`. +2. Non-empty `TI_FS_TOKEN`. +3. The selected local File System credential. + +The effective region remains: + +1. Explicit global `--region`. +2. `TI_REGION_CODE`. +3. The selected local credential region. +4. The profile region. + +The region selects one hosted FS backend deployment. Token management does not scan other regions, accept a server URL, or retry against another region. + +The API client must use separate constructors or explicit credential modes for control-plane and bearer requests. A generic helper must not attach both credential classes because the backend correctly rejects ambiguous requests with HTTP 400. + +## API Call Chains + +Generate: + +```text +ti fs generate-file-system-token + -> validate explicit File System ID, token name, lifetime, and local-store preconditions + -> resolve hosted FS endpoint from the effective ti region + -> POST /v1/tokens/generate + headers: X-TiDBCloud-Public-Key, X-TiDBCloud-Private-Key + body: tenant_id, key_name, ttl_seconds when finite + -> map tenant_id to file_system_id and token to fs_token + -> optionally store the returned token locally + -> render the one-time secret response +``` + +List: + +```text +ti fs list-file-system-tokens --file-system-id + -> resolve TiDB Cloud credentials and hosted FS endpoint + -> GET /v1/tokens?tenant_id=&offset=&limit=[&include_expired=1] + -> map backend metadata to the ti output model + -> render without secret material +``` + +Enable and disable: + +```text +ti fs enable-file-system-token | disable-file-system-token + -> validate file_system_id and token_id + -> inspect known local mount and credential metadata + -> POST /v1/tokens//activate|deactivate?tenant_id= + headers: X-TiDBCloud-Public-Key, X-TiDBCloud-Private-Key + -> keep local plaintext unchanged + -> render the accepted remote state +``` + +Delete: + +```text +ti fs delete-file-system-token + -> validate file_system_id and token_id + -> inspect known local mount and credential metadata + -> DELETE /v1/tokens/?tenant_id= + headers: X-TiDBCloud-Public-Key, X-TiDBCloud-Private-Key + -> remove the selected local credential only when its stored token_id matches + -> render revoked state and local cleanup result +``` + +Refresh: + +```text +ti fs refresh-file-system-token + -> resolve exactly one bearer token and assert its decoded File System ID + -> reject a matching active local mount + -> prepare local atomic-write recovery state when the source is local + -> POST /v1/tokens/refresh + Authorization: Bearer + body: ttl_seconds only when explicitly provided + -> receive the rotated plaintext and token_id once + -> atomically replace the selected local credential only when the source was local + -> render the new token and storage outcome +``` + +`ti fs create-file-system` remains unchanged by this spec. It continues to invoke `ti-drive9 create --json` with a temporary companion HOME, capture the returned `tenant_id` and `api_key`, discard the temporary Drive9 context, and store the owner token in ti's credential store. Normal data-plane and mount commands continue to inject the selected ti credential as `DRIVE9_API_KEY`; they do not depend on a persistent Drive9 context. + +The current provision response does not expose `token_id`. Therefore a token stored by create starts with unknown remote token metadata. This spec must not rotate it merely to discover its ID. + +## Local Credential Model + +Keep one credential file per profile and File System. Extend its schema with optional metadata: + +```toml +file_system_id = "" +region_code = "aws-us-east-1" +api_key = "drive9_..." + +token_id = "" +scope_kind = "owner" +token_name = "local-owner" +expires_at = "2026-09-11T00:00:00Z" +``` + +The existing `api_key` key remains unchanged for compatibility. `token_id`, `scope_kind`, `token_name`, and `expires_at` are optional because create and old imports cannot discover them with the available backend APIs. + +Do not persist remote `status` as an authoritative local value. Another machine can enable, disable, delete, or refresh a token at any time, making such a cached status stale. + +Local metadata is populated only from authoritative responses: + +- Generate with `--store-locally` stores every returned field. +- Refresh of a local credential updates token plaintext, token ID, scope kind, and expiry from the refresh response. It preserves a known local token name because refresh does not return it. +- Existing create and import paths keep optional metadata empty unless the command already possesses an authoritative response containing it. +- List never guesses which row matches an unknown local token. +- Enable and disable do not change local plaintext. +- Delete removes local credentials only when the stored `token_id` exactly matches the deleted ID. + +When a delete targets a token while the local credential has no `token_id`, ti leaves the local file unchanged and returns `local_credentials_updated: false` with reason `local_token_id_unknown`. A later data-plane 401 must explain that the selected token might be disabled, expired, refreshed elsewhere, or revoked and recommend generating or importing a valid token. It must not claim which state caused the 401. + +Credential schema migration is read-compatible and lazy: + +- Existing files continue to load without rewriting. +- Missing optional fields do not make a credential incomplete. +- A successful generate/store or local refresh writes the expanded schema atomically. +- No migration performs a remote request, refresh, token generation, or list-based guess. + +## Generate And Local Replacement Safety + +Generate does not store locally by default because additional tokens are commonly created for CI, sandboxes, or another machine. The plaintext remains available in the successful structured response exactly once. + +When `--store-locally` is requested: + +1. Validate the credential directory and requested replacement policy before the API call. +2. Prepare a mode-`0600` temporary file in the target directory where POSIX modes apply. +3. Generate the remote token. +4. Write the complete credential to the temporary file, flush it, and atomically rename it over the target. +5. If storage fails after generation, attempt to delete only the just-generated `token_id` with the same TiDB Cloud credentials. +6. If rollback succeeds, return a storage error and do not expose a token that has already been revoked. +7. If rollback fails, return the generated result with `credentials_stored: false`, a stable partial-success error, and actionable guidance. The plaintext must remain recoverable from structured stdout and must never be duplicated into logs or stderr. + +Replacing the local token does not modify the old remote token. Output and documentation must state that the old token remains active until explicitly disabled or deleted. + +## Refresh And Atomic Local Replacement + +Refresh is an explicit destructive rotation of one credential, not an ordinary read or lease check. The backend commits the new token before the client can persist it, so the client must minimize but cannot eliminate the response-loss window. + +For a locally sourced token: + +1. Acquire a cross-process lock for the credential file. +2. Re-read the credential and verify that its token is the same token resolved before locking. +3. Validate that the directory is writable and prepare mode-`0600` recovery state in the same directory. +4. Confirm that no known local mount uses the same token fingerprint. +5. Call refresh once. Never retry automatically after an ambiguous network failure. +6. Write the new token and returned metadata to recovery state, flush it, and atomically rename it over the credential file. +7. If final persistence fails, preserve the recovery file when possible, return its path without token contents, and include the new plaintext in structured stdout with `credentials_stored: false`. + +For a flag or environment token, refresh never changes a local credential. It returns `credentials_stored: false` and the new token so the caller can update its environment, CI secret, or sandbox input. + +The new token is always rendered in the successful refresh JSON because it cannot be retrieved later. `--query fs_token --output text` remains the explicit way to print only the token. Refresh output, recovery paths, errors, operation logs, and telemetry must never print the old token. + +## Mount Safety + +A running Drive9 mount retains the credential with which it started. Updating ti's credential file cannot inject a new token into that process. Refreshing, disabling, or deleting that token can therefore turn an apparently healthy mount into delayed authentication failures. + +Extend new mount locators with optional non-secret correlation metadata: + +```json +{ + "file_system_id": "", + "token_id": "", + "token_fingerprint": "" +} +``` + +The fingerprint is derived locally from the selected token, truncated to at least 128 bits of a cryptographic hash, and used only to compare a refresh input with local mounts. It must not be sent to telemetry, operation logs, remote APIs, or user-facing output. It grants no authentication authority and never replaces the plaintext credential. + +Mount behavior: + +- New mounts store `token_id` when the selected local credential knows it and always store the local fingerprint. +- Existing locators without these fields remain valid for drain and unmount. +- Refresh computes the supplied token fingerprint and fails before the API call if a local mount locator for the same File System has the same fingerprint. The error provides exact drain and unmount commands. No force bypass is added in the MVP. +- Disable and delete fail before the API call when their `token_id` matches a known active local mount locator. +- A mount with unknown token ID cannot be correlated with an ID-based disable/delete request. ti must not guess or block every unrelated token mutation. +- Generate, list, and enable do not invalidate an active mount and are not blocked. + +Remote mounts and processes on another machine cannot be detected. Documentation must recommend generate-distribute-disable-delete rotation for shared tokens: + +1. Generate a new named token. +2. Distribute it to every consumer. +3. Verify each consumer with the new token. +4. Disable the old token. +5. Observe for unexpected failures during the rollback window. +6. Delete the old token after the transition is accepted. + +Refresh is documented only for a single credential holder that can atomically replace its secret and has no active mount using it. + +## Output Contracts + +Generate JSON: + +```json +{ + "file_system_id": "tnt_abc123", + "token_id": "4b2d97e8-3e72-4ba6-8db1-1ca2d7370c20", + "token_name": "ci-deploy", + "scope_kind": "owner", + "status": "active", + "issued_at": "2026-08-12T00:00:00Z", + "expires_at": "2026-08-13T00:00:00Z", + "fs_token": "drive9_...", + "credentials_stored": false +} +``` + +List JSON: + +```json +{ + "file_system_id": "tnt_abc123", + "tokens": [ + { + "token_id": "4b2d97e8-3e72-4ba6-8db1-1ca2d7370c20", + "token_name": "ci-deploy", + "scope_kind": "owner", + "status": "active", + "expired": false, + "issued_at": "2026-08-12T00:00:00Z", + "expires_at": "2026-08-13T00:00:00Z", + "created_at": "2026-08-12T00:00:00Z", + "updated_at": "2026-08-12T00:00:00Z" + } + ], + "next_offset": 50 +} +``` + +List text output uses one row per token: + +```text +TOKEN_ID NAME SCOPE STATUS EXPIRES_AT +4b2d97e8-3e72-4ba6-8db1-1ca2d7370c20 ci-deploy owner active 2026-08-13T00:00:00Z +0d716939-f896-420c-a3f9-68310345f17d agent-ro fs_scoped disabled 2026-08-13T00:00:00Z +``` + +If the backend returns `expired: true`, text output displays `expired` as the effective status while JSON keeps the separate stored `status` and derived `expired` fields. Text output omits issuer metadata by default; JSON may preserve non-secret `issued_by_*` metadata. + +List output does not claim which row is locally selected when the local credential lacks `token_id`. Do not display a guessed `LOCAL` marker. + +Disable, enable, and delete output includes `file_system_id`, `token_id`, accepted status, whether local credentials changed, and a reminder that backend cache convergence may take approximately 10 seconds. The reminder must not corrupt JSON stdout; structured fields carry the state, while optional human guidance follows the existing output-mode rules. + +## Errors + +Add stable client error codes for: + +- `fs.token_name_required`: generate omitted an explicit token name. +- `fs.token_lifetime_required`: neither or both of `--ttl` and `--no-expiration` were supplied. +- `fs.token_ttl_invalid`: TTL is non-positive, sub-second, not whole-second representable, or over 365 days. +- `fs.token_id_required`: an ID-based mutation omitted `--token-id`. +- `fs.token_credentials_ambiguous`: client input would send both TiDB Cloud and FS bearer credentials. +- `fs.token_local_conflict`: local storage exists and `--replace` was not supplied. +- `fs.token_local_changed`: the selected local credential changed while refresh waited for its lock. +- `fs.token_mount_active`: refresh, disable, or delete would invalidate a known active local mount. +- `fs.token_refresh_ambiguous`: refresh may have committed but the response was lost; do not retry with the old token. +- `fs.token_partial_success`: remote generation or refresh succeeded but secure local persistence and automatic recovery did not complete. + +Map backend responses without hiding their meaning: + +- 400: invalid/ambiguous request. +- 401: missing, invalid, disabled, expired, refreshed, or revoked FS bearer; do not guess the exact state without control-plane metadata. +- 403: insufficient TiDB Cloud role, organization mismatch, or identity not allowed for the endpoint. +- 404: File System or token not found, or token management unavailable in that deployment. +- 409: terminal token, expired activation, token limit, concurrent refresh, or lifecycle conflict. + +An expired disabled token cannot be re-enabled. The error must recommend generating a new token rather than refreshing or repeatedly enabling it. + +## Package And Code Design + +- `internal/api/fs` adds strict wire models and methods for generate, single-FS list, activate, deactivate, delete, and refresh. Wire structs retain backend field names; mapping to ti output happens above the client. +- `internal/fs/tokenmgmt` owns token lifecycle use cases, identity separation, TTL conversion, pagination, output models, partial-success handling, and local reconciliation. +- `internal/fs/fscred` extends the optional credential metadata, cross-process locking, recovery-file creation, atomic replacement, and lazy compatibility behavior. +- `internal/fs/mountlocator` adds optional token ID and local fingerprint fields without changing existing locator loading or unmount behavior. +- `internal/cli` registers the six commands, long flags, required annotations, dry-run handlers, and shared output/query behavior. +- `internal/authz` adds explicit permissions for token list, generate, enable, disable, delete, and refresh. Do not infer permission from the command name. +- Existing `internal/fswrap` and `ti-drive9` routing remain unchanged for create, data-plane, Git, Journal, Vault, layers, pack/unpack, and mount commands. + +Do not import or depend on `ref/fs` or `ref/drive9`. They remain reference-only. + +## Dependencies And Portability + +- No new Go module is required. Use the standard library HTTP, JSON, duration, hashing, and file APIs plus existing ti config/output helpers. +- No cgo requirement is introduced. +- Control-plane token management is platform-neutral on macOS, Linux, and Windows. +- POSIX permission checks use mode `0600`; Windows uses the existing credential-store security behavior. +- Mount availability remains controlled by the bundled Drive9 companion and host FUSE/WebDAV support. + +## Security Requirements + +- Never write FS token plaintext to operation logs, telemetry, debug output, mount locators, non-secret config, errors, test failure dumps, or HTTP traces. +- The accepted flag name may be logged; its value may not. +- Redact `Authorization`, TiDB Cloud credential headers, request credential fields, `fs_token`, and backend `token` fields from debug output. +- Generate and refresh return plaintext only through successful or partial-success structured stdout. Do not duplicate it to stderr. +- `--dry-run`, list, enable, disable, and delete never output plaintext. +- Token files and recovery files are owner-only and are never committed. +- Token names are operational metadata, not secrets. Users must not put passwords or tokens in a token name. +- Local fingerprints are correlation metadata only and never leave the local mount locator boundary. +- Deleting one token never deletes the File System or any other token. +- Local replacement never revokes the previous remote token implicitly. + +## Testing + +Unit tests must cover: + +- Exact method, path, query, headers, and JSON body for every backend endpoint. +- Control-plane requests never contain an FS bearer; refresh never contains TiDB Cloud credentials. +- Missing/both lifetime flags, TTL rounding, sub-second TTL, overflow, and 365-day boundary. +- Required explicit File System ID on list and ID-based mutations. +- Backend `tenant_id` to ti `file_system_id` and `key_name` to `token_name` mapping. +- List pagination, include-expired behavior, revoked omission as returned by the backend, and text effective-expired status. +- List and every non-secret command omit plaintext even when the fake server sends malicious unexpected fields. +- Generate default does not write local state. +- `--store-locally`, pre-existing conflict, explicit replace, secure atomic write, rollback success, rollback failure, and partial-success output. +- Old credential files with no optional metadata continue to load without rewrite. +- Generate/store and local refresh populate authoritative optional metadata. +- Delete removes a matching known local token and preserves unknown or non-matching local tokens. +- Disable preserves local plaintext. +- Flag/environment refresh never rewrites local credentials. +- Local refresh lock, concurrent local change detection, atomic replacement, recovery-file behavior, and no automatic network retry. +- Token-derived File System ID assertion and region precedence. +- Mount locator fingerprint matching, known token-ID matching, old locator compatibility, and exact drain/unmount guidance. +- Secret redaction in errors, debug, dry-run, operation logs, telemetry, and failed fake-server responses. + +Black-box e2e uses a fake FS server and fake companion to verify command help, required flags, output/query behavior, credential precedence, local file modes, and mount guards without live credentials. + +Live e2e must use a uniquely generated token on the temporary test File System and must not mutate the provision token or any pre-existing token: + +1. Generate a uniquely named finite-TTL owner token. +2. List the exact File System and verify the generated token metadata and absence of plaintext. +3. Perform a data-plane read using the generated token. +4. Disable the generated token, wait beyond the documented cache convergence window, and verify data-plane authentication is rejected. +5. Enable the same token and verify data-plane access returns. +6. Refresh the token with no active mount, verify the token ID is unchanged, verify the new token works, and verify the old token stops working after cache convergence. +7. Delete the refreshed token and verify it no longer authenticates or appears in default list. +8. Clean up only the token generated by the same test run, including failure cleanup through TiDB Cloud credentials. + +The live test also exercises local store/replace in an isolated temporary `TI_HOME` and verifies that no plaintext reaches captured logs. It must tolerate the expected authentication-cache convergence delay without using an unbounded retry. + +## Documentation Changes + +When implemented, update README, PingCAP command references, examples, troubleshooting, security guidance, and AGENTS.md command inventory. Documentation must cover: + +- One File System can have multiple tokens while one local profile selects one operational token per FS. +- The difference between an owner token and a path/operation-limited `fs_scoped` token. +- Token plaintext is visible only on generate/refresh. +- List is scoped to an explicit File System ID. +- Generate does not replace local credentials unless requested. +- Refresh cannot update an environment variable or remote secret store. +- Shared-token rotation uses generate, distribute, disable, then delete rather than refresh. +- Active mounts must be drained and unmounted before refreshing their token. +- Historical create/import credentials might not have a known remote token ID, and ti never guesses it. + +## Dependencies + +- `docs/spec/done/0018-fs-token-auth-and-config-free-access.md` +- `docs/spec/done/0020-explicit-file-system-selection.md` +- `docs/spec/done/0028-remote-fs-resource-inventory.md` +- Hosted FS backend deployment containing `tidbcloud/fs#49` + +## Acceptance Criteria + +- Users can generate, list, enable, disable, delete, and self-refresh FS tokens through ti using the existing backend API. +- Every list and ID-based mutation is explicitly scoped to one File System ID. +- Multiple remote tokens do not force ti to persist multiple local secrets. +- Existing create/import credentials remain usable without a token ID. +- ti does not guess local-to-remote token identity when metadata is unavailable. +- A local refresh securely replaces the selected credential and learns the returned token ID. +- Environment/flag refresh returns the new token without modifying local state. +- Known active local mounts are protected from token invalidation. +- Remote/shared consumer limitations and the safe staged rotation workflow are documented. +- No secret enters logs, telemetry, dry-run, non-secret config, mount locators, or list output. +- Unit, black-box e2e, and real live token lifecycle tests pass. + +## Out Of Scope + +- Backend changes, including `/v1/tokens/self`, current-token markers, list fingerprints, unique token names, mount inventory, or idempotent refresh. +- Local storage of every remote token. +- Automatic refresh, background renewal, token expiry notifications, or a credential daemon. +- Automatic distribution to CI, sandboxes, containers, remote hosts, or secret managers. +- Detecting mounts or processes on another machine. +- Generating new `fs_scoped` tokens through ti in this phase. +- Changing the existing `ti fs create-file-system` provisioning API or companion implementation. diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 52e978f..59d08d6 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -17,6 +17,7 @@ import ( "testing" "time" + "github.com/tidbcloud/ti-cli/internal/apperr" "github.com/tidbcloud/ti-cli/internal/fs/fscred" ) @@ -941,6 +942,114 @@ func TestFSImportFileSystemToken(t *testing.T) { } } +func TestFSFileSystemTokenLifecycle(t *testing.T) { + bin := tiBinary(t) + home := t.TempDir() + generatedToken := drive9TestTokenWithVersion("tenant-tokens", 1) + refreshedToken := drive9TestTokenWithVersion("tenant-tokens", 2) + remoteRequests := 0 + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + remoteRequests++ + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/tokens/refresh" { + if r.Header.Get("X-TiDBCloud-Public-Key") != "e2e-public" || r.Header.Get("X-TiDBCloud-Private-Key") != "e2e-private" || r.Header.Get("Authorization") != "" { + t.Errorf("control-plane token authentication headers = %#v", r.Header) + } + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/generate": + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"token":%q,"token_id":"token-e2e","tenant_id":"tenant-tokens","key_name":"e2e-owner","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z","expires_at":"2026-08-13T00:00:00Z"}`, generatedToken) + case r.Method == http.MethodGet && r.URL.Path == "/v1/tokens": + if r.URL.Query().Get("tenant_id") != "tenant-tokens" || r.URL.Query().Get("limit") != "50" { + t.Errorf("list query = %s", r.URL.RawQuery) + } + _, _ = fmt.Fprint(w, `{"tokens":[{"token_id":"token-e2e","tenant_id":"tenant-tokens","key_name":"e2e-owner","scope_kind":"owner","status":"active","expired":false,"issued_at":"2026-08-12T00:00:00Z","expires_at":"2026-08-13T00:00:00Z","created_at":"2026-08-12T00:00:00Z","updated_at":"2026-08-12T00:00:00Z"}]}`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/token-e2e/deactivate": + _, _ = fmt.Fprint(w, `{"token_id":"token-e2e","tenant_id":"tenant-tokens","status":"disabled"}`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/token-e2e/activate": + _, _ = fmt.Fprint(w, `{"token_id":"token-e2e","tenant_id":"tenant-tokens","status":"active"}`) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/refresh": + if r.Header.Get("Authorization") != "Bearer "+generatedToken || r.Header.Get("X-TiDBCloud-Public-Key") != "" { + t.Errorf("refresh authentication headers = %#v", r.Header) + } + _, _ = fmt.Fprintf(w, `{"token":%q,"token_id":"token-e2e","tenant_id":"tenant-tokens","scope_kind":"owner","expires_at":"2026-08-14T00:00:00Z"}`, refreshedToken) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/tokens/token-e2e": + _, _ = fmt.Fprint(w, `{"token_id":"token-e2e","tenant_id":"tenant-tokens","status":"revoked"}`) + default: + http.NotFound(w, r) + } + })) + defer tokenServer.Close() + manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"service":"drive9","regions":[{"region_code":"aws-us-east-1","mode":"tidb_cloud_native","server_url":%q,"cloud_provider":"aws","tidb_region":"us-east-1"}]}`, tokenServer.URL) + })) + defer manifestServer.Close() + env := []string{ + "HOME=" + home, "TI_ALLOW_TEST_ENDPOINTS=1", "TI_TEST_FS_MANIFEST_URL=" + manifestServer.URL, + "TI_REGION_CODE=aws-us-east-1", "TIDB_CLOUD_PUBLIC_KEY=e2e-public", "TIDB_CLOUD_PRIVATE_KEY=e2e-private", + } + configured := runTIWithInput(t, bin, "", env, "configure", "--profile", "stage", "--non-interactive") + configured.wantExitCode(0) + env = append(env, "TI_REGION_CODE=") + + missingLifetime := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-token", "--file-system-id", "tenant-tokens", "--token-name", "e2e-owner") + missingLifetime.wantExitCode(2) + missingLifetime.wantStderrContains("exactly one of --ttl or --no-expiration") + + beforeDryRun := remoteRequests + dryRun := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-token", "--file-system-id", "tenant-tokens", "--token-name", "e2e-owner", "--ttl", "24h", "--dry-run") + dryRun.wantExitCode(0) + dryRun.wantStdoutNotContains("drive9_") + if remoteRequests != beforeDryRun { + t.Fatalf("dry-run sent a remote request: before=%d after=%d", beforeDryRun, remoteRequests) + } + + generated := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-token", "--file-system-id", "tenant-tokens", "--token-name", "e2e-owner", "--ttl", "24h", "--store-locally", "--query", "fs_token", "--output", "text") + generated.wantExitCode(0) + if strings.TrimSpace(generated.stdout) != generatedToken { + generated.fail("generate query did not return the one-time token") + } + credential, err := fscred.GetCredential(home, "stage", "tenant-tokens") + if err != nil || credential.TokenID != "token-e2e" || credential.APIKey != generatedToken { + t.Fatalf("generated credential = %#v, %v", credential, err) + } + + listed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens", "--output", "text") + listed.wantExitCode(0) + listed.wantStdoutContains("token-e2e") + listed.wantStdoutContains("e2e-owner") + listed.wantStdoutNotContains("drive9_") + + disabled := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "disable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e") + disabled.wantExitCode(0) + disabled.wantStdoutContains(`"status": "disabled"`) + enabled := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "enable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e") + enabled.wantExitCode(0) + enabled.wantStdoutContains(`"status": "active"`) + + refreshed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "refresh-file-system-token", "--file-system-id", "tenant-tokens", "--query", "fs_token", "--output", "text") + refreshed.wantExitCode(0) + if strings.TrimSpace(refreshed.stdout) != refreshedToken { + refreshed.fail("refresh query did not return the rotated token") + } + credential, err = fscred.GetCredential(home, "stage", "tenant-tokens") + if err != nil || credential.APIKey != refreshedToken { + t.Fatalf("refreshed credential = %#v, %v", credential, err) + } + + deleted := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "delete-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e") + deleted.wantExitCode(0) + deleted.wantStdoutContains(`"local_credentials_updated": true`) + if _, err := fscred.GetCredential(home, "stage", "tenant-tokens"); apperr.CodeFor(err) != "fs.credential_not_found" { + t.Fatalf("deleted token left local credentials: %v", err) + } + logData, err := os.ReadFile(filepath.Join(home, ".ti", "logs", "ti.jsonl")) + if err == nil && (strings.Contains(string(logData), generatedToken) || strings.Contains(string(logData), refreshedToken)) { + t.Fatal("operation log leaked a file system token") + } +} + func drive9TestToken(fileSystemID string) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) payload, _ := json.Marshal(map[string]string{"tenant_id": fileSystemID}) @@ -948,6 +1057,13 @@ func drive9TestToken(fileSystemID string) string { return "drive9_" + base64.RawURLEncoding.EncodeToString([]byte(jwt)) } +func drive9TestTokenWithVersion(fileSystemID string, version int) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload, _ := json.Marshal(map[string]any{"tenant_id": fileSystemID, "token_version": version}) + jwt := header + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature" + return "drive9_" + base64.RawURLEncoding.EncodeToString([]byte(jwt)) +} + type fakeDrive9Call struct { Args []string `json:"args"` Home string `json:"home"` diff --git a/e2e/live_test.go b/e2e/live_test.go index 534546a..d8a4e7a 100644 --- a/e2e/live_test.go +++ b/e2e/live_test.go @@ -28,13 +28,15 @@ const defaultLiveProfile = "live-e2e" var ( liveFSResourceMu sync.Mutex liveFSResourceAutoCreatedID string + liveFSTokenAutoCreatedID string + liveFSTokenAutoFileSystemID string liveFSSelectedID string liveProfileConfigureMu sync.Mutex ) func TestMain(m *testing.M) { code := m.Run() - if liveFSResourceAutoCreatedID != "" { + if liveFSResourceAutoCreatedID != "" || liveFSTokenAutoCreatedID != "" { cleanupAutoCreatedLiveFSResource() } os.Exit(code) @@ -96,12 +98,10 @@ func TestLiveFSRemoteInventoryLifecycle(t *testing.T) { if err := json.Unmarshal([]byte(create.stdout), &created); err != nil || created.FileSystemID == "" || created.FSToken == "" { t.Fatalf("decode live tdc fs create result: %v", err) } - defer func() { - result := runTI(t, bin, "--profile", profileName, "fs", "delete-file-system", "--file-system-id", created.FileSystemID) - if result.exitCode != 0 { - t.Logf("cleanup delete failed for tdc fs resource %q: exit=%d stdout=%s stderr=%s", created.FileSystemID, result.exitCode, result.stdout, result.stderr) - } - }() + liveFSResourceMu.Lock() + liveFSResourceAutoCreatedID = created.FileSystemID + liveFSSelectedID = created.FileSystemID + liveFSResourceMu.Unlock() list := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems") list.wantExitCode(0) @@ -214,6 +214,9 @@ func TestLiveFSCommandSurface(t *testing.T) { {"fs", "stat", "help"}, {"fs", "mv", "help"}, {"fs", "rm", "help"}, {"fs", "mkdir", "help"}, {"fs", "chmod", "help"}, {"fs", "symlink", "help"}, {"fs", "hardlink", "help"}, {"fs", "grep", "help"}, {"fs", "find", "help"}, + {"fs", "generate-file-system-token", "help"}, {"fs", "list-file-system-tokens", "help"}, + {"fs", "enable-file-system-token", "help"}, {"fs", "disable-file-system-token", "help"}, + {"fs", "delete-file-system-token", "help"}, {"fs", "refresh-file-system-token", "help"}, {"fs", "mount", "help"}, {"fs", "drain", "help"}, {"fs", "umount", "help"}, }) testLiveMutatingDryRuns(t, bin, profileName, [][]string{ @@ -225,7 +228,14 @@ func TestLiveFSCommandSurface(t *testing.T) { {"fs", "pack-file-system", "--local-root", "/tmp/ti-e2e-pack", "--remote-root", "/workspace", "--mount-profile", "portable"}, {"fs", "unpack-file-system", "--local-root", "/tmp/ti-e2e-pack", "--remote-root", "/workspace", "--mount-profile", "portable"}, {"fs", "mount-file-system", "--mount-path", "/tmp/ti-e2e-mount", "--driver", "webdav"}, + {"fs", "generate-file-system-token", "--file-system-id", selected.FSTenantID, "--token-name", "ti-e2e-dry-run", "--ttl", "1h"}, + {"fs", "enable-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", "00000000-0000-0000-0000-000000000000"}, + {"fs", "disable-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", "00000000-0000-0000-0000-000000000000"}, + {"fs", "delete-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", "00000000-0000-0000-0000-000000000000"}, }, "remote_mutation") + refreshDryRun := runTIWithInput(t, bin, "", []string{"TI_FS_TOKEN=" + drive9TestTokenWithVersion(selected.FSTenantID, 999), "TI_REGION_CODE=" + selected.FSPlacementRegionCode}, + "--profile", profileName, "fs", "refresh-file-system-token", "--file-system-id", selected.FSTenantID, "--dry-run") + refreshDryRun.wantExitCode(0) unmountDryRun := runTI(t, bin, "--profile", profileName, "fs", "unmount-file-system", "--mount-path", "/tmp/ti-e2e-mount", "--ignore-absent", "--dry-run", "--query", "checks[].name") unmountDryRun.wantExitCode(0) for _, check := range []string{"input_validation", "mount_locator", "remote_mutation"} { @@ -256,6 +266,107 @@ func TestLiveFSCommandSurface(t *testing.T) { }) } +func TestLiveFSFileSystemTokenLifecycle(t *testing.T) { + requireLive(t) + bin := tiBinary(t) + profileName := liveProfileName(t) + selected := ensureLiveFSResource(t, bin, profileName) + regionCode := selected.FSPlacementRegionCode + if regionCode == "" { + regionCode = selected.PlacementRegionCode + } + tokenName := fmt.Sprintf("ti-e2e-token-%d", time.Now().UnixNano()) + generated := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "generate-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-name", tokenName, "--ttl", "1h") + generated.wantExitCode(0) + var generatedResult struct { + FileSystemID string `json:"file_system_id"` + TokenID string `json:"token_id"` + FSToken string `json:"fs_token"` + } + if err := json.Unmarshal([]byte(generated.stdout), &generatedResult); err != nil || generatedResult.TokenID == "" || generatedResult.FSToken == "" { + t.Fatalf("decode generated FS token: %v\n%s", err, generated.stdout) + } + if generatedResult.FileSystemID != selected.FSTenantID { + t.Fatalf("generated token file_system_id = %q, want %q", generatedResult.FileSystemID, selected.FSTenantID) + } + deleted := false + defer func() { + if deleted { + return + } + cleanup := runLiveFSSetupCommand(t, bin, "--profile", profileName, "--region", regionCode, "fs", "delete-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-id", generatedResult.TokenID) + if cleanup.exitCode != 0 && !strings.Contains(strings.ToLower(cleanup.stderr), "not found") { + t.Logf("cleanup generated FS token failed: %s", strings.TrimSpace(cleanup.stderr)) + } + }() + + list := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "list-file-system-tokens", "--file-system-id", selected.FSTenantID) + list.wantExitCode(0) + list.wantStdoutContains(generatedResult.TokenID) + list.wantStdoutContains(tokenName) + list.wantStdoutNotContains(generatedResult.FSToken) + + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, generatedResult.FSToken, true, 30*time.Second) + disable := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "disable-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-id", generatedResult.TokenID) + disable.wantExitCode(0) + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, generatedResult.FSToken, false, 30*time.Second) + + enable := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "enable-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-id", generatedResult.TokenID) + enable.wantExitCode(0) + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, generatedResult.FSToken, true, 30*time.Second) + + refreshEnv := []string{"TI_FS_TOKEN=" + generatedResult.FSToken, "TI_REGION_CODE=" + regionCode} + refresh := runTIWithInput(t, bin, "", refreshEnv, "--profile", profileName, "fs", "refresh-file-system-token", "--file-system-id", selected.FSTenantID) + refresh.wantExitCode(0) + var refreshResult struct { + TokenID string `json:"token_id"` + FSToken string `json:"fs_token"` + } + if err := json.Unmarshal([]byte(refresh.stdout), &refreshResult); err != nil || refreshResult.FSToken == "" { + t.Fatalf("decode refreshed FS token: %v\n%s", err, refresh.stdout) + } + if refreshResult.TokenID != generatedResult.TokenID { + t.Fatalf("refresh changed token ID: %q -> %q", generatedResult.TokenID, refreshResult.TokenID) + } + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, generatedResult.FSToken, false, 30*time.Second) + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, refreshResult.FSToken, true, 30*time.Second) + + remove := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "delete-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-id", generatedResult.TokenID) + remove.wantExitCode(0) + deleted = true + waitLiveFSTokenAccess(t, bin, profileName, regionCode, selected.FSTenantID, refreshResult.FSToken, false, 30*time.Second) + listAfter := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "list-file-system-tokens", "--file-system-id", selected.FSTenantID) + listAfter.wantExitCode(0) + listAfter.wantStdoutNotContains(generatedResult.TokenID) +} + +func waitLiveFSTokenAccess(t *testing.T, bin, profileName, regionCode, fileSystemID, token string, wantAccess bool, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var last commandResult + for { + last = runTIWithInput(t, bin, "", []string{"TI_FS_TOKEN=" + token, "TI_REGION_CODE=" + regionCode}, + "--profile", profileName, "fs", "list-files", "--file-system-id", fileSystemID, "--path", "/") + hasAccess := last.exitCode == 0 + if hasAccess == wantAccess { + message := strings.ToLower(last.stderr) + if !wantAccess && !strings.Contains(message, "authentication") && !strings.Contains(message, "invalid api key") { + last.fail("token access failed for a reason other than authentication") + } + return + } + if time.Now().After(deadline) { + last.fail("token access did not converge to %v within %s", wantAccess, timeout) + } + time.Sleep(time.Second) + } +} + func TestLiveFSVaultCommandSurface(t *testing.T) { requireLive(t) bin := tiBinary(t) @@ -1153,45 +1264,55 @@ func TestLiveFSConfigurationFreeAccess(t *testing.T) { bin := tiBinary(t) profileName := liveProfileName(t) suffix := fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102150405"), os.Getpid()) - preflightList := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems") - preflightList.wantExitCode(0) - create := runTI(t, bin, "--profile", profileName, "fs", "create-file-system", "--wait") - create.wantExitCode(0) + selected := ensureLiveFSResource(t, bin, profileName) var created struct { FileSystemID string `json:"file_system_id"` RegionCode string `json:"region_code"` FSToken string `json:"fs_token"` + TokenID string `json:"token_id"` Status string `json:"status"` } - if err := json.Unmarshal([]byte(create.stdout), &created); err != nil { - t.Fatalf("decode configuration-free FS create result: %v", err) + createToken := runTI(t, bin, "--profile", profileName, "--region", selected.FSPlacementRegionCode, "fs", "generate-file-system-token", + "--file-system-id", selected.FSTenantID, + "--token-name", "ti-e2e-config-free-"+suffix, + "--ttl", "1h") + createToken.wantExitCode(0) + if err := json.Unmarshal([]byte(createToken.stdout), &created); err != nil { + t.Fatalf("decode configuration-free FS token result: %v", err) } - create.stdout = "" - if created.FileSystemID == "" || created.RegionCode == "" || created.FSToken == "" { - t.Fatalf("configuration-free FS create result is incomplete") + createToken.stdout = "" + created.RegionCode = selected.FSPlacementRegionCode + if created.FileSystemID == "" || created.RegionCode == "" || created.FSToken == "" || created.TokenID == "" { + t.Fatalf("configuration-free FS token result is incomplete") } - if created.Status != "ready" { - t.Fatalf("--wait returned ti fs resource in status %q", created.Status) + if created.Status != "active" { + t.Fatalf("generated configuration-free token is in status %q", created.Status) } - deletedResource := false + deletedToken := false defer func() { - if deletedResource { + if deletedToken { return } - cleanup := runTI(t, bin, "--profile", profileName, "fs", "delete-file-system", "--file-system-id", created.FileSystemID) + cleanup := runTI(t, bin, "--profile", profileName, "--region", created.RegionCode, "fs", "delete-file-system-token", + "--file-system-id", created.FileSystemID, "--token-id", created.TokenID) if cleanup.exitCode != 0 { - t.Logf("cleanup configuration-free FS resource failed for %q: exit=%d stderr=%s", created.FileSystemID, cleanup.exitCode, strings.TrimSpace(cleanup.stderr)) + t.Logf("cleanup configuration-free FS token failed for %q: exit=%d stderr=%s", created.TokenID, cleanup.exitCode, strings.TrimSpace(cleanup.stderr)) } }() profile := liveProfile(t) - selected := resolveLiveFSResourceByID(t, profile, created.FileSystemID) - if selected.FSAPIKey != created.FSToken || selected.FSPlacementRegionCode != created.RegionCode { - t.Fatal("stored FS resource credentials or placement differ from create output") - } cleanHome := t.TempDir() - authEnv := liveFSTokenEnv(selected, cleanHome) + authEnv := []string{ + "HOME=" + cleanHome, + "TI_PROFILE=", + "TIDB_CLOUD_PUBLIC_KEY=", + "TIDB_CLOUD_PRIVATE_KEY=", + "TI_REGION_CODE=" + created.RegionCode, + "TI_FS_FILE_SYSTEM_ID=", + "TI_FS_TOKEN=" + created.FSToken, + } + waitLiveFSTokenAccess(t, bin, profileName, created.RegionCode, created.FileSystemID, created.FSToken, true, 30*time.Second) remoteRoot := "/ti-e2e-token-" + suffix remoteDeleted := false defer func() { @@ -1334,15 +1455,11 @@ func TestLiveFSConfigurationFreeAccess(t *testing.T) { deleteRemote := runTI(t, bin, "--profile", profileName, "fs", "delete-file", "--file-system-id", created.FileSystemID, "--path", remoteRoot, "--recursive") deleteRemote.wantExitCode(0) remoteDeleted = true - deleteResource := runTIWithInput(t, bin, "", controlEnv, "fs", "delete-file-system", "--file-system-id", created.FileSystemID) - deleteResource.wantExitCode(0) - deleteResource.wantStdoutContains(`"status": "deleting"`) - deleteResource.wantStdoutContains(`"remote_deletion_state": "deleting"`) - deletedResource = true - if _, err := fscred.DeleteCredential(profile.HomeDir, profileName, created.FileSystemID); err != nil { - t.Fatalf("remove original local credential after remote deletion acceptance: %v", err) - } - waitLiveFSInventoryAbsent(t, bin, controlEnv, created.FileSystemID, 2*time.Minute) + deleteToken := runTIWithInput(t, bin, "", controlEnv, "fs", "delete-file-system-token", + "--file-system-id", created.FileSystemID, "--token-id", created.TokenID) + deleteToken.wantExitCode(0) + deleteToken.wantStdoutContains(`"status": "revoked"`) + deletedToken = true } func TestLiveFSWebDAVMountRuntime(t *testing.T) { @@ -1928,14 +2045,17 @@ func ensureLiveFSResource(t *testing.T, bin, profileName string) *config.Profile list := runTI(t, bin, "--profile", profileName, "fs", "list-file-systems") list.wantExitCode(0) var inventory struct { + RegionCode string `json:"region_code"` FileSystems []struct { FileSystemID string `json:"file_system_id"` + RegionCode string `json:"region_code"` HasLocalToken bool `json:"has_local_token"` } `json:"file_systems"` } if err := json.Unmarshal([]byte(list.stdout), &inventory); err != nil { t.Fatalf("decode live fs inventory: %v", err) } + var unusableResources []string for _, resource := range inventory.FileSystems { if !resource.HasLocalToken || (requestedID != "" && requestedID != resource.FileSystemID) { continue @@ -1946,8 +2066,54 @@ func ensureLiveFSResource(t *testing.T, bin, profileName string) *config.Profile return selected } + for _, resource := range inventory.FileSystems { + if requestedID != "" && requestedID != resource.FileSystemID { + continue + } + regionCode := resource.RegionCode + if regionCode == "" { + regionCode = inventory.RegionCode + } + if regionCode == "" { + regionCode = profile.PlacementRegionCode + } + generate := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "generate-file-system-token", + "--file-system-id", resource.FileSystemID, + "--token-name", fmt.Sprintf("ti-e2e-session-%d", time.Now().UnixNano()), + "--ttl", "1h", + "--store-locally") + generate.wantExitCode(0) + var generated struct { + TokenID string `json:"token_id"` + } + if err := json.Unmarshal([]byte(generate.stdout), &generated); err != nil || generated.TokenID == "" { + t.Fatalf("decode temporary live FS token: %v\n%s", err, generate.stdout) + } + probe := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "list-files", "--file-system-id", resource.FileSystemID, "--path", "/") + if probe.exitCode != 0 { + cleanup := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "delete-file-system-token", + "--file-system-id", resource.FileSystemID, "--token-id", generated.TokenID) + if cleanup.exitCode != 0 { + cleanup.fail("clean up temporary token for unusable live FS %s", resource.FileSystemID) + } + unusableResources = append(unusableResources, fmt.Sprintf("%s: %s", resource.FileSystemID, strings.TrimSpace(probe.stderr))) + if requestedID != "" { + t.Fatalf("TI_LIVE_FS_ID %q is not data-plane accessible: %s", requestedID, strings.TrimSpace(probe.stderr)) + } + continue + } + liveFSTokenAutoCreatedID = generated.TokenID + liveFSTokenAutoFileSystemID = resource.FileSystemID + liveFSSelectedID = resource.FileSystemID + selected := resolveLiveFSResourceByID(t, profile, resource.FileSystemID) + return selected + } + if requestedID != "" { - t.Fatalf("TI_LIVE_FS_ID %q is not remotely visible with a local token", requestedID) + t.Fatalf("TI_LIVE_FS_ID %q is not remotely visible", requestedID) + } + if len(inventory.FileSystems) > 0 { + t.Fatalf("no remotely visible Filesystem has a working data plane:\n%s", strings.Join(unusableResources, "\n")) } create := runTI(t, bin, "--profile", profileName, "fs", "create-file-system", "--wait") create.wantExitCode(0) @@ -2083,6 +2249,22 @@ func cleanupAutoCreatedLiveFSResource() { return } profileName := liveProfileNameFromEnv() + if liveFSTokenAutoCreatedID != "" && liveFSTokenAutoFileSystemID != "" { + cmd := exec.Command( + bin, + "--profile", profileName, + "fs", "delete-file-system-token", + "--file-system-id", liveFSTokenAutoFileSystemID, + "--token-id", liveFSTokenAutoCreatedID, + ) + output, err := cmd.CombinedOutput() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "ti live e2e cleanup warning: delete temporary FS token %q failed: %v\n%s", liveFSTokenAutoCreatedID, err, string(output)) + } + } + if liveFSResourceAutoCreatedID == "" { + return + } fileSystemID := liveFSResourceAutoCreatedID cmd := exec.Command( bin, @@ -2100,6 +2282,20 @@ func releaseAutoCreatedLiveFSResource(t *testing.T, bin, profileName string) { t.Helper() liveFSResourceMu.Lock() defer liveFSResourceMu.Unlock() + if liveFSTokenAutoCreatedID != "" && liveFSTokenAutoFileSystemID != "" { + result := runTI( + t, + bin, + "--profile", profileName, + "fs", "delete-file-system-token", + "--file-system-id", liveFSTokenAutoFileSystemID, + "--token-id", liveFSTokenAutoCreatedID, + ) + result.wantExitCode(0) + liveFSTokenAutoCreatedID = "" + liveFSTokenAutoFileSystemID = "" + liveFSSelectedID = "" + } if liveFSResourceAutoCreatedID == "" { return } diff --git a/internal/api/client.go b/internal/api/client.go index 1866eba..2a37912 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -34,6 +34,7 @@ type Client struct { Service endpoints.Service UserAgent string MaxRetries int + Redactor apitransport.Redactor } type Options struct { @@ -96,6 +97,7 @@ func New(opts Options) (*Client, error) { Service: opts.Endpoint.Service, UserAgent: userAgent, MaxRetries: maxRetries, + Redactor: opts.Redactor, }, nil } @@ -293,6 +295,7 @@ func retryableRequest(req *http.Request) bool { func (c *Client) statusError(req *http.Request, res *http.Response) error { body, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024)) + body = []byte(c.Redactor.Redact(string(body))) apiMessage := responseMessage(body) switch res.StatusCode { case http.StatusBadRequest: @@ -314,7 +317,11 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { case http.StatusUnauthorized: message := fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) if c.Service == endpoints.ServiceFS { - message = fmt.Sprintf("authentication failed: ti fs rejected fs_api_key for profile %q. Run `ti fs create-file-system` or recreate the ti fs resource.", profileName(c.ProfileName)) + if strings.HasPrefix(string(c.Permission), "fs.token.") && c.Permission != authz.FSTokenRefresh { + message = fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) + } else { + message = fmt.Sprintf("authentication failed: ti fs rejected the selected token for profile %q. It might be disabled, expired, refreshed elsewhere, or revoked; generate or import a valid token and try again.", profileName(c.ProfileName)) + } } return &Error{ Code: "auth.invalid_credentials", @@ -369,6 +376,12 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Message: messageOrDefault(apiMessage, "API rate limit exceeded: retry later"), Body: string(body), } + case http.StatusConflict: + message := messageOrDefault(apiMessage, "remote lifecycle conflict: inspect the resource state and retry only when safe") + if c.Permission == authz.FSTokenEnable && strings.Contains(strings.ToLower(message), "expired") { + message = "the token is expired and cannot be enabled; generate a new token instead" + } + return &Error{Code: "api.conflict", Category: "api", ExitCode: 1, StatusCode: res.StatusCode, Message: message, Body: string(body)} default: return &Error{ Code: "api.remote_error", diff --git a/internal/api/fs/token.go b/internal/api/fs/token.go new file mode 100644 index 0000000..2bbe3bb --- /dev/null +++ b/internal/api/fs/token.go @@ -0,0 +1,180 @@ +package fs + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + tidbCloudPublicKeyHeader = "X-TiDBCloud-Public-Key" + tidbCloudPrivateKeyHeader = "X-TiDBCloud-Private-Key" +) + +type TiDBCloudCredentials struct { + PublicKey string + PrivateKey string +} + +type GenerateTokenRequest struct { + FileSystemID string + TokenName string + TTLSeconds *int64 +} + +type GenerateTokenResponse struct { + Token string `json:"token"` + TokenID string `json:"token_id"` + FileSystemID string `json:"tenant_id"` + TokenName string `json:"key_name"` + ScopeKind string `json:"scope_kind"` + Status string `json:"status"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` +} + +type TokenMetadata struct { + TokenID string `json:"token_id"` + FileSystemID string `json:"tenant_id"` + TokenName string `json:"key_name"` + ScopeKind string `json:"scope_kind"` + Status string `json:"status"` + Expired bool `json:"expired"` + IssuedByProvider string `json:"issued_by_provider,omitempty"` + IssuedBySubjectKey string `json:"issued_by_subject_key,omitempty"` + IssuedByMetadataJSON json.RawMessage `json:"issued_by_metadata_json,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListTokensOptions struct { + FileSystemID string + IncludeExpired bool + Offset int + Limit int +} + +type ListTokensResponse struct { + Tokens []TokenMetadata `json:"tokens"` + NextOffset *int `json:"next_offset,omitempty"` +} + +type TokenMutationResponse struct { + TokenID string `json:"token_id"` + FileSystemID string `json:"tenant_id"` + Status string `json:"status"` +} + +type RefreshTokenRequest struct { + TTLSeconds *int64 +} + +type RefreshTokenResponse struct { + Token string `json:"token"` + TokenID string `json:"token_id"` + FileSystemID string `json:"tenant_id"` + ScopeKind string `json:"scope_kind"` + ExpiresAt *time.Time `json:"expires_at"` +} + +func (c *Client) GenerateToken(ctx context.Context, creds TiDBCloudCredentials, input GenerateTokenRequest) (GenerateTokenResponse, error) { + body := struct { + FileSystemID string `json:"tenant_id"` + TokenName string `json:"key_name"` + TTLSeconds *int64 `json:"ttl_seconds,omitempty"` + }{input.FileSystemID, input.TokenName, input.TTLSeconds} + req, err := c.api.NewRequest(ctx, http.MethodPost, "/v1/tokens/generate", body) + if err != nil { + return GenerateTokenResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response GenerateTokenResponse + if err := c.api.DoJSON(req, &response); err != nil { + return GenerateTokenResponse{}, err + } + return response, nil +} + +func (c *Client) ListTokens(ctx context.Context, creds TiDBCloudCredentials, opts ListTokensOptions) (ListTokensResponse, error) { + query := url.Values{} + query.Set("tenant_id", opts.FileSystemID) + query.Set("offset", strconv.Itoa(opts.Offset)) + query.Set("limit", strconv.Itoa(opts.Limit)) + if opts.IncludeExpired { + query.Set("include_expired", "1") + } + req, err := c.api.NewRequest(ctx, http.MethodGet, "/v1/tokens?"+query.Encode(), nil) + if err != nil { + return ListTokensResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response ListTokensResponse + if err := c.api.DoJSON(req, &response); err != nil { + return ListTokensResponse{}, err + } + if response.Tokens == nil { + response.Tokens = []TokenMetadata{} + } + return response, nil +} + +func (c *Client) SetTokenEnabled(ctx context.Context, creds TiDBCloudCredentials, fileSystemID, tokenID string, enabled bool) (TokenMutationResponse, error) { + action := "deactivate" + if enabled { + action = "activate" + } + query := url.Values{"tenant_id": []string{fileSystemID}} + path := "/v1/tokens/" + url.PathEscape(tokenID) + "/" + action + "?" + query.Encode() + req, err := c.api.NewRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return TokenMutationResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response TokenMutationResponse + if err := c.api.DoJSON(req, &response); err != nil { + return TokenMutationResponse{}, err + } + return response, nil +} + +func (c *Client) DeleteToken(ctx context.Context, creds TiDBCloudCredentials, fileSystemID, tokenID string) (TokenMutationResponse, error) { + query := url.Values{"tenant_id": []string{fileSystemID}} + path := "/v1/tokens/" + url.PathEscape(tokenID) + "?" + query.Encode() + req, err := c.api.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return TokenMutationResponse{}, err + } + setTiDBCloudCredentialHeaders(req, creds) + var response TokenMutationResponse + if err := c.api.DoJSON(req, &response); err != nil { + return TokenMutationResponse{}, err + } + return response, nil +} + +func (c *Client) RefreshToken(ctx context.Context, input RefreshTokenRequest) (RefreshTokenResponse, error) { + body := struct { + TTLSeconds *int64 `json:"ttl_seconds,omitempty"` + }{input.TTLSeconds} + req, err := c.api.NewRequest(ctx, http.MethodPost, "/v1/tokens/refresh", body) + if err != nil { + return RefreshTokenResponse{}, err + } + var response RefreshTokenResponse + if err := c.api.DoJSON(req, &response); err != nil { + return RefreshTokenResponse{}, err + } + return response, nil +} + +func setTiDBCloudCredentialHeaders(req *http.Request, creds TiDBCloudCredentials) { + req.Header.Set(tidbCloudPublicKeyHeader, strings.TrimSpace(creds.PublicKey)) + req.Header.Set(tidbCloudPrivateKeyHeader, strings.TrimSpace(creds.PrivateKey)) +} diff --git a/internal/api/fs/token_test.go b/internal/api/fs/token_test.go new file mode 100644 index 0000000..cb5faba --- /dev/null +++ b/internal/api/fs/token_test.go @@ -0,0 +1,176 @@ +package fs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tidbcloud/ti-cli/internal/api" + "github.com/tidbcloud/ti-cli/internal/api/endpoints" +) + +func TestControlPlaneTokenRequestsUseHeadersAndExpectedShapes(t *testing.T) { + t.Parallel() + requests := make(chan *http.Request, 5) + bodies := make(chan map[string]any, 5) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + requests <- r.Clone(r.Context()) + bodies <- body + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/v1/tokens/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"drive9_secret","token_id":"token-1","tenant_id":"fs-1","key_name":"ci","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z","expires_at":null}`)) + case r.Method == http.MethodGet: + _, _ = w.Write([]byte(`{"tokens":[],"next_offset":50}`)) + default: + _, _ = w.Write([]byte(`{"token_id":"token-1","tenant_id":"fs-1","status":"active"}`)) + } + })) + defer server.Close() + + client := newTokenTestClient(t, server.URL, "") + creds := TiDBCloudCredentials{PublicKey: "public", PrivateKey: "private"} + ttl := int64(3600) + generated, err := client.GenerateToken(context.Background(), creds, GenerateTokenRequest{FileSystemID: "fs-1", TokenName: "ci", TTLSeconds: &ttl}) + if err != nil || generated.Token != "drive9_secret" { + t.Fatalf("GenerateToken() = %#v, %v", generated, err) + } + req, body := <-requests, <-bodies + assertControlHeaders(t, req) + if body["tenant_id"] != "fs-1" || body["key_name"] != "ci" || body["ttl_seconds"] != float64(3600) { + t.Fatalf("generate body = %#v", body) + } + + listed, err := client.ListTokens(context.Background(), creds, ListTokensOptions{FileSystemID: "fs-1", IncludeExpired: true, Offset: 2, Limit: 50}) + if err != nil || listed.NextOffset == nil || *listed.NextOffset != 50 { + t.Fatalf("ListTokens() = %#v, %v", listed, err) + } + req, body = <-requests, <-bodies + assertControlHeaders(t, req) + if len(body) != 0 || req.URL.Query().Get("tenant_id") != "fs-1" || req.URL.Query().Get("include_expired") != "1" || req.URL.Query().Get("offset") != "2" || req.URL.Query().Get("limit") != "50" { + t.Fatalf("list request = %s %#v", req.URL.String(), body) + } + + if _, err := client.SetTokenEnabled(context.Background(), creds, "fs-1", "token-1", true); err != nil { + t.Fatal(err) + } + req, body = <-requests, <-bodies + assertControlHeaders(t, req) + if req.URL.Path != "/v1/tokens/token-1/activate" || req.URL.Query().Get("tenant_id") != "fs-1" || len(body) != 0 { + t.Fatalf("activate request = %s %#v", req.URL.Path, body) + } + if _, err := client.SetTokenEnabled(context.Background(), creds, "fs-1", "token-1", false); err != nil { + t.Fatal(err) + } + req, body = <-requests, <-bodies + assertControlHeaders(t, req) + if req.URL.Path != "/v1/tokens/token-1/deactivate" || req.URL.Query().Get("tenant_id") != "fs-1" || len(body) != 0 { + t.Fatalf("deactivate request = %s %#v", req.URL.Path, body) + } + + if _, err := client.DeleteToken(context.Background(), creds, "fs-1", "token-1"); err != nil { + t.Fatal(err) + } + req, body = <-requests, <-bodies + assertControlHeaders(t, req) + if req.Method != http.MethodDelete || req.URL.Path != "/v1/tokens/token-1" || req.URL.Query().Get("tenant_id") != "fs-1" || len(body) != 0 { + t.Fatalf("delete request = %s %s %#v", req.Method, req.URL.Path, body) + } +} + +func TestRefreshTokenUsesBearerOnly(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer drive9_old" { + t.Errorf("Authorization = %q", got) + } + if r.Header.Get(tidbCloudPublicKeyHeader) != "" || r.Header.Get(tidbCloudPrivateKeyHeader) != "" { + t.Error("refresh included control-plane credentials") + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 0 { + t.Errorf("refresh body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"drive9_new","token_id":"token-1","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + })) + defer server.Close() + + client := newTokenTestClient(t, server.URL, "drive9_old") + response, err := client.RefreshToken(context.Background(), RefreshTokenRequest{}) + if err != nil || response.Token != "drive9_new" { + t.Fatalf("RefreshToken() = %#v, %v", response, err) + } +} + +func TestRefreshTokenSendsExplicitTTL(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["ttl_seconds"] != float64(3600) { + t.Errorf("refresh body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"drive9_new","token_id":"token-1","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + })) + defer server.Close() + client := newTokenTestClient(t, server.URL, "drive9_old") + ttl := int64(3600) + if _, err := client.RefreshToken(context.Background(), RefreshTokenRequest{TTLSeconds: &ttl}); err != nil { + t.Fatal(err) + } +} + +func TestTokenErrorsRedactCredentialMaterial(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"rejected drive9_old"}`)) + })) + defer server.Close() + client := newTokenTestClient(t, server.URL, "drive9_old") + _, err := client.RefreshToken(context.Background(), RefreshTokenRequest{}) + if err == nil || strings.Contains(err.Error(), "drive9_old") { + t.Fatalf("RefreshToken error leaked token: %v", err) + } +} + +func newTokenTestClient(t *testing.T, baseURL, bearer string) *Client { + t.Helper() + endpoint := endpoints.Endpoint{Service: endpoints.ServiceFS, BaseURL: baseURL, Provider: "aws", RegionCode: "us-east-1"} + var raw *api.Client + var err error + if bearer == "" { + raw, err = api.New(api.Options{Endpoint: endpoint, ProfileName: "test", MaxRetries: -1}) + } else { + raw, err = api.NewBearerClient("test", bearer, endpoint, "fs.token.refresh", api.Options{MaxRetries: -1}) + } + if err != nil { + t.Fatal(err) + } + return New(raw) +} + +func assertControlHeaders(t *testing.T, req *http.Request) { + t.Helper() + if req.Header.Get(tidbCloudPublicKeyHeader) != "public" || req.Header.Get(tidbCloudPrivateKeyHeader) != "private" { + t.Fatalf("control headers = %q/%q", req.Header.Get(tidbCloudPublicKeyHeader), req.Header.Get(tidbCloudPrivateKeyHeader)) + } + if req.Header.Get("Authorization") != "" { + t.Fatalf("unexpected Authorization = %q", req.Header.Get("Authorization")) + } +} diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 85b0f53..3e2402b 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -25,6 +25,12 @@ const ( FSVolumeRead Permission = "fs.volume.read" FSVolumeCreate Permission = "fs.volume.create" FSVolumeDelete Permission = "fs.volume.delete" + FSTokenList Permission = "fs.token.list" + FSTokenGenerate Permission = "fs.token.generate" + FSTokenEnable Permission = "fs.token.enable" + FSTokenDisable Permission = "fs.token.disable" + FSTokenDelete Permission = "fs.token.delete" + FSTokenRefresh Permission = "fs.token.refresh" FSFileRead Permission = "fs.file.read" FSFileWrite Permission = "fs.file.write" FSVaultSecretRead Permission = "fs.vault.secret.read" @@ -50,6 +56,12 @@ var commandPermissions = map[string]Permission{ "ti fs list-file-systems": FSVolumeRead, "ti fs describe-file-system": FSVolumeRead, "ti fs check-file-system": FSVolumeRead, + "ti fs generate-file-system-token": FSTokenGenerate, + "ti fs list-file-system-tokens": FSTokenList, + "ti fs enable-file-system-token": FSTokenEnable, + "ti fs disable-file-system-token": FSTokenDisable, + "ti fs delete-file-system-token": FSTokenDelete, + "ti fs refresh-file-system-token": FSTokenRefresh, "ti fs copy-file": FSFileWrite, "ti fs read-file": FSFileRead, "ti fs list-files": FSFileRead, diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 793f75d..8eaa70d 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "net/http" "os" "runtime" "strings" @@ -20,6 +21,7 @@ import ( "github.com/tidbcloud/ti-cli/internal/dryrun" tifs "github.com/tidbcloud/ti-cli/internal/fs" "github.com/tidbcloud/ti-cli/internal/fs/fscred" + "github.com/tidbcloud/ti-cli/internal/fs/tokenmgmt" outputpkg "github.com/tidbcloud/ti-cli/internal/output" "github.com/tidbcloud/ti-cli/internal/update" "github.com/tidbcloud/ti-cli/internal/version" @@ -858,6 +860,12 @@ func newFSCommand(info version.Info) *cobra.Command { newFSListFileSystemsCommand(info), newFSDescribeFileSystemCommand(info), newFSImportFileSystemTokenCommand(info), + newFSGenerateFileSystemTokenCommand(info), + newFSListFileSystemTokensCommand(info), + newFSEnableFileSystemTokenCommand(info), + newFSDisableFileSystemTokenCommand(info), + newFSDeleteFileSystemTokenCommand(info), + newFSRefreshFileSystemTokenCommand(info), newFSCheckFileSystemCommand(info), newFSCopyFileCommand(info), newFSReadFileCommand(info), @@ -884,7 +892,9 @@ func newFSCommand(info version.Info) *cobra.Command { newFSDrainFileSystemCommand(info), newFSUnmountFileSystemCommand(info), } - addFSSelectorFlags(commands, "create-file-system", "list-file-systems", "describe-file-system", "delete-file-system", "import-file-system-token", "drain-file-system", "unmount-file-system") + tokenCommands := []string{"generate-file-system-token", "list-file-system-tokens", "enable-file-system-token", "disable-file-system-token", "delete-file-system-token", "refresh-file-system-token"} + selectorExclusions := append([]string{"create-file-system", "list-file-systems", "describe-file-system", "delete-file-system", "import-file-system-token", "drain-file-system", "unmount-file-system"}, tokenCommands...) + addFSSelectorFlags(commands, selectorExclusions...) addFSAuthFlags(commands, "create-file-system", "list-file-systems", @@ -893,11 +903,262 @@ func newFSCommand(info version.Info) *cobra.Command { "import-file-system-token", "drain-file-system", "unmount-file-system", + "generate-file-system-token", + "list-file-system-tokens", + "enable-file-system-token", + "disable-file-system-token", + "delete-file-system-token", + "refresh-file-system-token", ) cmd.AddCommand(commands...) return cmd } +func newFSGenerateFileSystemTokenCommand(info version.Info) *cobra.Command { + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ + Use: "generate-file-system-token", Short: "Generate an owner token for one file system.", Mutation: mutatingCommand, Permission: authz.FSTokenGenerate, + Run: func(ctx commandContext) (any, error) { + service, profile, err := fsTokenTIServiceAndProfile(ctx) + if err != nil { + return nil, err + } + opts, err := fsGenerateTokenOptions(ctx, profile) + if err != nil { + return nil, err + } + return service.Generate(ctx.cmd.Context(), opts) + }, + DryRun: func(ctx commandContext) (dryrun.Result, error) { + service, profile, err := fsTokenTIServiceAndProfile(ctx) + if err != nil { + return dryrun.Result{}, err + } + opts, err := fsGenerateTokenOptions(ctx, profile) + if err != nil { + return dryrun.Result{}, err + } + return service.DryRunGenerate(ctx.CommandPath(), opts) + }, + }, info) + cmd.Flags().String("file-system-id", "", "The file system ID that owns the generated token.") + cmd.Flags().String("token-name", "", "An operational name for the token (maximum 64 bytes).") + cmd.Flags().Duration("ttl", 0, "Token lifetime as a positive duration of whole seconds, up to 365 days.") + cmd.Flags().Bool("no-expiration", false, "Generate an owner token without an expiry.") + cmd.Flags().Bool("store-locally", false, "Select and store the generated token in this profile's local credentials.") + cmd.Flags().Bool("replace", false, "Replace the selected local token; the previous remote token remains active.") + markUsageRequired(cmd, "file-system-id", "token-name") + return cmd +} + +func newFSListFileSystemTokensCommand(info version.Info) *cobra.Command { + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ + Use: "list-file-system-tokens", Short: "List token metadata for one file system.", Mutation: readOnlyCommand, Permission: authz.FSTokenList, + Run: func(ctx commandContext) (any, error) { + service, profile, err := fsTokenTIServiceAndProfile(ctx) + if err != nil { + return nil, err + } + fileSystemID, err := ctx.StringFlag("file-system-id") + if err != nil { + return nil, err + } + offset, err := ctx.Int32Flag("offset") + if err != nil { + return nil, err + } + limit, err := ctx.Int32Flag("limit") + if err != nil { + return nil, err + } + includeExpired, err := ctx.BoolFlag("include-expired") + if err != nil { + return nil, err + } + regionOverride, err := fsExplicitRegionOverride(ctx) + if err != nil { + return nil, err + } + return service.List(ctx.cmd.Context(), tokenmgmt.ListOptions{Profile: profile, FileSystemID: fileSystemID, Offset: int(offset), Limit: int(limit), IncludeExpired: includeExpired, RegionOverride: regionOverride}) + }, + }, info) + cmd.Flags().String("file-system-id", "", "The file system ID whose tokens are listed.") + cmd.Flags().Bool("include-expired", false, "Include expired token metadata.") + cmd.Flags().Int32("offset", 0, "The zero-based token offset.") + cmd.Flags().Int32("limit", tokenmgmt.DefaultListLimit, "The maximum number of tokens to return (maximum 200).") + markUsageRequired(cmd, "file-system-id") + return cmd +} + +func newFSEnableFileSystemTokenCommand(info version.Info) *cobra.Command { + return newFSTokenMutationCommand("enable-file-system-token", "Enable a disabled file system token.", "enable_file_system_token", http.MethodPost, "/v1/tokens//activate", authz.FSTokenEnable, false, info) +} + +func newFSDisableFileSystemTokenCommand(info version.Info) *cobra.Command { + return newFSTokenMutationCommand("disable-file-system-token", "Disable an active file system token.", "disable_file_system_token", http.MethodPost, "/v1/tokens//deactivate", authz.FSTokenDisable, true, info) +} + +func newFSDeleteFileSystemTokenCommand(info version.Info) *cobra.Command { + return newFSTokenMutationCommand("delete-file-system-token", "Permanently revoke a file system token.", "delete_file_system_token", http.MethodDelete, "/v1/tokens/", authz.FSTokenDelete, true, info) +} + +func newFSTokenMutationCommand(use, short, operation, method, path string, permission authz.Permission, mountGuard bool, info version.Info) *cobra.Command { + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ + Use: use, Short: short, Mutation: mutatingCommand, Permission: permission, + Run: func(ctx commandContext) (any, error) { + service, profile, err := fsTokenTIServiceAndProfile(ctx) + if err != nil { + return nil, err + } + opts, err := fsTokenMutationOptions(ctx, profile) + if err != nil { + return nil, err + } + switch permission { + case authz.FSTokenEnable: + return service.Enable(ctx.cmd.Context(), opts) + case authz.FSTokenDisable: + return service.Disable(ctx.cmd.Context(), opts) + case authz.FSTokenDelete: + return service.Delete(ctx.cmd.Context(), opts) + default: + panic("unsupported FS token mutation") + } + }, + DryRun: func(ctx commandContext) (dryrun.Result, error) { + service, profile, err := fsTokenTIServiceAndProfile(ctx) + if err != nil { + return dryrun.Result{}, err + } + opts, err := fsTokenMutationOptions(ctx, profile) + if err != nil { + return dryrun.Result{}, err + } + return service.DryRunMutation(ctx.CommandPath(), operation, method, path, opts, permission, mountGuard) + }, + }, info) + cmd.Flags().String("file-system-id", "", "The file system ID that owns the token.") + cmd.Flags().String("token-id", "", "The immutable token ID.") + markUsageRequired(cmd, "file-system-id", "token-id") + return cmd +} + +func newFSRefreshFileSystemTokenCommand(info version.Info) *cobra.Command { + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ + Use: "refresh-file-system-token", Short: "Rotate the supplied file system token and return its new plaintext once.", Mutation: mutatingCommand, Permission: authz.FSTokenRefresh, + Run: func(ctx commandContext) (any, error) { + service, profile, err := fsTokenLocalServiceAndProfile(ctx) + if err != nil { + return nil, err + } + opts, err := fsRefreshTokenOptions(ctx, profile) + if err != nil { + return nil, err + } + return service.Refresh(ctx.cmd.Context(), opts) + }, + DryRun: func(ctx commandContext) (dryrun.Result, error) { + service, profile, err := fsTokenLocalServiceAndProfile(ctx) + if err != nil { + return dryrun.Result{}, err + } + opts, err := fsRefreshTokenOptions(ctx, profile) + if err != nil { + return dryrun.Result{}, err + } + opts.DryRun = true + return service.DryRunRefresh(ctx.CommandPath(), opts) + }, + }, info) + cmd.Flags().String("file-system-id", "", "Optional file system ID assertion; required when using a locally stored token.") + cmd.Flags().String("fs-token", "", "Current file system token. Default: TI_FS_TOKEN, then the selected local credential.") + cmd.Flags().Duration("ttl", 0, "Optional new lifetime as a positive duration of whole seconds, up to 365 days.") + return cmd +} + +func fsGenerateTokenOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.GenerateOptions, error) { + fileSystemID, err := ctx.StringFlag("file-system-id") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + tokenName, err := ctx.StringFlag("token-name") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + noExpiration, err := ctx.BoolFlag("no-expiration") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + storeLocally, err := ctx.BoolFlag("store-locally") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + replace, err := ctx.BoolFlag("replace") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + var ttl *time.Duration + if ctx.FlagChanged("ttl") { + value, err := ctx.DurationFlag("ttl") + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + ttl = &value + } + regionOverride, err := fsExplicitRegionOverride(ctx) + if err != nil { + return tokenmgmt.GenerateOptions{}, err + } + return tokenmgmt.GenerateOptions{Profile: profile, FileSystemID: fileSystemID, TokenName: tokenName, TTL: ttl, NoExpiration: noExpiration, StoreLocally: storeLocally, Replace: replace, RegionOverride: regionOverride}, nil +} + +func fsTokenMutationOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.MutationOptions, error) { + fileSystemID, err := ctx.StringFlag("file-system-id") + if err != nil { + return tokenmgmt.MutationOptions{}, err + } + tokenID, err := ctx.StringFlag("token-id") + if err != nil { + return tokenmgmt.MutationOptions{}, err + } + regionOverride, err := fsExplicitRegionOverride(ctx) + if err != nil { + return tokenmgmt.MutationOptions{}, err + } + return tokenmgmt.MutationOptions{Profile: profile, FileSystemID: fileSystemID, TokenID: tokenID, RegionOverride: regionOverride}, nil +} + +func fsRefreshTokenOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.RefreshOptions, error) { + fileSystemID, err := ctx.StringFlag("file-system-id") + if err != nil { + return tokenmgmt.RefreshOptions{}, err + } + token, err := ctx.StringFlag("fs-token") + if err != nil { + return tokenmgmt.RefreshOptions{}, err + } + var ttl *time.Duration + if ctx.FlagChanged("ttl") { + value, err := ctx.DurationFlag("ttl") + if err != nil { + return tokenmgmt.RefreshOptions{}, err + } + ttl = &value + } + regionOverride, err := fsExplicitRegionOverride(ctx) + if err != nil { + return tokenmgmt.RefreshOptions{}, err + } + return tokenmgmt.RefreshOptions{Profile: profile, FileSystemID: fileSystemID, Token: token, TokenExplicit: ctx.FlagChanged("fs-token"), RegionOverride: regionOverride, TTL: ttl}, nil +} + +func fsExplicitRegionOverride(ctx commandContext) (string, error) { + if flag := ctx.cmd.Flag("region"); flag != nil && flag.Changed { + return strings.TrimSpace(flag.Value.String()), nil + } + value, _, _, err := envcompat.ResolveNames(nil, "TI_REGION_CODE", envcompat.LegacyNameFor("TI_REGION_CODE")) + return strings.TrimSpace(value), err +} + func addFSSelectorFlags(commands []*cobra.Command, excluded ...string) { skip := make(map[string]struct{}, len(excluded)) for _, name := range excluded { @@ -2383,6 +2644,32 @@ func fsLocalServiceAndProfile(ctx commandContext) (tifs.Service, *config.Profile return fsService(ctx, profile) } +func fsTokenTIServiceAndProfile(ctx commandContext) (tokenmgmt.Service, *config.Profile, error) { + profile, err := ctx.LoadProfile() + if err != nil { + return tokenmgmt.Service{}, nil, err + } + return fsTokenService(ctx, profile) +} + +func fsTokenLocalServiceAndProfile(ctx commandContext) (tokenmgmt.Service, *config.Profile, error) { + profile, err := ctx.LoadLocalProfile() + if err != nil { + return tokenmgmt.Service{}, nil, err + } + return fsTokenService(ctx, profile) +} + +func fsTokenService(ctx commandContext, profile *config.Profile) (tokenmgmt.Service, *config.Profile, error) { + debug, err := ctx.BoolFlag("debug") + if err != nil { + return tokenmgmt.Service{}, nil, err + } + return tokenmgmt.Service{ + Timeout: 30 * time.Second, Debug: debug, DebugWriter: ctx.cmd.ErrOrStderr(), HomeDir: profile.HomeDir, + }, profile, nil +} + func fsService(ctx commandContext, profile *config.Profile) (tifs.Service, *config.Profile, error) { debug, err := ctx.BoolFlag("debug") if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index a6764d4..6f5ed5d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -602,6 +602,11 @@ func newControlPlaneCommand(spec controlPlaneCommandSpec, info version.Info) *co } result, err := run(ctx) if err != nil { + if partial, ok := err.(interface{ StructuredResult() any }); ok { + if renderErr := renderStructured(cmd, partial.StructuredResult()); renderErr != nil { + return renderErr + } + } return err } return renderStructured(cmd, result) diff --git a/internal/config/profile.go b/internal/config/profile.go index f0c3338..c7ac4c6 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -37,6 +37,9 @@ type Profile struct { FSCloudProvider string FSRegionCode string FSAPIKey string + FSTokenID string + FSTokenScopeKind string + FSTokenName string } func Load(ctx context.Context, opts LoadOptions) (*Profile, error) { diff --git a/internal/fs/drive9_companion.go b/internal/fs/drive9_companion.go index 89751d0..83c18f0 100644 --- a/internal/fs/drive9_companion.go +++ b/internal/fs/drive9_companion.go @@ -1512,6 +1512,7 @@ func (s Service) writeDrive9MountLocator(profile *config.Profile, mountPath, kin if err != nil { return apperr.Wrap("fs.write_mount_locator", "runtime", 1, "construct ti fs mount locator", err) } + locator = locator.WithTokenCorrelation(profile.FSTenantID, profile.FSTokenID, fsTokenFingerprint(profile.FSAPIKey)) if _, err := mountlocator.Write(homeDir, locator); err != nil { return apperr.Wrap("fs.write_mount_locator", "runtime", 1, "write ti fs mount locator", err) } diff --git a/internal/fs/fscred/credential.go b/internal/fs/fscred/credential.go index 1325c24..cf7739d 100644 --- a/internal/fs/fscred/credential.go +++ b/internal/fs/fscred/credential.go @@ -1,6 +1,7 @@ package fscred import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -8,8 +9,10 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" + "time" "github.com/pelletier/go-toml/v2" "github.com/tidbcloud/ti-cli/internal/apperr" @@ -27,10 +30,14 @@ const ( var migrationMu sync.Mutex type Credential struct { - FileSystemID string `json:"file_system_id" toml:"file_system_id"` - RegionCode string `json:"region_code" toml:"region_code"` - HasLocalToken bool `json:"has_local_token" toml:"-"` - APIKey string `json:"-" toml:"api_key"` + FileSystemID string `json:"file_system_id" toml:"file_system_id"` + RegionCode string `json:"region_code" toml:"region_code"` + HasLocalToken bool `json:"has_local_token" toml:"-"` + APIKey string `json:"-" toml:"api_key"` + TokenID string `json:"token_id,omitempty" toml:"token_id,omitempty"` + ScopeKind string `json:"scope_kind,omitempty" toml:"scope_kind,omitempty"` + TokenName string `json:"token_name,omitempty" toml:"token_name,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty" toml:"expires_at,omitempty"` } type CredentialPaths struct { @@ -54,18 +61,26 @@ type ResolveCredentialOptions struct { } func StoreCredential(homeDir string, profile *config.Profile, fileSystemID, regionCode, apiKey string, replace bool) (Credential, error) { + return StoreCredentialRecord(homeDir, profile, Credential{ + FileSystemID: fileSystemID, + RegionCode: regionCode, + APIKey: apiKey, + }, replace) +} + +func StoreCredentialRecord(homeDir string, profile *config.Profile, credential Credential, replace bool) (Credential, error) { if profile == nil { return Credential{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") } - fileSystemID, err := ValidateFileSystemID(fileSystemID) + fileSystemID, err := ValidateFileSystemID(credential.FileSystemID) if err != nil { return Credential{}, err } - apiKey = strings.TrimSpace(apiKey) + apiKey := strings.TrimSpace(credential.APIKey) if apiKey == "" { return Credential{}, apperr.New("fs.missing_token", "authentication", 3, "authentication required: missing FS token") } - placementCode := strings.TrimSpace(regionCode) + placementCode := strings.TrimSpace(credential.RegionCode) if placementCode == "" { placementCode = strings.TrimSpace(profile.PlacementRegionCode) } @@ -73,9 +88,19 @@ func StoreCredential(homeDir string, profile *config.Profile, fileSystemID, regi if err != nil { return Credential{}, apperr.Wrap("config.invalid_region", "config", 2, err.Error(), err) } - credential := Credential{FileSystemID: fileSystemID, RegionCode: placement.Code, HasLocalToken: true, APIKey: apiKey} + credential.FileSystemID = fileSystemID + credential.RegionCode = placement.Code + credential.HasLocalToken = true + credential.APIKey = apiKey + credential.TokenID = strings.TrimSpace(credential.TokenID) + credential.ScopeKind = strings.TrimSpace(credential.ScopeKind) + credential.TokenName = strings.TrimSpace(credential.TokenName) + if credential.ExpiresAt != nil { + expiresAt := credential.ExpiresAt.UTC() + credential.ExpiresAt = &expiresAt + } if existing, getErr := GetCredential(homeDir, profile.Name, fileSystemID); getErr == nil { - if existing.RegionCode == credential.RegionCode && existing.APIKey == credential.APIKey { + if credentialsEqual(existing, credential) { return existing, nil } if !replace { @@ -104,6 +129,100 @@ func StoreCredential(homeDir string, profile *config.Profile, fileSystemID, regi return stored, nil } +func WithCredentialLock(ctx context.Context, homeDir, profileName, fileSystemID string, fn func() error) error { + dir, err := credentialDir(homeDir, profileName, fileSystemID) + if err != nil { + return err + } + profileDir := credentialProfileDir(homeDir, profileName) + if err := os.MkdirAll(profileDir, 0o700); err != nil { + return err + } + if err := os.Chmod(profileDir, 0o700); err != nil { + return err + } + lockPath := filepath.Join(profileDir, "."+filepath.Base(dir)+".credentials.lock") + for { + lock, lockErr := os.OpenFile(lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if lockErr == nil { + _, _ = lock.WriteString(strconv.Itoa(os.Getpid()) + "\n" + time.Now().UTC().Format(time.RFC3339Nano) + "\n") + _ = lock.Sync() + _ = lock.Close() + defer os.Remove(lockPath) + return fn() + } + if !errors.Is(lockErr, os.ErrExist) { + return lockErr + } + if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > 10*time.Minute { + _ = os.Remove(lockPath) + continue + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +func WriteRecoveryCredential(homeDir, profileName string, credential Credential) (string, error) { + dir, err := credentialDir(homeDir, profileName, credential.FileSystemID) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + path := filepath.Join(dir, ".credentials.recovery") + if err := writeTOML(path, credential, 0o600); err != nil { + return "", err + } + return path, nil +} + +func CommitRecoveryCredential(homeDir, profileName, fileSystemID, recoveryPath string) error { + paths, err := CredentialPath(homeDir, profileName, fileSystemID) + if err != nil { + return err + } + if filepath.Dir(paths.Credentials) != filepath.Dir(recoveryPath) { + return credentialError("fs.credential_store_failed", profileName, fileSystemID, "recovery file is outside the credential directory") + } + if err := os.Rename(recoveryPath, paths.Credentials); err != nil { + return err + } + return os.Chmod(paths.Credentials, 0o600) +} + +func DeleteCredentialIfTokenID(homeDir, profileName, fileSystemID, tokenID string) (bool, string, error) { + credential, err := GetCredential(homeDir, profileName, fileSystemID) + if err != nil { + if apperr.CodeFor(err) == "fs.credential_not_found" { + return false, "local_credentials_not_found", nil + } + return false, "", err + } + if credential.TokenID == "" { + return false, "local_token_id_unknown", nil + } + if credential.TokenID != strings.TrimSpace(tokenID) { + return false, "local_token_id_mismatch", nil + } + removed, err := DeleteCredential(homeDir, profileName, fileSystemID) + return removed, "", err +} + +func credentialsEqual(left, right Credential) bool { + if left.FileSystemID != right.FileSystemID || left.RegionCode != right.RegionCode || left.APIKey != right.APIKey || left.TokenID != right.TokenID || left.ScopeKind != right.ScopeKind || left.TokenName != right.TokenName { + return false + } + if left.ExpiresAt == nil || right.ExpiresAt == nil { + return left.ExpiresAt == nil && right.ExpiresAt == nil + } + return left.ExpiresAt.Equal(*right.ExpiresAt) +} + func GetCredential(homeDir, profileName, fileSystemID string) (Credential, error) { fileSystemID, err := ValidateFileSystemID(fileSystemID) if err != nil { @@ -224,6 +343,37 @@ func PrepareCredentialStore(homeDir, profileName string) error { return nil } +func PrepareCredentialTarget(homeDir, profileName, fileSystemID string) error { + if err := PrepareCredentialStore(homeDir, profileName); err != nil { + return err + } + dir, err := credentialDir(homeDir, profileName, fileSystemID) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("prepare ti fs credential directory: %w", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + return fmt.Errorf("restrict ti fs credential directory: %w", err) + } + probe, err := os.CreateTemp(dir, ".credential-write-probe-*") + if err != nil { + return fmt.Errorf("verify ti fs credential target is writable: %w", err) + } + probePath := probe.Name() + defer os.Remove(probePath) + if err := probe.Chmod(0o600); err != nil { + _ = probe.Close() + return err + } + if err := probe.Sync(); err != nil { + _ = probe.Close() + return err + } + return probe.Close() +} + func ResolveCredential(homeDir string, profile *config.Profile, opts ResolveCredentialOptions) (*config.Profile, Credential, error) { if profile == nil { return nil, Credential{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") @@ -330,6 +480,11 @@ func ResolveCredential(homeDir string, profile *config.Profile, opts ResolveCred selected.FSCloudProvider = placement.Provider selected.FSRegionCode = placement.NativeCode selected.FSAPIKey = token + if found && token == credential.APIKey { + selected.FSTokenID = credential.TokenID + selected.FSTokenScopeKind = credential.ScopeKind + selected.FSTokenName = credential.TokenName + } return &selected, credential, nil } diff --git a/internal/fs/fscred/credential_test.go b/internal/fs/fscred/credential_test.go index d5e06f4..307f570 100644 --- a/internal/fs/fscred/credential_test.go +++ b/internal/fs/fscred/credential_test.go @@ -1,12 +1,14 @@ package fscred import ( + "context" "encoding/base64" "encoding/json" "os" "path/filepath" "strings" "testing" + "time" "github.com/tidbcloud/ti-cli/internal/apperr" "github.com/tidbcloud/ti-cli/internal/config" @@ -51,6 +53,84 @@ func TestCredentialStoreAndResolveByID(t *testing.T) { } } +func TestCredentialOptionalTokenMetadataAndExactDelete(t *testing.T) { + home := t.TempDir() + profile := credentialTestProfile() + expiresAt := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + stored, err := StoreCredentialRecord(home, profile, Credential{ + FileSystemID: "tenant-meta", RegionCode: "aws-us-east-1", APIKey: wrappedToken(t, "tenant-meta"), + TokenID: "token-id", ScopeKind: "owner", TokenName: "local-owner", ExpiresAt: &expiresAt, + }, false) + if err != nil { + t.Fatal(err) + } + if stored.TokenID != "token-id" || stored.ScopeKind != "owner" || stored.TokenName != "local-owner" || stored.ExpiresAt == nil || !stored.ExpiresAt.Equal(expiresAt) { + t.Fatalf("stored metadata = %#v", stored) + } + if removed, reason, err := DeleteCredentialIfTokenID(home, profile.Name, "tenant-meta", "other"); err != nil || removed || reason != "local_token_id_mismatch" { + t.Fatalf("mismatched delete = %v %q %v", removed, reason, err) + } + if removed, reason, err := DeleteCredentialIfTokenID(home, profile.Name, "tenant-meta", "token-id"); err != nil || !removed || reason != "" { + t.Fatalf("matching delete = %v %q %v", removed, reason, err) + } +} + +func TestOldCredentialWithoutTokenMetadataRemainsReadable(t *testing.T) { + home := t.TempDir() + profile := credentialTestProfile() + if _, err := StoreCredential(home, profile, "tenant-old", "aws-us-east-1", wrappedToken(t, "tenant-old"), false); err != nil { + t.Fatal(err) + } + credential, err := GetCredential(home, profile.Name, "tenant-old") + if err != nil { + t.Fatal(err) + } + if credential.TokenID != "" || credential.ScopeKind != "" || credential.TokenName != "" || credential.ExpiresAt != nil { + t.Fatalf("legacy credential gained guessed metadata: %#v", credential) + } + removed, reason, err := DeleteCredentialIfTokenID(home, profile.Name, "tenant-old", "token-id") + if err != nil || removed || reason != "local_token_id_unknown" { + t.Fatalf("legacy delete = %v %q %v", removed, reason, err) + } +} + +func TestCredentialLockSerializesWriters(t *testing.T) { + home := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + entered := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- WithCredentialLock(ctx, home, "stage", "tenant-lock", func() error { + close(entered) + <-release + return nil + }) + }() + <-entered + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + secondDone <- WithCredentialLock(ctx, home, "stage", "tenant-lock", func() error { + close(secondEntered) + return nil + }) + }() + select { + case <-secondEntered: + t.Fatal("second writer entered while first lock was held") + case <-time.After(100 * time.Millisecond): + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + func TestResolveCredentialDerivesIDFromExplicitToken(t *testing.T) { profile := credentialTestProfile() token := wrappedToken(t, "tenant-token") diff --git a/internal/fs/mountlocator/locator.go b/internal/fs/mountlocator/locator.go index 90c0104..703b33f 100644 --- a/internal/fs/mountlocator/locator.go +++ b/internal/fs/mountlocator/locator.go @@ -13,13 +13,16 @@ import ( const schema = "ti.fs.mount-locator/v1" type Locator struct { - Schema string `json:"schema"` - Profile string `json:"profile"` - FileSystemName string `json:"file_system_name"` - RegionCode string `json:"region_code"` - CompanionHome string `json:"companion_home"` - MountPath string `json:"mount_path"` - Kind string `json:"kind,omitempty"` + Schema string `json:"schema"` + Profile string `json:"profile"` + FileSystemName string `json:"file_system_name"` + RegionCode string `json:"region_code"` + CompanionHome string `json:"companion_home"` + MountPath string `json:"mount_path"` + Kind string `json:"kind,omitempty"` + FileSystemID string `json:"file_system_id,omitempty"` + TokenID string `json:"token_id,omitempty"` + TokenFingerprint string `json:"token_fingerprint,omitempty"` } func New(profile, fileSystemName, regionCode, companionHome, mountPath, kind string) (Locator, error) { @@ -47,6 +50,43 @@ func New(profile, fileSystemName, regionCode, companionHome, mountPath, kind str }, nil } +func (l Locator) WithTokenCorrelation(fileSystemID, tokenID, fingerprint string) Locator { + l.FileSystemID = strings.TrimSpace(fileSystemID) + l.TokenID = strings.TrimSpace(tokenID) + l.TokenFingerprint = strings.TrimSpace(fingerprint) + return l +} + +func List(homeDir string) ([]Locator, error) { + dir := filepath.Join(homeDir, ".ti", "mounts") + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []Locator{}, nil + } + if err != nil { + return nil, err + } + locators := make([]Locator, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".locator.json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + var locator Locator + if err := json.Unmarshal(data, &locator); err != nil { + return nil, err + } + if locator.Schema != schema || locator.FileSystemName == "" || locator.MountPath == "" { + continue + } + locators = append(locators, locator) + } + return locators, nil +} + func CanonicalMountPath(mountPath string) (string, error) { mountPath = strings.TrimSpace(mountPath) if mountPath == "" { diff --git a/internal/fs/mountlocator/locator_test.go b/internal/fs/mountlocator/locator_test.go index be1e82e..26587c1 100644 --- a/internal/fs/mountlocator/locator_test.go +++ b/internal/fs/mountlocator/locator_test.go @@ -67,3 +67,42 @@ func TestReadRejectsIncompleteLocator(t *testing.T) { t.Fatal("expected incomplete locator to fail") } } + +func TestTokenCorrelationAndLegacyListCompatibility(t *testing.T) { + home := t.TempDir() + legacy, err := New("default", "tenant-old", "aws-us-east-1", "/tmp/legacy-home", t.TempDir(), "fs") + if err != nil { + t.Fatal(err) + } + if _, err := Write(home, legacy); err != nil { + t.Fatal(err) + } + correlated, err := New("default", "tenant-new", "aws-us-east-1", "/tmp/new-home", t.TempDir(), "fs") + if err != nil { + t.Fatal(err) + } + correlated = correlated.WithTokenCorrelation("tenant-new", "token-id", "0123456789abcdef0123456789abcdef") + path, err := Write(home, correlated) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "drive9_") || !strings.Contains(string(data), `"token_id": "token-id"`) { + t.Fatalf("correlated locator = %s", data) + } + locators, err := List(home) + if err != nil { + t.Fatal(err) + } + if len(locators) != 2 { + t.Fatalf("locators = %#v", locators) + } + for _, locator := range locators { + if locator.FileSystemName == "tenant-old" && (locator.TokenID != "" || locator.TokenFingerprint != "") { + t.Fatalf("legacy locator gained correlation: %#v", locator) + } + } +} diff --git a/internal/fs/token_fingerprint.go b/internal/fs/token_fingerprint.go new file mode 100644 index 0000000..31bfec0 --- /dev/null +++ b/internal/fs/token_fingerprint.go @@ -0,0 +1,15 @@ +package fs + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +func fsTokenFingerprint(token string) string { + if strings.TrimSpace(token) == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:16]) +} diff --git a/internal/fs/tokenmgmt/service.go b/internal/fs/tokenmgmt/service.go new file mode 100644 index 0000000..9e959b3 --- /dev/null +++ b/internal/fs/tokenmgmt/service.go @@ -0,0 +1,728 @@ +package tokenmgmt + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "text/tabwriter" + "time" + + "github.com/tidbcloud/ti-cli/internal/api" + "github.com/tidbcloud/ti-cli/internal/api/endpoints" + apifs "github.com/tidbcloud/ti-cli/internal/api/fs" + apitransport "github.com/tidbcloud/ti-cli/internal/api/transport" + "github.com/tidbcloud/ti-cli/internal/apperr" + "github.com/tidbcloud/ti-cli/internal/auth" + "github.com/tidbcloud/ti-cli/internal/authz" + "github.com/tidbcloud/ti-cli/internal/config" + "github.com/tidbcloud/ti-cli/internal/config/envcompat" + "github.com/tidbcloud/ti-cli/internal/config/region" + "github.com/tidbcloud/ti-cli/internal/dryrun" + "github.com/tidbcloud/ti-cli/internal/fs/fscred" + "github.com/tidbcloud/ti-cli/internal/fs/mountlocator" +) + +const ( + DefaultListLimit = 50 + MaxListLimit = 200 + MaxTTL = 365 * 24 * time.Hour +) + +type Service struct { + Resolver endpoints.Resolver + HTTPClient *http.Client + Transport http.RoundTripper + Timeout time.Duration + Debug bool + DebugWriter io.Writer + HomeDir string + + storeCredential func(string, *config.Profile, fscred.Credential, bool) (fscred.Credential, error) + writeRecovery func(string, string, fscred.Credential) (string, error) + commitRecovery func(string, string, string, string) error +} + +type GenerateOptions struct { + Profile *config.Profile + FileSystemID string + TokenName string + TTL *time.Duration + NoExpiration bool + StoreLocally bool + Replace bool + RegionOverride string +} + +type ListOptions struct { + Profile *config.Profile + FileSystemID string + IncludeExpired bool + Offset int + Limit int + RegionOverride string +} + +type MutationOptions struct { + Profile *config.Profile + FileSystemID string + TokenID string + RegionOverride string +} + +type RefreshOptions struct { + Profile *config.Profile + FileSystemID string + Token string + TokenExplicit bool + RegionOverride string + TTL *time.Duration + DryRun bool +} + +type GenerateResult struct { + FileSystemID string `json:"file_system_id"` + TokenID string `json:"token_id"` + TokenName string `json:"token_name"` + ScopeKind string `json:"scope_kind"` + Status string `json:"status"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + FSToken string `json:"fs_token"` + CredentialsStored bool `json:"credentials_stored"` + PreviousTokenNote string `json:"previous_token_note,omitempty"` +} + +type TokenMetadata struct { + TokenID string `json:"token_id"` + TokenName string `json:"token_name"` + ScopeKind string `json:"scope_kind"` + Status string `json:"status"` + Expired bool `json:"expired"` + IssuedByProvider string `json:"issued_by_provider,omitempty"` + IssuedBySubjectKey string `json:"issued_by_subject_key,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListResult struct { + FileSystemID string `json:"file_system_id"` + Tokens []TokenMetadata `json:"tokens"` + NextOffset *int `json:"next_offset,omitempty"` +} + +type MutationResult struct { + FileSystemID string `json:"file_system_id"` + TokenID string `json:"token_id"` + Status string `json:"status"` + LocalCredentialsUpdated bool `json:"local_credentials_updated"` + LocalCredentialsReason string `json:"local_credentials_reason,omitempty"` + CacheConvergenceNote string `json:"cache_convergence_note"` +} + +type RefreshResult struct { + FileSystemID string `json:"file_system_id"` + TokenID string `json:"token_id"` + ScopeKind string `json:"scope_kind"` + ExpiresAt *time.Time `json:"expires_at"` + FSToken string `json:"fs_token"` + CredentialsStored bool `json:"credentials_stored"` + RecoveryPath string `json:"recovery_path,omitempty"` +} + +type PartialResultError struct { + Code string + Message string + Result any +} + +func (e *PartialResultError) Error() string { return e.Message } +func (e *PartialResultError) StructuredResult() any { return e.Result } +func (e *PartialResultError) AppError() *apperr.Error { + return apperr.New(e.Code, "runtime", 1, e.Message) +} + +func (s Service) Generate(ctx context.Context, opts GenerateOptions) (GenerateResult, error) { + fileSystemID, tokenName, ttlSeconds, err := validateGenerate(opts) + if err != nil { + return GenerateResult{}, err + } + if opts.StoreLocally { + var result GenerateResult + err := fscred.WithCredentialLock(ctx, s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID, func() error { + var generateErr error + result, generateErr = s.generate(ctx, opts, fileSystemID, tokenName, ttlSeconds) + return generateErr + }) + return result, err + } + return s.generate(ctx, opts, fileSystemID, tokenName, ttlSeconds) +} + +func (s Service) generate(ctx context.Context, opts GenerateOptions, fileSystemID, tokenName string, ttlSeconds *int64) (GenerateResult, error) { + homeDir := s.homeDir(opts.Profile) + if opts.StoreLocally { + if err := fscred.PrepareCredentialTarget(homeDir, profileName(opts.Profile), fileSystemID); err != nil { + return GenerateResult{}, apperr.Wrap("fs.token_store_preflight", "config", 1, "prepare local FS token storage", err) + } + if _, getErr := fscred.GetCredential(homeDir, profileName(opts.Profile), fileSystemID); getErr == nil && !opts.Replace { + return GenerateResult{}, apperr.New("fs.token_local_conflict", "config", 2, fmt.Sprintf("a local token is already stored for file system %q; add --replace to select the new token locally", fileSystemID)) + } else if getErr != nil && apperr.CodeFor(getErr) != "fs.credential_not_found" { + return GenerateResult{}, getErr + } + } + client, creds, endpoint, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenGenerate, "generate a file system token") + if err != nil { + return GenerateResult{}, err + } + response, err := client.GenerateToken(ctx, creds, apifs.GenerateTokenRequest{FileSystemID: fileSystemID, TokenName: tokenName, TTLSeconds: ttlSeconds}) + if err != nil { + return GenerateResult{}, err + } + result := mapGenerate(response) + if !opts.StoreLocally { + return result, nil + } + credential := fscred.Credential{ + FileSystemID: fileSystemID, + RegionCode: endpoint.RegionName, + APIKey: response.Token, + TokenID: response.TokenID, + ScopeKind: response.ScopeKind, + TokenName: response.TokenName, + ExpiresAt: response.ExpiresAt, + } + if _, storeErr := s.storeCredentialRecord(homeDir, opts.Profile, credential, opts.Replace); storeErr != nil { + _, rollbackErr := client.DeleteToken(ctx, creds, fileSystemID, response.TokenID) + if rollbackErr == nil { + return GenerateResult{}, apperr.Wrap("fs.token_store_failed", "runtime", 1, "store generated token locally; the generated remote token was revoked", storeErr) + } + result.CredentialsStored = false + return result, &PartialResultError{ + Code: "fs.token_partial_success", + Message: "the token was generated but local storage and remote rollback both failed; preserve fs_token from stdout, then import or revoke it explicitly", + Result: result, + } + } + result.CredentialsStored = true + if opts.Replace { + result.PreviousTokenNote = "the previously selected remote token remains active until explicitly disabled or deleted" + } + return result, nil +} + +func (s Service) List(ctx context.Context, opts ListOptions) (ListResult, error) { + fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) + if err != nil { + return ListResult{}, err + } + if opts.Offset < 0 { + return ListResult{}, apperr.New("fs.invalid_token_offset", "usage", 2, "--offset must be non-negative") + } + if opts.Limit <= 0 || opts.Limit > MaxListLimit { + return ListResult{}, apperr.New("fs.invalid_token_limit", "usage", 2, fmt.Sprintf("--limit must be between 1 and %d", MaxListLimit)) + } + client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenList, "list file system tokens") + if err != nil { + return ListResult{}, err + } + response, err := client.ListTokens(ctx, creds, apifs.ListTokensOptions{FileSystemID: fileSystemID, IncludeExpired: opts.IncludeExpired, Offset: opts.Offset, Limit: opts.Limit}) + if err != nil { + return ListResult{}, err + } + result := ListResult{FileSystemID: fileSystemID, Tokens: make([]TokenMetadata, 0, len(response.Tokens)), NextOffset: response.NextOffset} + for _, item := range response.Tokens { + if item.FileSystemID != "" && item.FileSystemID != fileSystemID { + return ListResult{}, apperr.New("fs.token_response_mismatch", "api", 1, "token list response contained a different file system ID") + } + result.Tokens = append(result.Tokens, TokenMetadata{ + TokenID: item.TokenID, TokenName: item.TokenName, ScopeKind: item.ScopeKind, Status: item.Status, Expired: item.Expired, + IssuedByProvider: item.IssuedByProvider, IssuedBySubjectKey: item.IssuedBySubjectKey, + IssuedAt: item.IssuedAt, ExpiresAt: item.ExpiresAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, + }) + } + return result, nil +} + +func (s Service) Enable(ctx context.Context, opts MutationOptions) (MutationResult, error) { + return s.setEnabled(ctx, opts, true) +} + +func (s Service) Disable(ctx context.Context, opts MutationOptions) (MutationResult, error) { + return s.setEnabled(ctx, opts, false) +} + +func (s Service) setEnabled(ctx context.Context, opts MutationOptions, enabled bool) (MutationResult, error) { + fileSystemID, tokenID, err := validateMutation(opts) + if err != nil { + return MutationResult{}, err + } + if !enabled { + if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { + return MutationResult{}, err + } + } + permission, action := authz.FSTokenDisable, "disable a file system token" + if enabled { + permission, action = authz.FSTokenEnable, "enable a file system token" + } + client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, action) + if err != nil { + return MutationResult{}, err + } + response, err := client.SetTokenEnabled(ctx, creds, fileSystemID, tokenID, enabled) + if err != nil { + return MutationResult{}, err + } + return MutationResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, Status: response.Status, CacheConvergenceNote: "authentication changes can take approximately 10 seconds to converge"}, nil +} + +func (s Service) Delete(ctx context.Context, opts MutationOptions) (MutationResult, error) { + fileSystemID, tokenID, err := validateMutation(opts) + if err != nil { + return MutationResult{}, err + } + if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { + return MutationResult{}, err + } + client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenDelete, "delete a file system token") + if err != nil { + return MutationResult{}, err + } + response, err := client.DeleteToken(ctx, creds, fileSystemID, tokenID) + if err != nil { + return MutationResult{}, err + } + var updated bool + var reason string + cleanupErr := fscred.WithCredentialLock(ctx, s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID, func() error { + var err error + updated, reason, err = fscred.DeleteCredentialIfTokenID(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID, tokenID) + return err + }) + if cleanupErr != nil { + return MutationResult{}, apperr.Wrap("fs.token_local_cleanup", "runtime", 1, "remote token was revoked but local credential cleanup failed", cleanupErr) + } + return MutationResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, Status: response.Status, LocalCredentialsUpdated: updated, LocalCredentialsReason: reason, CacheConvergenceNote: "authentication changes can take approximately 10 seconds to converge"}, nil +} + +func (s Service) Refresh(ctx context.Context, opts RefreshOptions) (RefreshResult, error) { + resolved, err := s.resolveRefresh(opts) + if err != nil { + return RefreshResult{}, err + } + if err := s.guardTokenFingerprintMount(resolved.fileSystemID, tokenFingerprint(resolved.token)); err != nil { + return RefreshResult{}, err + } + if !resolved.local { + return s.refreshRemote(ctx, opts.Profile, resolved, opts.TTL) + } + var result RefreshResult + err = fscred.WithCredentialLock(ctx, s.homeDir(opts.Profile), profileName(opts.Profile), resolved.fileSystemID, func() error { + current, err := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), resolved.fileSystemID) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(current.APIKey), []byte(resolved.token)) != 1 { + return apperr.New("fs.token_local_changed", "runtime", 1, "the selected local token changed before refresh; retry with the current credential") + } + if err := fscred.PrepareCredentialTarget(s.homeDir(opts.Profile), profileName(opts.Profile), resolved.fileSystemID); err != nil { + return apperr.Wrap("fs.token_store_preflight", "config", 1, "prepare local FS token recovery storage", err) + } + if err := s.guardTokenFingerprintMount(resolved.fileSystemID, tokenFingerprint(current.APIKey)); err != nil { + return err + } + result, err = s.refreshRemote(ctx, opts.Profile, resolved, opts.TTL) + if err != nil { + return err + } + credential := current + credential.APIKey = result.FSToken + credential.TokenID = result.TokenID + credential.ScopeKind = result.ScopeKind + credential.ExpiresAt = result.ExpiresAt + recoveryPath, writeErr := s.writeRecoveryCredential(s.homeDir(opts.Profile), profileName(opts.Profile), credential) + if writeErr != nil { + result.CredentialsStored = false + return &PartialResultError{Code: "fs.token_partial_success", Message: "the token was refreshed but the new credential could not be written; preserve fs_token from stdout and import it explicitly", Result: result} + } + result.RecoveryPath = recoveryPath + if commitErr := s.commitRecoveryCredential(s.homeDir(opts.Profile), profileName(opts.Profile), resolved.fileSystemID, recoveryPath); commitErr != nil { + result.CredentialsStored = false + return &PartialResultError{Code: "fs.token_partial_success", Message: fmt.Sprintf("the token was refreshed but final credential replacement failed; recovery state remains at %s", recoveryPath), Result: result} + } + result.CredentialsStored = true + result.RecoveryPath = "" + return nil + }) + return result, err +} + +func (s Service) refreshRemote(ctx context.Context, profile *config.Profile, resolved refreshInput, ttl *time.Duration) (RefreshResult, error) { + ttlSeconds, err := optionalTTLSeconds(ttl) + if err != nil { + return RefreshResult{}, err + } + endpoint, err := s.resolveEndpoint(profile, resolved.regionCode) + if err != nil { + return RefreshResult{}, err + } + raw, err := api.NewBearerClient(profileName(profile), resolved.token, endpoint, authz.FSTokenRefresh, api.Options{ + Action: "refresh a file system token", HTTPClient: s.HTTPClient, Transport: s.Transport, Timeout: s.Timeout, + Debug: s.Debug, DebugWriter: s.DebugWriter, UserAgent: "ti fs token management", MaxRetries: -1, + }) + if err != nil { + return RefreshResult{}, err + } + response, err := apifs.New(raw).RefreshToken(ctx, apifs.RefreshTokenRequest{TTLSeconds: ttlSeconds}) + if err != nil { + if apperr.CodeFor(err) == "api.network_error" { + return RefreshResult{}, apperr.Wrap("fs.token_refresh_ambiguous", "api", 1, "token refresh may have committed but its response was lost; do not retry with the old token, generate another owner token or inspect local recovery state", err) + } + return RefreshResult{}, err + } + if response.FileSystemID != resolved.fileSystemID { + return RefreshResult{}, apperr.New("fs.token_response_mismatch", "api", 1, "refresh response returned a different file system ID") + } + return RefreshResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, ScopeKind: response.ScopeKind, ExpiresAt: response.ExpiresAt, FSToken: response.Token}, nil +} + +type refreshInput struct { + fileSystemID string + token string + regionCode string + local bool +} + +func (s Service) resolveRefresh(opts RefreshOptions) (refreshInput, error) { + if opts.Profile == nil { + return refreshInput{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") + } + token := strings.TrimSpace(opts.Token) + sourceLocal := false + if opts.TokenExplicit && token == "" { + return refreshInput{}, apperr.New("fs.empty_token", "usage", 2, "--fs-token cannot be empty") + } + if token == "" { + envToken, _, _, err := envcompat.ResolveNames(nil, "TI_FS_TOKEN", envcompat.LegacyNameFor("TI_FS_TOKEN")) + if err != nil { + return refreshInput{}, err + } + token = strings.TrimSpace(envToken) + } + fileSystemID := strings.TrimSpace(opts.FileSystemID) + if token == "" { + if fileSystemID == "" { + return refreshInput{}, apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required when refresh uses a locally stored token") + } + credential, err := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID) + if err != nil { + return refreshInput{}, err + } + token = credential.APIKey + sourceLocal = true + } + tokenFileSystemID, err := fscred.FileSystemIDFromToken(token) + if err != nil { + return refreshInput{}, err + } + if fileSystemID == "" { + fileSystemID = tokenFileSystemID + } else if fileSystemID != tokenFileSystemID { + return refreshInput{}, apperr.New("fs.token_file_system_mismatch", "authentication", 3, fmt.Sprintf("FS token belongs to file system %q, not %q", tokenFileSystemID, fileSystemID)) + } + regionCode := strings.TrimSpace(opts.RegionOverride) + if sourceLocal { + credential, err := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID) + if err != nil { + return refreshInput{}, err + } + if regionCode == "" { + regionCode = credential.RegionCode + } + } + if regionCode == "" { + regionCode = opts.Profile.PlacementRegionCode + } + return refreshInput{fileSystemID: fileSystemID, token: token, regionCode: regionCode, local: sourceLocal}, nil +} + +func (s Service) DryRunGenerate(commandPath string, opts GenerateOptions) (dryrun.Result, error) { + fileSystemID, _, _, err := validateGenerate(opts) + if err != nil { + return dryrun.Result{}, err + } + _, _, endpoint, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenGenerate, "generate a file system token") + if err != nil { + return dryrun.Result{}, err + } + if opts.StoreLocally { + if _, getErr := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID); getErr == nil && !opts.Replace { + return dryrun.Result{}, apperr.New("fs.token_local_conflict", "config", 2, "a local token is already stored; add --replace") + } else if getErr != nil && apperr.CodeFor(getErr) != "fs.credential_not_found" { + return dryrun.Result{}, getErr + } + } + return tokenDryRun(commandPath, "generate_file_system_token", http.MethodPost, "/v1/tokens/generate", fileSystemID, opts.Profile, endpoint, authz.FSTokenGenerate), nil +} + +func (s Service) DryRunMutation(commandPath, operation, method, path string, opts MutationOptions, permission authz.Permission, mountGuard bool) (dryrun.Result, error) { + fileSystemID, tokenID, err := validateMutation(opts) + if err != nil { + return dryrun.Result{}, err + } + if mountGuard { + if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { + return dryrun.Result{}, err + } + } + _, _, endpoint, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, operation) + if err != nil { + return dryrun.Result{}, err + } + return tokenDryRun(commandPath, operation, method, path, fileSystemID, opts.Profile, endpoint, permission), nil +} + +func (s Service) DryRunRefresh(commandPath string, opts RefreshOptions) (dryrun.Result, error) { + resolved, err := s.resolveRefresh(opts) + if err != nil { + return dryrun.Result{}, err + } + if _, err := optionalTTLSeconds(opts.TTL); err != nil { + return dryrun.Result{}, err + } + if err := s.guardTokenFingerprintMount(resolved.fileSystemID, tokenFingerprint(resolved.token)); err != nil { + return dryrun.Result{}, err + } + endpoint, err := s.resolveEndpoint(opts.Profile, resolved.regionCode) + if err != nil { + return dryrun.Result{}, err + } + return tokenDryRun(commandPath, "refresh_file_system_token", http.MethodPost, "/v1/tokens/refresh", resolved.fileSystemID, opts.Profile, endpoint, authz.FSTokenRefresh), nil +} + +func tokenDryRun(commandPath, operation, method, path, fileSystemID string, profile *config.Profile, endpoint endpoints.Endpoint, permission authz.Permission) dryrun.Result { + return dryrun.New(commandPath, operation, dryrun.RequestSummary{Method: method, Path: path, Description: "credentials and token plaintext are redacted"}, + dryrun.Check{Name: "config_and_credentials", Status: "passed", Message: fmt.Sprintf("profile %q loaded", profileName(profile))}, + dryrun.Check{Name: "endpoint_selection", Status: "passed", Message: endpoint.BaseURL}, + dryrun.Check{Name: "file_system_id", Status: "passed", Message: fileSystemID}, + dryrun.Check{Name: "permission_requirement", Status: "passed", Message: string(permission)}) +} + +func (s Service) controlClient(profile *config.Profile, fileSystemID, regionOverride string, permission authz.Permission, action string) (*apifs.Client, apifs.TiDBCloudCredentials, endpoints.Endpoint, error) { + creds, err := auth.ValidateProfile(profile) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + placementCode := strings.TrimSpace(regionOverride) + if placementCode == "" && strings.TrimSpace(fileSystemID) != "" { + if credential, getErr := fscred.GetCredential(s.homeDir(profile), profileName(profile), fileSystemID); getErr == nil { + placementCode = credential.RegionCode + } else if apperr.CodeFor(getErr) != "fs.credential_not_found" { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, getErr + } + } + endpoint, err := s.resolveEndpoint(profile, placementCode) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + raw, err := api.New(api.Options{ + Endpoint: endpoint, ProfileName: creds.ProfileName, Permission: permission, Action: action, + HTTPClient: s.HTTPClient, Transport: s.Transport, Timeout: s.Timeout, Debug: s.Debug, DebugWriter: s.DebugWriter, + Redactor: apiRedactor(creds.PublicKey, creds.PrivateKey), UserAgent: "ti fs token management", + }) + if err != nil { + return nil, apifs.TiDBCloudCredentials{}, endpoints.Endpoint{}, err + } + return apifs.New(raw), apifs.TiDBCloudCredentials{PublicKey: creds.PublicKey, PrivateKey: creds.PrivateKey}, endpoint, nil +} + +func apiRedactor(secrets ...string) apitransport.Redactor { + return apitransport.Redactor{Secrets: secrets} +} + +func (s Service) resolveEndpoint(profile *config.Profile, override string) (endpoints.Endpoint, error) { + placementCode := strings.TrimSpace(override) + if placementCode == "" && profile != nil { + placementCode = profile.PlacementRegionCode + } + if placementCode == "" { + return endpoints.Endpoint{}, apperr.New("fs.missing_region", "config", 2, "ti fs region is required; pass --region, set TI_REGION_CODE, or configure a profile region") + } + placement, err := region.ParsePlacementCode(placementCode) + if err != nil { + return endpoints.Endpoint{}, apperr.Wrap("config.invalid_region", "config", 2, err.Error(), err) + } + resolver := s.Resolver + if resolver.IsZero() { + resolver = endpoints.NewResolver() + } + return resolver.ResolveFS(placement.Provider, placement.NativeCode) +} + +func (s Service) guardTokenIDMount(fileSystemID, tokenID string) error { + locators, err := mountlocator.List(s.homeDir(nil)) + if err != nil { + return apperr.Wrap("fs.mount_inventory", "runtime", 1, "inspect local mount state", err) + } + for _, locator := range locators { + locatorFSID := locator.FileSystemID + if locatorFSID == "" { + locatorFSID = locator.FileSystemName + } + if locatorFSID == fileSystemID && locator.TokenID != "" && locator.TokenID == tokenID { + return activeMountError(locator.MountPath) + } + } + return nil +} + +func (s Service) guardTokenFingerprintMount(fileSystemID, fingerprint string) error { + locators, err := mountlocator.List(s.homeDir(nil)) + if err != nil { + return apperr.Wrap("fs.mount_inventory", "runtime", 1, "inspect local mount state", err) + } + for _, locator := range locators { + locatorFSID := locator.FileSystemID + if locatorFSID == "" { + locatorFSID = locator.FileSystemName + } + if locatorFSID == fileSystemID && locator.TokenFingerprint != "" && locator.TokenFingerprint == fingerprint { + return activeMountError(locator.MountPath) + } + } + return nil +} + +func activeMountError(path string) error { + return apperr.New("fs.token_mount_active", "runtime", 1, fmt.Sprintf("this token is used by the local mount at %s; run `ti fs drain-file-system --mount-path %s` and `ti fs unmount-file-system --mount-path %s` before changing it", path, path, path)) +} + +func validateGenerate(opts GenerateOptions) (string, string, *int64, error) { + fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) + if err != nil { + return "", "", nil, err + } + tokenName := strings.TrimSpace(opts.TokenName) + if tokenName == "" { + return "", "", nil, apperr.New("fs.token_name_required", "usage", 2, "--token-name is required") + } + if len(tokenName) > 64 { + return "", "", nil, apperr.New("fs.invalid_token_name", "usage", 2, "--token-name must be at most 64 bytes") + } + if (opts.TTL != nil) == opts.NoExpiration { + return "", "", nil, apperr.New("fs.token_lifetime_required", "usage", 2, "provide exactly one of --ttl or --no-expiration") + } + if opts.Replace && !opts.StoreLocally { + return "", "", nil, apperr.New("fs.token_replace_without_store", "usage", 2, "--replace requires --store-locally") + } + ttlSeconds, err := optionalTTLSeconds(opts.TTL) + if err != nil { + return "", "", nil, err + } + return fileSystemID, tokenName, ttlSeconds, nil +} + +func validateMutation(opts MutationOptions) (string, string, error) { + fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) + if err != nil { + return "", "", err + } + tokenID := strings.TrimSpace(opts.TokenID) + if tokenID == "" { + return "", "", apperr.New("fs.token_id_required", "usage", 2, "--token-id is required") + } + if strings.ContainsAny(tokenID, "/\\") { + return "", "", apperr.New("fs.invalid_token_id", "usage", 2, "--token-id must be one path-safe identifier") + } + return fileSystemID, tokenID, nil +} + +func optionalTTLSeconds(ttl *time.Duration) (*int64, error) { + if ttl == nil { + return nil, nil + } + if *ttl <= 0 { + return nil, apperr.New("fs.token_ttl_invalid", "usage", 2, "--ttl must be positive") + } + if *ttl > MaxTTL { + return nil, apperr.New("fs.token_ttl_invalid", "usage", 2, "--ttl must not exceed 365 days") + } + if *ttl%time.Second != 0 { + return nil, apperr.New("fs.token_ttl_invalid", "usage", 2, "--ttl must resolve to whole seconds") + } + seconds := int64(*ttl / time.Second) + return &seconds, nil +} + +func mapGenerate(response apifs.GenerateTokenResponse) GenerateResult { + return GenerateResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, TokenName: response.TokenName, ScopeKind: response.ScopeKind, Status: response.Status, IssuedAt: response.IssuedAt, ExpiresAt: response.ExpiresAt, FSToken: response.Token} +} + +func tokenFingerprint(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:16]) +} + +func (s Service) homeDir(profile *config.Profile) string { + if s.HomeDir != "" { + return s.HomeDir + } + if profile != nil && profile.HomeDir != "" { + return profile.HomeDir + } + return "" +} + +func (s Service) storeCredentialRecord(homeDir string, profile *config.Profile, credential fscred.Credential, replace bool) (fscred.Credential, error) { + if s.storeCredential != nil { + return s.storeCredential(homeDir, profile, credential, replace) + } + return fscred.StoreCredentialRecord(homeDir, profile, credential, replace) +} + +func (s Service) writeRecoveryCredential(homeDir, profileName string, credential fscred.Credential) (string, error) { + if s.writeRecovery != nil { + return s.writeRecovery(homeDir, profileName, credential) + } + return fscred.WriteRecoveryCredential(homeDir, profileName, credential) +} + +func (s Service) commitRecoveryCredential(homeDir, profileName, fileSystemID, recoveryPath string) error { + if s.commitRecovery != nil { + return s.commitRecovery(homeDir, profileName, fileSystemID, recoveryPath) + } + return fscred.CommitRecoveryCredential(homeDir, profileName, fileSystemID, recoveryPath) +} + +func profileName(profile *config.Profile) string { + if profile == nil || profile.Name == "" { + return config.DefaultProfile + } + return profile.Name +} + +func (r ListResult) Human() string { + var out strings.Builder + w := tabwriter.NewWriter(&out, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "TOKEN_ID\tNAME\tSCOPE\tSTATUS\tEXPIRES_AT") + for _, token := range r.Tokens { + status := token.Status + if token.Expired { + status = "expired" + } + expiresAt := "never" + if token.ExpiresAt != nil { + expiresAt = token.ExpiresAt.UTC().Format(time.RFC3339) + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", token.TokenID, token.TokenName, token.ScopeKind, status, expiresAt) + } + _ = w.Flush() + return strings.TrimRight(out.String(), "\n") +} diff --git a/internal/fs/tokenmgmt/service_test.go b/internal/fs/tokenmgmt/service_test.go new file mode 100644 index 0000000..9d74bf9 --- /dev/null +++ b/internal/fs/tokenmgmt/service_test.go @@ -0,0 +1,360 @@ +package tokenmgmt + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/tidbcloud/ti-cli/internal/api/endpoints" + "github.com/tidbcloud/ti-cli/internal/apperr" + "github.com/tidbcloud/ti-cli/internal/config" + "github.com/tidbcloud/ti-cli/internal/fs/fscred" + "github.com/tidbcloud/ti-cli/internal/fs/mountlocator" +) + +func TestTokenTTLValidation(t *testing.T) { + t.Parallel() + valid := []time.Duration{time.Second, 24 * time.Hour, MaxTTL} + for _, ttl := range valid { + if _, err := optionalTTLSeconds(&ttl); err != nil { + t.Fatalf("optionalTTLSeconds(%s): %v", ttl, err) + } + } + invalid := []time.Duration{0, -time.Second, time.Millisecond, MaxTTL + time.Second} + for _, ttl := range invalid { + if _, err := optionalTTLSeconds(&ttl); apperr.CodeFor(err) != "fs.token_ttl_invalid" { + t.Fatalf("optionalTTLSeconds(%s) = %v", ttl, err) + } + } + ttl := time.Hour + base := GenerateOptions{FileSystemID: "fs-1", TokenName: "name"} + if _, _, _, err := validateGenerate(base); apperr.CodeFor(err) != "fs.token_lifetime_required" { + t.Fatalf("missing lifetime = %v", err) + } + base.TTL, base.NoExpiration = &ttl, true + if _, _, _, err := validateGenerate(base); apperr.CodeFor(err) != "fs.token_lifetime_required" { + t.Fatalf("both lifetimes = %v", err) + } +} + +func TestGenerateAndListMapBackendFieldsAndStoreMetadata(t *testing.T) { + home := t.TempDir() + newToken := wrappedToken(t, "fs-1", 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-TiDBCloud-Public-Key") != "public" || r.Header.Get("Authorization") != "" { + t.Errorf("unexpected authentication headers: %#v", r.Header) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/tokens/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","key_name":"local-owner","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z","expires_at":"2026-08-13T00:00:00Z"}`)) + case "/v1/tokens": + _, _ = w.Write([]byte(`{"tokens":[{"token_id":"token-1","tenant_id":"fs-1","key_name":"local-owner","scope_kind":"owner","status":"active","expired":false,"issued_at":"2026-08-12T00:00:00Z","expires_at":"2026-08-13T00:00:00Z","created_at":"2026-08-12T00:00:00Z","updated_at":"2026-08-12T00:00:00Z","token":"drive9_malicious"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: wrappedToken(t, "fs-1", 1), TokenID: "token-old", ScopeKind: "owner"}, false); err != nil { + t.Fatal(err) + } + ttl := time.Hour + generated, err := service.Generate(context.Background(), GenerateOptions{Profile: profile, FileSystemID: "fs-1", TokenName: "local-owner", TTL: &ttl, StoreLocally: true, Replace: true}) + if err != nil { + t.Fatal(err) + } + if generated.TokenName != "local-owner" || generated.FSToken != newToken || !generated.CredentialsStored || !strings.Contains(generated.PreviousTokenNote, "remains active") { + t.Fatalf("generated = %#v", generated) + } + credential, err := fscred.GetCredential(home, profile.Name, "fs-1") + if err != nil { + t.Fatal(err) + } + if credential.TokenID != "token-1" || credential.TokenName != "local-owner" || credential.ScopeKind != "owner" || credential.ExpiresAt == nil { + t.Fatalf("credential = %#v", credential) + } + listed, err := service.List(context.Background(), ListOptions{Profile: profile, FileSystemID: "fs-1", Limit: 50}) + if err != nil { + t.Fatal(err) + } + if len(listed.Tokens) != 1 || listed.Tokens[0].TokenName != "local-owner" || strings.Contains(listed.Human(), newToken) { + t.Fatalf("listed = %#v human=%q", listed, listed.Human()) + } + encoded, err := json.Marshal(listed) + if err != nil || strings.Contains(string(encoded), "drive9_malicious") { + t.Fatalf("list output retained unexpected plaintext: %s, %v", encoded, err) + } +} + +func TestGenerateStoreConflictPreventsRemoteMutation(t *testing.T) { + home := t.TempDir() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredential(home, profile, "fs-1", "aws-us-east-1", wrappedToken(t, "fs-1", 1), false); err != nil { + t.Fatal(err) + } + ttl := time.Hour + _, err := service.Generate(context.Background(), GenerateOptions{Profile: profile, FileSystemID: "fs-1", TokenName: "next", TTL: &ttl, StoreLocally: true}) + if apperr.CodeFor(err) != "fs.token_local_conflict" || requests != 0 { + t.Fatalf("Generate() err=%v requests=%d", err, requests) + } +} + +func TestGenerateDoesNotStoreByDefault(t *testing.T) { + home := t.TempDir() + token := wrappedToken(t, "fs-1", 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":` + jsonString(token) + `,"token_id":"token-1","tenant_id":"fs-1","key_name":"ci","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z"}`)) + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + ttl := time.Hour + result, err := service.Generate(context.Background(), GenerateOptions{Profile: profile, FileSystemID: "fs-1", TokenName: "ci", TTL: &ttl}) + if err != nil || result.CredentialsStored || result.FSToken != token { + t.Fatalf("Generate() result=%#v err=%v", result, err) + } + if _, err := fscred.GetCredential(home, profile.Name, "fs-1"); apperr.CodeFor(err) != "fs.credential_not_found" { + t.Fatalf("default generation wrote local credentials: %v", err) + } +} + +func TestGenerateStoreFailureRollsBackRemoteToken(t *testing.T) { + home := t.TempDir() + newToken := wrappedToken(t, "fs-1", 2) + deleteRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","key_name":"owner","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z"}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/tokens/token-1": + deleteRequests++ + _, _ = w.Write([]byte(`{"token_id":"token-1","tenant_id":"fs-1","status":"revoked"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + service.storeCredential = func(string, *config.Profile, fscred.Credential, bool) (fscred.Credential, error) { + return fscred.Credential{}, errors.New("injected write failure") + } + ttl := time.Hour + result, err := service.Generate(context.Background(), GenerateOptions{Profile: profile, FileSystemID: "fs-1", TokenName: "owner", TTL: &ttl, StoreLocally: true}) + if apperr.CodeFor(err) != "fs.token_store_failed" || deleteRequests != 1 { + t.Fatalf("Generate() result=%#v err=%v deleteRequests=%d", result, err, deleteRequests) + } + if result.FSToken != "" { + t.Fatalf("rolled-back token leaked in result: %#v", result) + } +} + +func TestGenerateStoreAndRollbackFailureReturnsOneTimeSecret(t *testing.T) { + home := t.TempDir() + newToken := wrappedToken(t, "fs-1", 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","key_name":"owner","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z"}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/tokens/token-1": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"rollback failed"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + service.storeCredential = func(string, *config.Profile, fscred.Credential, bool) (fscred.Credential, error) { + return fscred.Credential{}, errors.New("injected write failure") + } + ttl := time.Hour + result, err := service.Generate(context.Background(), GenerateOptions{Profile: profile, FileSystemID: "fs-1", TokenName: "owner", TTL: &ttl, StoreLocally: true}) + if apperr.CodeFor(err) != "fs.token_partial_success" || result.FSToken != newToken || result.TokenID != "token-1" { + t.Fatalf("Generate() result=%#v err=%v", result, err) + } + partial, ok := err.(*PartialResultError) + if !ok || partial.StructuredResult().(GenerateResult).FSToken != newToken { + t.Fatalf("partial result = %#v", err) + } +} + +func TestLocalRefreshAtomicallyReplacesCredential(t *testing.T) { + home := t.TempDir() + oldToken := wrappedToken(t, "fs-1", 1) + newToken := wrappedToken(t, "fs-1", 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+oldToken || r.Header.Get("X-TiDBCloud-Public-Key") != "" { + t.Errorf("refresh authentication = %#v", r.Header) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: oldToken, TokenID: "token-1", TokenName: "preserved", ScopeKind: "owner"}, false); err != nil { + t.Fatal(err) + } + result, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile, FileSystemID: "fs-1"}) + if err != nil { + t.Fatal(err) + } + if !result.CredentialsStored || result.FSToken != newToken { + t.Fatalf("result = %#v", result) + } + credential, err := fscred.GetCredential(home, profile.Name, "fs-1") + if err != nil { + t.Fatal(err) + } + if credential.APIKey != newToken || credential.TokenName != "preserved" || credential.TokenID != "token-1" { + t.Fatalf("credential = %#v", credential) + } +} + +func TestLocalRefreshRecoveryWriteFailureReturnsNewSecret(t *testing.T) { + home := t.TempDir() + oldToken := wrappedToken(t, "fs-1", 1) + newToken := wrappedToken(t, "fs-1", 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: oldToken, TokenID: "token-1", ScopeKind: "owner"}, false); err != nil { + t.Fatal(err) + } + service.writeRecovery = func(string, string, fscred.Credential) (string, error) { + return "", errors.New("injected recovery write failure") + } + result, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile, FileSystemID: "fs-1"}) + if apperr.CodeFor(err) != "fs.token_partial_success" || result.FSToken != newToken || result.CredentialsStored { + t.Fatalf("Refresh() result=%#v err=%v", result, err) + } + credential, loadErr := fscred.GetCredential(home, profile.Name, "fs-1") + if loadErr != nil || credential.APIKey != oldToken { + t.Fatalf("old credential was not preserved: %#v %v", credential, loadErr) + } +} + +func TestRefreshNetworkFailureIsAmbiguousAndNotRetried(t *testing.T) { + home := t.TempDir() + token := wrappedToken(t, "fs-1", 1) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Fatal("test server does not support hijacking") + } + conn, _, err := hijacker.Hijack() + if err != nil { + t.Errorf("Hijack(): %v", err) + return + } + _ = conn.Close() + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + result, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile, Token: token, TokenExplicit: true}) + if apperr.CodeFor(err) != "fs.token_refresh_ambiguous" || requests != 1 || result.FSToken != "" { + t.Fatalf("Refresh() result=%#v err=%v requests=%d", result, err, requests) + } +} + +func TestRefreshAndDisableRejectMatchingMounts(t *testing.T) { + home := t.TempDir() + token := wrappedToken(t, "fs-1", 1) + service, profile := tokenTestService(home, "http://127.0.0.1:1") + locator, err := mountlocator.New(profile.Name, "fs-1", "aws-us-east-1", t.TempDir(), t.TempDir(), "fs") + if err != nil { + t.Fatal(err) + } + locator = locator.WithTokenCorrelation("fs-1", "token-1", tokenFingerprint(token)) + if _, err := mountlocator.Write(home, locator); err != nil { + t.Fatal(err) + } + if _, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile, FileSystemID: "fs-1", Token: token, TokenExplicit: true}); apperr.CodeFor(err) != "fs.token_mount_active" || !strings.Contains(err.Error(), "drain-file-system") { + t.Fatalf("refresh mount guard = %v", err) + } + if _, err := service.Disable(context.Background(), MutationOptions{Profile: profile, FileSystemID: "fs-1", TokenID: "token-1"}); apperr.CodeFor(err) != "fs.token_mount_active" { + t.Fatalf("disable mount guard = %v", err) + } +} + +func TestEnvironmentRefreshDoesNotRewriteLocalCredential(t *testing.T) { + home := t.TempDir() + localToken := wrappedToken(t, "fs-1", 1) + envToken := wrappedToken(t, "fs-1", 8) + newToken := wrappedToken(t, "fs-1", 9) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+envToken { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-env","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredential(home, profile, "fs-1", "aws-us-east-1", localToken, false); err != nil { + t.Fatal(err) + } + t.Setenv("TI_FS_TOKEN", envToken) + result, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile}) + if err != nil { + t.Fatal(err) + } + if result.CredentialsStored { + t.Fatalf("environment refresh stored credentials: %#v", result) + } + credential, err := fscred.GetCredential(home, profile.Name, "fs-1") + if err != nil || credential.APIKey != localToken { + t.Fatalf("local credential changed: %#v %v", credential, err) + } +} + +func tokenTestService(home, baseURL string) (Service, *config.Profile) { + profile := &config.Profile{ + Name: "test", HomeDir: home, PlacementRegionCode: "aws-us-east-1", CloudProvider: "aws", RegionCode: "us-east-1", + TiDBCloudPublicKey: "public", TiDBCloudPrivateKey: "private", + } + resolver := endpoints.Resolver{FSBaseURLs: map[endpoints.ProviderRegion]string{{Provider: "aws", Region: "us-east-1"}: baseURL}} + return Service{Resolver: resolver, HomeDir: home, Timeout: 2 * time.Second}, profile +} + +func wrappedToken(t *testing.T, fileSystemID string, version int) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payloadBytes, err := json.Marshal(map[string]any{"tenant_id": fileSystemID, "token_version": version}) + if err != nil { + t.Fatal(err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return "drive9_" + base64.RawURLEncoding.EncodeToString([]byte(header+"."+payload+".signature")) +} + +func jsonString(value string) string { + data, _ := json.Marshal(value) + return string(data) +} + +func TestMain(m *testing.M) { + os.Unsetenv("TI_FS_TOKEN") + os.Exit(m.Run()) +} From e20215acab88da173633ef72a7feb0fae4afe576 Mon Sep 17 00:00:00 2001 From: Cheese Date: Thu, 13 Aug 2026 13:15:26 +0800 Subject: [PATCH 2/2] feat(fs): issue scoped file system tokens --- AGENTS.md | 9 + README.md | 16 +- ...-file-system-token-lifecycle-management.md | 109 ++++- e2e/cli_test.go | 23 +- e2e/live_test.go | 61 +++ internal/api/client.go | 29 +- internal/api/fs/token.go | 64 ++- internal/api/fs/token_test.go | 31 ++ internal/authz/authz.go | 112 ++--- internal/cli/commands.go | 124 ++++- internal/fs/fscred/credential.go | 58 ++- internal/fs/fscred/credential_test.go | 23 +- internal/fs/tokenmgmt/service.go | 438 ++++++++++++++++-- internal/fs/tokenmgmt/service_test.go | 84 +++- 14 files changed, 1056 insertions(+), 125 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2827cf..a531a9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ Implemented: - `ti fs create-file-system` - `ti fs import-file-system-token` - `ti fs generate-file-system-token` +- `ti fs generate-file-system-scoped-token` - `ti fs list-file-system-tokens` - `ti fs enable-file-system-token` - `ti fs disable-file-system-token` @@ -533,6 +534,7 @@ Implemented command behavior: - `ti fs import-file-system-token --from-file ./fs-token` - `ti fs generate-file-system-token --file-system-id --token-name ci --ttl 24h` - `ti fs generate-file-system-token --file-system-id --token-name local --no-expiration --store-locally --replace` +- `TI_FS_TOKEN= ti fs generate-file-system-scoped-token --ttl 24h --allow /workspace:read,list,write --subject sandbox-agent` - `ti fs list-file-system-tokens --file-system-id ` - `ti fs disable-file-system-token --file-system-id --token-id ` - `ti fs enable-file-system-token --file-system-id --token-id ` @@ -636,6 +638,13 @@ Registered command surface: - `ti db execute-sql-statement` - `ti fs create-file-system` - `ti fs import-file-system-token` +- `ti fs generate-file-system-token` +- `ti fs generate-file-system-scoped-token` +- `ti fs list-file-system-tokens` +- `ti fs enable-file-system-token` +- `ti fs disable-file-system-token` +- `ti fs delete-file-system-token` +- `ti fs refresh-file-system-token` - `ti fs delete-file-system` - `ti fs list-file-systems` - `ti fs describe-file-system` diff --git a/README.md b/README.md index a7c41ba..2962e57 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ ti fs list-files `create-file-system` does not accept a user-defined name. Drive9 assigns the stable `file_system_id`, and the command returns the owner credential as `fs_token` once in its JSON result. Treat it as a secret. The example above captures both fields from one provisioning request and removes the temporary owner-only JSON file immediately. -One Filesystem can have multiple independently managed tokens for different machines, CI jobs, and sandboxes. Owner tokens authorize the complete Filesystem; path-and-operation-limited `fs_scoped` tokens can also appear in inventory, although this release does not issue new scoped tokens. The remote service is the source of truth for token inventory, while each local profile stores at most one selected token for each Filesystem. Generate an additional owner token and capture its one-time plaintext response: +One Filesystem can have multiple independently managed tokens for different machines, CI jobs, and sandboxes. Owner tokens authorize the complete Filesystem and can issue path-and-operation-limited `fs_scoped` tokens. The remote service is the source of truth for token inventory, while each local profile stores at most one selected token for each Filesystem. Generate an additional owner token and capture its one-time plaintext response: ```shell umask 077 @@ -192,6 +192,19 @@ ti fs generate-file-system-token \ ti fs list-file-system-tokens --file-system-id "$FILE_SYSTEM_ID" --output text ``` +Use an owner token to issue a finite scoped token. Repeat `--allow`; supported operations are `read`, `list`, `search`, `write`, and `delete`, and `search` requires `read`: + +```shell +export TI_FS_TOKEN="" +ti fs generate-file-system-scoped-token \ + --subject sandbox-agent \ + --ttl 24h \ + --allow /workspace:read,list,write \ + --allow /artifacts:read,list +``` + +`TI_FS_TOKEN` may contain either token kind. Scoped tokens work only for allowed paths and operations and can self-refresh; they cannot generate child tokens or manage token inventory. Explicit `--fs-token` takes precedence over the environment. Token list, enable, disable, and delete use an explicit/environment owner token when present, otherwise they use configured TiDB Cloud API keys. With owner Bearer authentication, enable and disable apply only to scoped targets; TiDB Cloud credentials can manage either token kind. Because the token JWT does not expose its kind or scopes, the FS backend is the final permission authority. + Generation does not modify local credentials by default. Add `--store-locally` to select the new token locally; if a selected token already exists, add `--replace` explicitly. Replacing local selection does not revoke the previous remote token. Use immutable `token_id` values from the list response to disable, enable, or permanently revoke a token: ```shell @@ -254,6 +267,7 @@ ti db execute-sql-statement ti fs create-file-system ti fs import-file-system-token ti fs generate-file-system-token +ti fs generate-file-system-scoped-token ti fs list-file-system-tokens ti fs enable-file-system-token ti fs disable-file-system-token diff --git a/docs/spec/done/0030-file-system-token-lifecycle-management.md b/docs/spec/done/0030-file-system-token-lifecycle-management.md index 3b06dd8..11f201f 100644 --- a/docs/spec/done/0030-file-system-token-lifecycle-management.md +++ b/docs/spec/done/0030-file-system-token-lifecycle-management.md @@ -42,12 +42,34 @@ The backend model has these constraints: - Local state remains a selected operational credential, not a replica of all remote tokens and not a multi-token wallet. - A profile may store one selected token for each File System. Different profiles may store different tokens for the same File System. - Token management never changes which File System is selected implicitly. Existing explicit FS ID and token-derived ID rules remain in force. -- Control-plane token management uses TiDB Cloud public/private keys only. It must not also send the selected local FS token. +- Owner-token generation uses TiDB Cloud public/private keys only. It must not also send an FS token. +- Scoped-token generation uses an owner FS bearer token only. It must not also send TiDB Cloud public/private keys. +- List, enable, disable, and delete use an explicitly supplied `--fs-token` or `TI_FS_TOKEN` when present; otherwise they use TiDB Cloud public/private keys. An owner bearer is accepted and an `fs_scoped` bearer is rejected by the backend. - Self-refresh uses one FS bearer token only. It must not also send TiDB Cloud public/private keys. - ti never guesses a remote `token_id` from token name, issuance time, list ordering, profile, or the number of returned rows. - An old local credential with no known `token_id` remains valid for data-plane use but cannot be correlated with one list row. - No background refresh, automatic expiry renewal, token daemon, or automatic remote revocation is introduced. -- Owner token management is the first-phase creation surface. This spec does not add a ti command for issuing new path-level `fs_scoped` tokens. Existing scoped tokens can appear in list and can be managed by token ID through TiDB Cloud credentials. A separate spec can expose scoped issuance after its local import and scope-display contract is designed. +- `TI_FS_TOKEN` can contain either an owner token or an `fs_scoped` token. The token wrapper does not reveal its kind or scopes, so ti must not infer capabilities from JWT claims. The backend remains the authorization boundary for explicit and environment tokens. Authoritative metadata stored by ti may provide an earlier diagnostic but never grants authority. +- Owner tokens can access the full File System, generate scoped tokens, list token metadata, delete same-FS tokens, enable or disable scoped targets, and self-refresh. Owner bearer authentication cannot enable or disable an owner-token target. Scoped tokens can self-refresh and access only allowed filesystem paths and operations; they cannot issue child tokens or manage token inventory. +- The backend token list does not return path scopes. ti displays scopes from the one-time scoped-generation response and can retain them with a locally stored generated token, but it does not claim that remote list reconstructs scope details. + +The effective backend capability matrix is: + +| Capability | Owner token | `fs_scoped` token | +| --- | --- | --- | +| File read, list, search, write, append, copy, move, mkdir, symlink, hardlink, and delete | Allowed | Allowed only when every requested path and operation is covered by its scopes. | +| File `chmod` | Allowed | Always denied. | +| Upload, resume, pack/unpack data paths | Allowed | Allowed only within the scoped paths and required operations. | +| Layer create/list/read/write/checkpoint/rollback/commit | Allowed | Allowed only when the layer base root and entries satisfy the scopes. | +| Mount | Allowed | Allowed when mount startup probes and subsequent operations are within the token scopes; use a scoped remote root rather than assuming `/` is accessible. | +| Generate another scoped token | Allowed | Denied. | +| List token metadata through Bearer auth | Allowed for the same File System. | Denied. | +| Enable or disable tokens through Bearer auth | Allowed only when the target is an `fs_scoped` token in the same File System. | Denied. | +| Delete tokens through Bearer auth | Allowed for a target in the same File System, including an owner target. | Denied. | +| Self-refresh | Allowed | Allowed; scopes remain unchanged. | +| Git workspace API, Journal, Vault, SQL, fork, and event APIs | Allowed under their normal owner contracts | Denied by the backend scoped-token dispatcher. | + +ti passes both token kinds unchanged to the companion for ordinary FS commands. It does not pre-authorize paths from locally stored scope metadata, because remote scope changes and backend routing remain authoritative. ## User-Facing Commands @@ -55,6 +77,7 @@ Add these commands: ```text ti fs generate-file-system-token +ti fs generate-file-system-scoped-token ti fs list-file-system-tokens ti fs enable-file-system-token ti fs disable-file-system-token @@ -94,6 +117,20 @@ ti fs generate-file-system-token \ If another local token already exists, `--store-locally` fails before the remote request. The user must explicitly add `--replace`. Replacing the local selection does not disable, delete, refresh, or otherwise change the previous remote token. +Generate a finite path-and-operation-limited token using an owner token: + +```bash +TI_FS_TOKEN= ti fs generate-file-system-scoped-token \ + --subject sandbox-agent \ + --ttl 24h \ + --allow /workspace:read,list,write \ + --allow /artifacts:read,list +``` + +`--ttl` and at least one repeatable `--allow :` are required. Scoped TTL is a positive Go duration that resolves to whole seconds; unlike owner generation, the current backend does not impose a 365-day scoped-token ceiling. Supported operations are `read`, `list`, `search`, `write`, and `delete`; `search` also requires `read`. Prefixes are canonical absolute remote paths, and duplicate canonical prefixes are rejected. `--file-system-id` is optional when the owner token is supplied explicitly or through `TI_FS_TOKEN` because ti verifies the ID embedded in the token. It is required when loading a locally stored owner token. + +`--subject` is an optional server-side audit label of at most 64 bytes. It is not a unique token name or selector. Add `--store-locally` to replace the selected operational credential with the generated scoped token; an existing local token additionally requires `--replace`. This explicit replacement can remove the locally selected owner capability, while the previous remote owner token remains active. + List token metadata for exactly one File System: ```bash @@ -126,7 +163,15 @@ All remote mutations support `--dry-run`. Dry-run validates credential availabil ## Authentication And Region Resolution -Generate, list, enable, disable, and delete use the selected profile's TiDB Cloud API keys. Request credentials are sent through `X-TiDBCloud-Public-Key` and `X-TiDBCloud-Private-Key` headers, not copied into request JSON. These commands fail before the request if either key is missing. +Owner generate uses the selected profile's TiDB Cloud API keys. Request credentials are sent through `X-TiDBCloud-Public-Key` and `X-TiDBCloud-Private-Key` headers, not copied into request JSON. The command fails before the request if either key is missing. + +Scoped generate resolves an owner FS token in this order: + +1. Explicit non-empty `--fs-token`. +2. Non-empty `TI_FS_TOKEN`. +3. The selected local File System credential identified by `--file-system-id`. + +List, enable, disable, and delete use the same explicit flag then environment precedence. If neither contains a token, they use TiDB Cloud API keys. They do not silently use a local token because doing so would unexpectedly replace the established control-plane identity; use `--fs-token` when a locally known owner token should authorize one management call. Refresh resolves its FS token in the existing order: @@ -161,6 +206,20 @@ ti fs generate-file-system-token -> render the one-time secret response ``` +Generate scoped: + +```text +ti fs generate-file-system-scoped-token + -> resolve exactly one owner bearer and verify its embedded File System ID + -> validate finite TTL, subject, canonical prefixes, and operation sets + -> POST /v1/tokens + Authorization: Bearer + body: subject, ttl_seconds, scopes[{prefix,ops}] + -> receive the scoped plaintext, immutable token ID, expiry, and normalized scopes once + -> optionally store that scoped token as the selected local credential + -> render the one-time secret response +``` + List: ```text @@ -227,9 +286,13 @@ token_id = "" scope_kind = "owner" token_name = "local-owner" expires_at = "2026-09-11T00:00:00Z" + +[[scopes]] +prefix = "/workspace" +ops = ["read", "list", "write"] ``` -The existing `api_key` key remains unchanged for compatibility. `token_id`, `scope_kind`, `token_name`, and `expires_at` are optional because create and old imports cannot discover them with the available backend APIs. +The existing `api_key` key remains unchanged for compatibility. `token_id`, `scope_kind`, `token_name`, `expires_at`, and `scopes` are optional because create and old imports cannot discover them with the available backend APIs. `scopes` is written only from an authoritative scoped-generation response and is preserved across self-refresh because refresh does not return scopes. Do not persist remote `status` as an authoritative local value. Another machine can enable, disable, delete, or refresh a token at any time, making such a cached status stale. @@ -339,6 +402,23 @@ Generate JSON: } ``` +Scoped generate JSON: + +```json +{ + "file_system_id": "tnt_abc123", + "token_id": "0d716939-f896-420c-a3f9-68310345f17d", + "subject": "sandbox-agent", + "scope_kind": "fs_scoped", + "expires_at": "2026-08-13T00:00:00Z", + "scopes": [ + {"prefix": "/workspace", "ops": ["read", "list", "write"]} + ], + "fs_token": "drive9_...", + "credentials_stored": false +} +``` + List JSON: ```json @@ -461,13 +541,15 @@ Black-box e2e uses a fake FS server and fake companion to verify command help, r Live e2e must use a uniquely generated token on the temporary test File System and must not mutate the provision token or any pre-existing token: 1. Generate a uniquely named finite-TTL owner token. -2. List the exact File System and verify the generated token metadata and absence of plaintext. -3. Perform a data-plane read using the generated token. -4. Disable the generated token, wait beyond the documented cache convergence window, and verify data-plane authentication is rejected. -5. Enable the same token and verify data-plane access returns. -6. Refresh the token with no active mount, verify the token ID is unchanged, verify the new token works, and verify the old token stops working after cache convergence. -7. Delete the refreshed token and verify it no longer authenticates or appears in default list. -8. Clean up only the token generated by the same test run, including failure cleanup through TiDB Cloud credentials. +2. Use that owner token to generate a finite scoped token with a unique subject and multiple path/operation constraints; verify in-scope operations succeed and out-of-scope path and operation requests fail. +3. Verify a scoped token can self-refresh but cannot generate another scoped token, list token metadata, or manage token status. +4. List the exact File System through both TiDB Cloud credentials and the generated owner token, and verify metadata and absence of plaintext. +5. Perform a data-plane read using the generated owner token. +6. Disable the generated token, wait beyond the documented cache convergence window, and verify data-plane authentication is rejected. +7. Enable the same token and verify data-plane access returns. +8. Refresh the token with no active mount, verify the token ID is unchanged, verify the new token works, and verify the old token stops working after cache convergence. +9. Delete the refreshed owner and scoped tokens and verify they no longer authenticate or appear in default list. +10. Clean up only tokens generated by the same test run, including failure cleanup through TiDB Cloud credentials. The live test also exercises local store/replace in an isolated temporary `TI_HOME` and verifies that no plaintext reaches captured logs. It must tolerate the expected authentication-cache convergence delay without using an unbounded retry. @@ -477,6 +559,8 @@ When implemented, update README, PingCAP command references, examples, troublesh - One File System can have multiple tokens while one local profile selects one operational token per FS. - The difference between an owner token and a path/operation-limited `fs_scoped` token. +- How `--allow` prefixes and `read,list,search,write,delete` operations constrain scoped access. +- The owner/scoped capability matrix for data plane, mount, token management, refresh, Git, Journal, and Vault commands. - Token plaintext is visible only on generate/refresh. - List is scoped to an explicit File System ID. - Generate does not replace local credentials unless requested. @@ -494,7 +578,7 @@ When implemented, update README, PingCAP command references, examples, troublesh ## Acceptance Criteria -- Users can generate, list, enable, disable, delete, and self-refresh FS tokens through ti using the existing backend API. +- Users can generate owner and scoped tokens, list, enable, disable, delete, and self-refresh FS tokens through ti using the existing backend API. - Every list and ID-based mutation is explicitly scoped to one File System ID. - Multiple remote tokens do not force ti to persist multiple local secrets. - Existing create/import credentials remain usable without a token ID. @@ -513,5 +597,4 @@ When implemented, update README, PingCAP command references, examples, troublesh - Automatic refresh, background renewal, token expiry notifications, or a credential daemon. - Automatic distribution to CI, sandboxes, containers, remote hosts, or secret managers. - Detecting mounts or processes on another machine. -- Generating new `fs_scoped` tokens through ti in this phase. - Changing the existing `ti fs create-file-system` provisioning API or companion implementation. diff --git a/e2e/cli_test.go b/e2e/cli_test.go index 59d08d6..cab6db3 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -947,11 +947,12 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) { home := t.TempDir() generatedToken := drive9TestTokenWithVersion("tenant-tokens", 1) refreshedToken := drive9TestTokenWithVersion("tenant-tokens", 2) + scopedToken := drive9TestTokenWithVersion("tenant-tokens", 3) remoteRequests := 0 tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { remoteRequests++ w.Header().Set("Content-Type", "application/json") - if r.URL.Path != "/v1/tokens/refresh" { + if r.URL.Path != "/v1/tokens/refresh" && !(r.Method == http.MethodPost && r.URL.Path == "/v1/tokens") && r.Header.Get("Authorization") == "" { if r.Header.Get("X-TiDBCloud-Public-Key") != "e2e-public" || r.Header.Get("X-TiDBCloud-Private-Key") != "e2e-private" || r.Header.Get("Authorization") != "" { t.Errorf("control-plane token authentication headers = %#v", r.Header) } @@ -960,6 +961,16 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/generate": w.WriteHeader(http.StatusCreated) _, _ = fmt.Fprintf(w, `{"token":%q,"token_id":"token-e2e","tenant_id":"tenant-tokens","key_name":"e2e-owner","scope_kind":"owner","status":"active","issued_at":"2026-08-12T00:00:00Z","expires_at":"2026-08-13T00:00:00Z"}`, generatedToken) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens": + if r.Header.Get("Authorization") != "Bearer "+generatedToken || r.Header.Get("X-TiDBCloud-Public-Key") != "" { + t.Errorf("scoped issue authentication headers = %#v", r.Header) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusCreated) + _, _ = fmt.Fprintf(w, `{"token":%q,"token_id":"token-scoped","subject":"e2e-agent","scope_kind":"fs_scoped","expires_at":"2026-08-13T00:00:00Z","scopes":[{"prefix":"/workspace","ops":["read","list"]}]}`, scopedToken) case r.Method == http.MethodGet && r.URL.Path == "/v1/tokens": if r.URL.Query().Get("tenant_id") != "tenant-tokens" || r.URL.Query().Get("limit") != "50" { t.Errorf("list query = %s", r.URL.RawQuery) @@ -1015,6 +1026,16 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) { t.Fatalf("generated credential = %#v, %v", credential, err) } + scoped := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-scoped-token", "--file-system-id", "tenant-tokens", "--fs-token", generatedToken, "--subject", "e2e-agent", "--ttl", "1h", "--allow", "/workspace:read,list") + scoped.wantExitCode(0) + scoped.wantStdoutContains(`"scope_kind": "fs_scoped"`) + scoped.wantStdoutContains(`"prefix": "/workspace"`) + scoped.wantStdoutContains(scopedToken) + + bearerListEnv := append(append([]string{}, env...), "TI_FS_TOKEN="+generatedToken) + bearerListed := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens") + bearerListed.wantExitCode(0) + listed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens", "--output", "text") listed.wantExitCode(0) listed.wantStdoutContains("token-e2e") diff --git a/e2e/live_test.go b/e2e/live_test.go index d8a4e7a..2fd4668 100644 --- a/e2e/live_test.go +++ b/e2e/live_test.go @@ -215,6 +215,7 @@ func TestLiveFSCommandSurface(t *testing.T) { {"fs", "mkdir", "help"}, {"fs", "chmod", "help"}, {"fs", "symlink", "help"}, {"fs", "hardlink", "help"}, {"fs", "grep", "help"}, {"fs", "find", "help"}, {"fs", "generate-file-system-token", "help"}, {"fs", "list-file-system-tokens", "help"}, + {"fs", "generate-file-system-scoped-token", "help"}, {"fs", "enable-file-system-token", "help"}, {"fs", "disable-file-system-token", "help"}, {"fs", "delete-file-system-token", "help"}, {"fs", "refresh-file-system-token", "help"}, {"fs", "mount", "help"}, {"fs", "drain", "help"}, {"fs", "umount", "help"}, @@ -233,6 +234,8 @@ func TestLiveFSCommandSurface(t *testing.T) { {"fs", "disable-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", "00000000-0000-0000-0000-000000000000"}, {"fs", "delete-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", "00000000-0000-0000-0000-000000000000"}, }, "remote_mutation") + scopedDryRun := runTI(t, bin, "--profile", profileName, "fs", "generate-file-system-scoped-token", "--file-system-id", selected.FSTenantID, "--ttl", "1h", "--allow", "/ti-e2e:read,list", "--dry-run") + scopedDryRun.wantExitCode(0) refreshDryRun := runTIWithInput(t, bin, "", []string{"TI_FS_TOKEN=" + drive9TestTokenWithVersion(selected.FSTenantID, 999), "TI_REGION_CODE=" + selected.FSPlacementRegionCode}, "--profile", profileName, "fs", "refresh-file-system-token", "--file-system-id", selected.FSTenantID, "--dry-run") refreshDryRun.wantExitCode(0) @@ -290,6 +293,64 @@ func TestLiveFSFileSystemTokenLifecycle(t *testing.T) { if generatedResult.FileSystemID != selected.FSTenantID { t.Fatalf("generated token file_system_id = %q, want %q", generatedResult.FileSystemID, selected.FSTenantID) } + scopedRoot := fmt.Sprintf("/ti-e2e-scoped-%d", time.Now().UnixNano()) + scopedIssue := runTIWithInput(t, bin, "", []string{"TI_FS_TOKEN=" + generatedResult.FSToken, "TI_REGION_CODE=" + regionCode}, + "--profile", profileName, "fs", "generate-file-system-scoped-token", "--file-system-id", selected.FSTenantID, + "--subject", "ti-e2e-scoped", "--ttl", "1h", "--allow", scopedRoot+":read,list,write,delete") + scopedIssue.wantExitCode(0) + var scopedResult struct { + TokenID string `json:"token_id"` + FSToken string `json:"fs_token"` + } + if err := json.Unmarshal([]byte(scopedIssue.stdout), &scopedResult); err != nil || scopedResult.TokenID == "" || scopedResult.FSToken == "" { + t.Fatalf("decode generated scoped FS token: %v\n%s", err, scopedIssue.stdout) + } + scopedDeleted := false + defer func() { + if scopedDeleted { + return + } + cleanup := runLiveFSSetupCommand(t, bin, "--profile", profileName, "--region", regionCode, "fs", "delete-file-system-token", + "--file-system-id", selected.FSTenantID, "--token-id", scopedResult.TokenID) + if cleanup.exitCode != 0 && !strings.Contains(strings.ToLower(cleanup.stderr), "not found") { + t.Logf("cleanup generated scoped FS token failed: %s", strings.TrimSpace(cleanup.stderr)) + } + }() + + scopedEnv := []string{"TI_FS_TOKEN=" + scopedResult.FSToken, "TI_REGION_CODE=" + regionCode} + mkdir := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "create-directory", "--file-system-id", selected.FSTenantID, "--path", scopedRoot) + mkdir.wantExitCode(0) + write := runTIWithInput(t, bin, "scoped-data", scopedEnv, "--profile", profileName, "fs", "copy-file", "--file-system-id", selected.FSTenantID, "--from-stdin", "--to-remote", scopedRoot+"/probe.txt") + write.wantExitCode(0) + read := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "read-file", "--file-system-id", selected.FSTenantID, "--path", scopedRoot+"/probe.txt") + read.wantExitCode(0) + read.wantStdoutContains("scoped-data") + outOfScope := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "list-files", "--file-system-id", selected.FSTenantID, "--path", "/") + if outOfScope.exitCode == 0 { + outOfScope.fail("scoped token unexpectedly accessed a path outside its prefix") + } + scopedList := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "list-file-system-tokens", "--file-system-id", selected.FSTenantID) + scopedList.wantExitCode(4) + scopedChild := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "generate-file-system-scoped-token", "--file-system-id", selected.FSTenantID, "--ttl", "1h", "--allow", scopedRoot+":read") + scopedChild.wantExitCode(4) + scopedRefresh := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "refresh-file-system-token", "--file-system-id", selected.FSTenantID) + scopedRefresh.wantExitCode(0) + var scopedRefreshResult struct { + TokenID string `json:"token_id"` + FSToken string `json:"fs_token"` + } + if err := json.Unmarshal([]byte(scopedRefresh.stdout), &scopedRefreshResult); err != nil || scopedRefreshResult.TokenID != scopedResult.TokenID || scopedRefreshResult.FSToken == "" { + t.Fatalf("decode refreshed scoped FS token: %v\n%s", err, scopedRefresh.stdout) + } + scopedEnv = []string{"TI_FS_TOKEN=" + scopedRefreshResult.FSToken, "TI_REGION_CODE=" + regionCode} + readAfterRefresh := runTIWithInput(t, bin, "", scopedEnv, "--profile", profileName, "fs", "read-file", "--file-system-id", selected.FSTenantID, "--path", scopedRoot+"/probe.txt") + readAfterRefresh.wantExitCode(0) + + cleanupPath := runTIWithInput(t, bin, "", []string{"TI_FS_TOKEN=" + generatedResult.FSToken, "TI_REGION_CODE=" + regionCode}, "--profile", profileName, "fs", "delete-file", "--file-system-id", selected.FSTenantID, "--path", scopedRoot, "--recursive") + cleanupPath.wantExitCode(0) + removeScoped := runTI(t, bin, "--profile", profileName, "--region", regionCode, "fs", "delete-file-system-token", "--file-system-id", selected.FSTenantID, "--token-id", scopedResult.TokenID) + removeScoped.wantExitCode(0) + scopedDeleted = true deleted := false defer func() { if deleted { diff --git a/internal/api/client.go b/internal/api/client.go index 2a37912..24f735c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -35,6 +35,7 @@ type Client struct { UserAgent string MaxRetries int Redactor apitransport.Redactor + BearerAuth bool } type Options struct { @@ -128,7 +129,12 @@ func NewBearerClient(profileName, apiKey string, endpoint endpoints.Endpoint, pe opts.Permission = permission opts.Transport = apitransport.NewBearer(apiKey, opts.Transport) opts.Redactor.Secrets = append(opts.Redactor.Secrets, apiKey) - return New(opts) + client, err := New(opts) + if err != nil { + return nil, err + } + client.BearerAuth = true + return client, nil } func (c *Client) NewRequest(ctx context.Context, method, requestPath string, body any) (*http.Request, error) { @@ -317,7 +323,7 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { case http.StatusUnauthorized: message := fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) if c.Service == endpoints.ServiceFS { - if strings.HasPrefix(string(c.Permission), "fs.token.") && c.Permission != authz.FSTokenRefresh { + if !c.BearerAuth && strings.HasPrefix(string(c.Permission), "fs.token.") && c.Permission != authz.FSTokenRefresh { message = fmt.Sprintf("authentication failed: TiDB Cloud rejected the API key pair for profile %q. Check ~/.ti/credentials or create a new API key.", profileName(c.ProfileName)) } else { message = fmt.Sprintf("authentication failed: ti fs rejected the selected token for profile %q. It might be disabled, expired, refreshed elsewhere, or revoked; generate or import a valid token and try again.", profileName(c.ProfileName)) @@ -332,12 +338,29 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { Body: string(body), } case http.StatusForbidden: + message := permissionDeniedMessage(profileName(c.ProfileName), c.Permission, c.Action, c.Provider, c.RegionCode) + if c.Service == endpoints.ServiceFS { + switch c.Permission { + case authz.FSTokenIssueScoped: + message = "permission denied: generating a scoped file system token requires an owner FS token; TI_FS_TOKEN or --fs-token currently contains a scoped token or another token without owner permission" + case authz.FSTokenList, authz.FSTokenDelete: + if c.BearerAuth { + message = "permission denied: this token management operation requires an owner FS token; TI_FS_TOKEN or --fs-token currently contains a scoped token or another token without owner permission" + } + case authz.FSTokenEnable, authz.FSTokenDisable: + if c.BearerAuth { + message = "permission denied: enabling or disabling a token requires an owner FS token and the target must be an fs_scoped token" + } + case authz.FSFileRead, authz.FSFileWrite, authz.FSMount: + message = "permission denied: the selected FS token does not allow this operation or path; use an owner token or a scoped token whose prefix and operations include the request" + } + } return &Error{ Code: "authz.permission_denied", Category: "authorization", ExitCode: 4, StatusCode: res.StatusCode, - Message: permissionDeniedMessage(profileName(c.ProfileName), c.Permission, c.Action, c.Provider, c.RegionCode), + Message: message, Body: string(body), } case http.StatusNotFound: diff --git a/internal/api/fs/token.go b/internal/api/fs/token.go index 2bbe3bb..3c47fe8 100644 --- a/internal/api/fs/token.go +++ b/internal/api/fs/token.go @@ -84,6 +84,26 @@ type RefreshTokenResponse struct { ExpiresAt *time.Time `json:"expires_at"` } +type TokenScope struct { + Prefix string `json:"prefix"` + Ops []string `json:"ops"` +} + +type IssueScopedTokenRequest struct { + Subject string `json:"subject,omitempty"` + TTLSeconds int64 `json:"ttl_seconds"` + Scopes []TokenScope `json:"scopes"` +} + +type IssueScopedTokenResponse struct { + Token string `json:"token"` + TokenID string `json:"token_id"` + Subject string `json:"subject,omitempty"` + ScopeKind string `json:"scope_kind"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Scopes []TokenScope `json:"scopes"` +} + func (c *Client) GenerateToken(ctx context.Context, creds TiDBCloudCredentials, input GenerateTokenRequest) (GenerateTokenResponse, error) { body := struct { FileSystemID string `json:"tenant_id"` @@ -103,6 +123,14 @@ func (c *Client) GenerateToken(ctx context.Context, creds TiDBCloudCredentials, } func (c *Client) ListTokens(ctx context.Context, creds TiDBCloudCredentials, opts ListTokensOptions) (ListTokensResponse, error) { + return c.listTokens(ctx, &creds, opts) +} + +func (c *Client) ListTokensWithBearer(ctx context.Context, opts ListTokensOptions) (ListTokensResponse, error) { + return c.listTokens(ctx, nil, opts) +} + +func (c *Client) listTokens(ctx context.Context, creds *TiDBCloudCredentials, opts ListTokensOptions) (ListTokensResponse, error) { query := url.Values{} query.Set("tenant_id", opts.FileSystemID) query.Set("offset", strconv.Itoa(opts.Offset)) @@ -114,7 +142,9 @@ func (c *Client) ListTokens(ctx context.Context, creds TiDBCloudCredentials, opt if err != nil { return ListTokensResponse{}, err } - setTiDBCloudCredentialHeaders(req, creds) + if creds != nil { + setTiDBCloudCredentialHeaders(req, *creds) + } var response ListTokensResponse if err := c.api.DoJSON(req, &response); err != nil { return ListTokensResponse{}, err @@ -126,6 +156,14 @@ func (c *Client) ListTokens(ctx context.Context, creds TiDBCloudCredentials, opt } func (c *Client) SetTokenEnabled(ctx context.Context, creds TiDBCloudCredentials, fileSystemID, tokenID string, enabled bool) (TokenMutationResponse, error) { + return c.setTokenEnabled(ctx, &creds, fileSystemID, tokenID, enabled) +} + +func (c *Client) SetTokenEnabledWithBearer(ctx context.Context, fileSystemID, tokenID string, enabled bool) (TokenMutationResponse, error) { + return c.setTokenEnabled(ctx, nil, fileSystemID, tokenID, enabled) +} + +func (c *Client) setTokenEnabled(ctx context.Context, creds *TiDBCloudCredentials, fileSystemID, tokenID string, enabled bool) (TokenMutationResponse, error) { action := "deactivate" if enabled { action = "activate" @@ -136,7 +174,9 @@ func (c *Client) SetTokenEnabled(ctx context.Context, creds TiDBCloudCredentials if err != nil { return TokenMutationResponse{}, err } - setTiDBCloudCredentialHeaders(req, creds) + if creds != nil { + setTiDBCloudCredentialHeaders(req, *creds) + } var response TokenMutationResponse if err := c.api.DoJSON(req, &response); err != nil { return TokenMutationResponse{}, err @@ -174,6 +214,26 @@ func (c *Client) RefreshToken(ctx context.Context, input RefreshTokenRequest) (R return response, nil } +func (c *Client) IssueScopedToken(ctx context.Context, input IssueScopedTokenRequest) (IssueScopedTokenResponse, error) { + req, err := c.api.NewRequest(ctx, http.MethodPost, "/v1/tokens", input) + if err != nil { + return IssueScopedTokenResponse{}, err + } + var response IssueScopedTokenResponse + if err := c.api.DoJSON(req, &response); err != nil { + return IssueScopedTokenResponse{}, err + } + return response, nil +} + +func (c *Client) DeleteTokenWithBearer(ctx context.Context, tokenID string) error { + req, err := c.api.NewRequest(ctx, http.MethodDelete, "/v1/tokens/"+url.PathEscape(tokenID), nil) + if err != nil { + return err + } + return c.api.DoJSON(req, nil) +} + func setTiDBCloudCredentialHeaders(req *http.Request, creds TiDBCloudCredentials) { req.Header.Set(tidbCloudPublicKeyHeader, strings.TrimSpace(creds.PublicKey)) req.Header.Set(tidbCloudPrivateKeyHeader, strings.TrimSpace(creds.PrivateKey)) diff --git a/internal/api/fs/token_test.go b/internal/api/fs/token_test.go index cb5faba..36adecd 100644 --- a/internal/api/fs/token_test.go +++ b/internal/api/fs/token_test.go @@ -114,6 +114,37 @@ func TestRefreshTokenUsesBearerOnly(t *testing.T) { } } +func TestIssueScopedTokenUsesBearerAndExpectedShape(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/tokens" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer drive9_owner" { + t.Errorf("Authorization = %q", got) + } + if r.Header.Get(tidbCloudPublicKeyHeader) != "" || r.Header.Get(tidbCloudPrivateKeyHeader) != "" { + t.Error("scoped issue included control-plane credentials") + } + var body IssueScopedTokenRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Subject != "agent" || body.TTLSeconds != 3600 || len(body.Scopes) != 1 || body.Scopes[0].Prefix != "/workspace" || strings.Join(body.Scopes[0].Ops, ",") != "read,list" { + t.Errorf("body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":"drive9_scoped","token_id":"token-scoped","subject":"agent","scope_kind":"fs_scoped","expires_at":"2026-08-13T00:00:00Z","scopes":[{"prefix":"/workspace","ops":["read","list"]}]}`)) + })) + defer server.Close() + client := newTokenTestClient(t, server.URL, "drive9_owner") + response, err := client.IssueScopedToken(context.Background(), IssueScopedTokenRequest{Subject: "agent", TTLSeconds: 3600, Scopes: []TokenScope{{Prefix: "/workspace", Ops: []string{"read", "list"}}}}) + if err != nil || response.ScopeKind != "fs_scoped" || len(response.Scopes) != 1 { + t.Fatalf("IssueScopedToken() = %#v, %v", response, err) + } +} + func TestRefreshTokenSendsExplicitTTL(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 3e2402b..a59dd12 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -27,6 +27,7 @@ const ( FSVolumeDelete Permission = "fs.volume.delete" FSTokenList Permission = "fs.token.list" FSTokenGenerate Permission = "fs.token.generate" + FSTokenIssueScoped Permission = "fs.token.issue_scoped" FSTokenEnable Permission = "fs.token.enable" FSTokenDisable Permission = "fs.token.disable" FSTokenDelete Permission = "fs.token.delete" @@ -51,61 +52,62 @@ const ( ) var commandPermissions = map[string]Permission{ - "ti fs create-file-system": FSVolumeCreate, - "ti fs delete-file-system": FSVolumeDelete, - "ti fs list-file-systems": FSVolumeRead, - "ti fs describe-file-system": FSVolumeRead, - "ti fs check-file-system": FSVolumeRead, - "ti fs generate-file-system-token": FSTokenGenerate, - "ti fs list-file-system-tokens": FSTokenList, - "ti fs enable-file-system-token": FSTokenEnable, - "ti fs disable-file-system-token": FSTokenDisable, - "ti fs delete-file-system-token": FSTokenDelete, - "ti fs refresh-file-system-token": FSTokenRefresh, - "ti fs copy-file": FSFileWrite, - "ti fs read-file": FSFileRead, - "ti fs list-files": FSFileRead, - "ti fs describe-file": FSFileRead, - "ti fs move-file": FSFileWrite, - "ti fs delete-file": FSFileWrite, - "ti fs create-directory": FSFileWrite, - "ti fs chmod-file": FSFileWrite, - "ti fs create-symlink": FSFileWrite, - "ti fs create-hardlink": FSFileWrite, - "ti fs search-file-content": FSFileRead, - "ti fs find-files": FSFileRead, - "ti fs create-layer": FSFileWrite, - "ti fs list-layers": FSFileRead, - "ti fs describe-layer": FSFileRead, - "ti fs diff-layer": FSFileRead, - "ti fs create-layer-checkpoint": FSFileWrite, - "ti fs rollback-layer": FSFileWrite, - "ti fs commit-layer": FSFileWrite, - "ti fs pack-file-system": FSFileWrite, - "ti fs unpack-file-system": FSFileRead, - "ti fs mount-file-system": FSMount, - "ti fs drain-file-system": FSMount, - "ti fs unmount-file-system": FSMount, - "ti fs-vault create-secret": FSVaultSecretCreate, - "ti fs-vault replace-secret": FSVaultSecretUpdate, - "ti fs-vault read-secret": FSVaultSecretRead, - "ti fs-vault list-secrets": FSVaultSecretRead, - "ti fs-vault delete-secret": FSVaultSecretDelete, - "ti fs-vault create-grant": FSVaultGrantCreate, - "ti fs-vault delete-grant": FSVaultGrantDelete, - "ti fs-vault list-audit-events": FSVaultAuditRead, - "ti fs-vault run-with-secret": FSVaultSecretRead, - "ti fs-vault mount-vault": FSVaultSecretRead, - "ti fs-vault unmount-vault": FSVaultSecretRead, - "ti fs-journal create-journal": FSJournalCreate, - "ti fs-journal append-journal-entries": FSJournalAppend, - "ti fs-journal read-journal-entries": FSJournalRead, - "ti fs-journal search-journal-entries": FSJournalSearch, - "ti fs-journal verify-journal": FSJournalVerify, - "ti fs-git clone-git-workspace": FSGitWorkspaceWrite, - "ti fs-git hydrate-git-workspace": FSGitWorkspaceRead, - "ti fs-git add-git-worktree": FSGitWorkspaceWrite, - "ti fs-git remove-git-worktree": FSGitWorkspaceWrite, + "ti fs create-file-system": FSVolumeCreate, + "ti fs delete-file-system": FSVolumeDelete, + "ti fs list-file-systems": FSVolumeRead, + "ti fs describe-file-system": FSVolumeRead, + "ti fs check-file-system": FSVolumeRead, + "ti fs generate-file-system-token": FSTokenGenerate, + "ti fs generate-file-system-scoped-token": FSTokenIssueScoped, + "ti fs list-file-system-tokens": FSTokenList, + "ti fs enable-file-system-token": FSTokenEnable, + "ti fs disable-file-system-token": FSTokenDisable, + "ti fs delete-file-system-token": FSTokenDelete, + "ti fs refresh-file-system-token": FSTokenRefresh, + "ti fs copy-file": FSFileWrite, + "ti fs read-file": FSFileRead, + "ti fs list-files": FSFileRead, + "ti fs describe-file": FSFileRead, + "ti fs move-file": FSFileWrite, + "ti fs delete-file": FSFileWrite, + "ti fs create-directory": FSFileWrite, + "ti fs chmod-file": FSFileWrite, + "ti fs create-symlink": FSFileWrite, + "ti fs create-hardlink": FSFileWrite, + "ti fs search-file-content": FSFileRead, + "ti fs find-files": FSFileRead, + "ti fs create-layer": FSFileWrite, + "ti fs list-layers": FSFileRead, + "ti fs describe-layer": FSFileRead, + "ti fs diff-layer": FSFileRead, + "ti fs create-layer-checkpoint": FSFileWrite, + "ti fs rollback-layer": FSFileWrite, + "ti fs commit-layer": FSFileWrite, + "ti fs pack-file-system": FSFileWrite, + "ti fs unpack-file-system": FSFileRead, + "ti fs mount-file-system": FSMount, + "ti fs drain-file-system": FSMount, + "ti fs unmount-file-system": FSMount, + "ti fs-vault create-secret": FSVaultSecretCreate, + "ti fs-vault replace-secret": FSVaultSecretUpdate, + "ti fs-vault read-secret": FSVaultSecretRead, + "ti fs-vault list-secrets": FSVaultSecretRead, + "ti fs-vault delete-secret": FSVaultSecretDelete, + "ti fs-vault create-grant": FSVaultGrantCreate, + "ti fs-vault delete-grant": FSVaultGrantDelete, + "ti fs-vault list-audit-events": FSVaultAuditRead, + "ti fs-vault run-with-secret": FSVaultSecretRead, + "ti fs-vault mount-vault": FSVaultSecretRead, + "ti fs-vault unmount-vault": FSVaultSecretRead, + "ti fs-journal create-journal": FSJournalCreate, + "ti fs-journal append-journal-entries": FSJournalAppend, + "ti fs-journal read-journal-entries": FSJournalRead, + "ti fs-journal search-journal-entries": FSJournalSearch, + "ti fs-journal verify-journal": FSJournalVerify, + "ti fs-git clone-git-workspace": FSGitWorkspaceWrite, + "ti fs-git hydrate-git-workspace": FSGitWorkspaceRead, + "ti fs-git add-git-worktree": FSGitWorkspaceWrite, + "ti fs-git remove-git-worktree": FSGitWorkspaceWrite, } func ForCommand(commandPath string) (Permission, error) { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 8eaa70d..c130c18 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -861,6 +861,7 @@ func newFSCommand(info version.Info) *cobra.Command { newFSDescribeFileSystemCommand(info), newFSImportFileSystemTokenCommand(info), newFSGenerateFileSystemTokenCommand(info), + newFSGenerateFileSystemScopedTokenCommand(info), newFSListFileSystemTokensCommand(info), newFSEnableFileSystemTokenCommand(info), newFSDisableFileSystemTokenCommand(info), @@ -892,7 +893,7 @@ func newFSCommand(info version.Info) *cobra.Command { newFSDrainFileSystemCommand(info), newFSUnmountFileSystemCommand(info), } - tokenCommands := []string{"generate-file-system-token", "list-file-system-tokens", "enable-file-system-token", "disable-file-system-token", "delete-file-system-token", "refresh-file-system-token"} + tokenCommands := []string{"generate-file-system-token", "generate-file-system-scoped-token", "list-file-system-tokens", "enable-file-system-token", "disable-file-system-token", "delete-file-system-token", "refresh-file-system-token"} selectorExclusions := append([]string{"create-file-system", "list-file-systems", "describe-file-system", "delete-file-system", "import-file-system-token", "drain-file-system", "unmount-file-system"}, tokenCommands...) addFSSelectorFlags(commands, selectorExclusions...) addFSAuthFlags(commands, @@ -904,6 +905,7 @@ func newFSCommand(info version.Info) *cobra.Command { "drain-file-system", "unmount-file-system", "generate-file-system-token", + "generate-file-system-scoped-token", "list-file-system-tokens", "enable-file-system-token", "disable-file-system-token", @@ -950,11 +952,52 @@ func newFSGenerateFileSystemTokenCommand(info version.Info) *cobra.Command { return cmd } +func newFSGenerateFileSystemScopedTokenCommand(info version.Info) *cobra.Command { + cmd := newControlPlaneCommand(controlPlaneCommandSpec{ + Use: "generate-file-system-scoped-token", Short: "Generate a path-and-operation-limited token using an owner token.", Mutation: mutatingCommand, Permission: authz.FSTokenIssueScoped, + Run: func(ctx commandContext) (any, error) { + service, profile, err := fsTokenLocalServiceAndProfile(ctx) + if err != nil { + return nil, err + } + opts, err := fsGenerateScopedTokenOptions(ctx, profile) + if err != nil { + return nil, err + } + return service.GenerateScoped(ctx.cmd.Context(), opts) + }, + DryRun: func(ctx commandContext) (dryrun.Result, error) { + service, profile, err := fsTokenLocalServiceAndProfile(ctx) + if err != nil { + return dryrun.Result{}, err + } + opts, err := fsGenerateScopedTokenOptions(ctx, profile) + if err != nil { + return dryrun.Result{}, err + } + return service.DryRunGenerateScoped(ctx.CommandPath(), opts) + }, + }, info) + cmd.Flags().String("file-system-id", "", "Optional file system ID assertion; required when using a locally stored owner token.") + cmd.Flags().String("fs-token", "", "Owner file system token. Default: TI_FS_TOKEN, then the selected local credential.") + cmd.Flags().String("subject", "", "Optional server-side audit label (maximum 64 bytes).") + cmd.Flags().Duration("ttl", 0, "Scoped token lifetime as a positive duration of whole seconds.") + cmd.Flags().StringArray("allow", nil, "Allowed path prefix and operations as :; repeatable. Operations: read,list,search,write,delete.") + cmd.Flags().Bool("store-locally", false, "Select and store the generated scoped token in this profile's local credentials.") + cmd.Flags().Bool("replace", false, "Replace the selected local token; the previous remote token remains active.") + markUsageRequired(cmd, "ttl", "allow") + return cmd +} + func newFSListFileSystemTokensCommand(info version.Info) *cobra.Command { cmd := newControlPlaneCommand(controlPlaneCommandSpec{ Use: "list-file-system-tokens", Short: "List token metadata for one file system.", Mutation: readOnlyCommand, Permission: authz.FSTokenList, Run: func(ctx commandContext) (any, error) { - service, profile, err := fsTokenTIServiceAndProfile(ctx) + token, err := ctx.StringFlag("fs-token") + if err != nil { + return nil, err + } + service, profile, err := fsTokenManagementServiceAndProfile(ctx, token, ctx.FlagChanged("fs-token")) if err != nil { return nil, err } @@ -978,10 +1021,11 @@ func newFSListFileSystemTokensCommand(info version.Info) *cobra.Command { if err != nil { return nil, err } - return service.List(ctx.cmd.Context(), tokenmgmt.ListOptions{Profile: profile, FileSystemID: fileSystemID, Offset: int(offset), Limit: int(limit), IncludeExpired: includeExpired, RegionOverride: regionOverride}) + return service.List(ctx.cmd.Context(), tokenmgmt.ListOptions{Profile: profile, FileSystemID: fileSystemID, Token: token, TokenExplicit: ctx.FlagChanged("fs-token"), Offset: int(offset), Limit: int(limit), IncludeExpired: includeExpired, RegionOverride: regionOverride}) }, }, info) cmd.Flags().String("file-system-id", "", "The file system ID whose tokens are listed.") + cmd.Flags().String("fs-token", "", "Optional owner FS token. Default: TI_FS_TOKEN; otherwise TiDB Cloud API keys are used.") cmd.Flags().Bool("include-expired", false, "Include expired token metadata.") cmd.Flags().Int32("offset", 0, "The zero-based token offset.") cmd.Flags().Int32("limit", tokenmgmt.DefaultListLimit, "The maximum number of tokens to return (maximum 200).") @@ -1005,7 +1049,11 @@ func newFSTokenMutationCommand(use, short, operation, method, path string, permi cmd := newControlPlaneCommand(controlPlaneCommandSpec{ Use: use, Short: short, Mutation: mutatingCommand, Permission: permission, Run: func(ctx commandContext) (any, error) { - service, profile, err := fsTokenTIServiceAndProfile(ctx) + token, err := ctx.StringFlag("fs-token") + if err != nil { + return nil, err + } + service, profile, err := fsTokenManagementServiceAndProfile(ctx, token, ctx.FlagChanged("fs-token")) if err != nil { return nil, err } @@ -1025,7 +1073,11 @@ func newFSTokenMutationCommand(use, short, operation, method, path string, permi } }, DryRun: func(ctx commandContext) (dryrun.Result, error) { - service, profile, err := fsTokenTIServiceAndProfile(ctx) + token, err := ctx.StringFlag("fs-token") + if err != nil { + return dryrun.Result{}, err + } + service, profile, err := fsTokenManagementServiceAndProfile(ctx, token, ctx.FlagChanged("fs-token")) if err != nil { return dryrun.Result{}, err } @@ -1038,6 +1090,7 @@ func newFSTokenMutationCommand(use, short, operation, method, path string, permi }, info) cmd.Flags().String("file-system-id", "", "The file system ID that owns the token.") cmd.Flags().String("token-id", "", "The immutable token ID.") + cmd.Flags().String("fs-token", "", "Optional owner FS token. Default: TI_FS_TOKEN; otherwise TiDB Cloud API keys are used.") markUsageRequired(cmd, "file-system-id", "token-id") return cmd } @@ -1111,6 +1164,46 @@ func fsGenerateTokenOptions(ctx commandContext, profile *config.Profile) (tokenm return tokenmgmt.GenerateOptions{Profile: profile, FileSystemID: fileSystemID, TokenName: tokenName, TTL: ttl, NoExpiration: noExpiration, StoreLocally: storeLocally, Replace: replace, RegionOverride: regionOverride}, nil } +func fsGenerateScopedTokenOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.GenerateScopedOptions, error) { + fileSystemID, err := ctx.StringFlag("file-system-id") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + token, err := ctx.StringFlag("fs-token") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + subject, err := ctx.StringFlag("subject") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + allows, err := ctx.StringArrayFlag("allow") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + storeLocally, err := ctx.BoolFlag("store-locally") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + replace, err := ctx.BoolFlag("replace") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + var ttl *time.Duration + if ctx.FlagChanged("ttl") { + value, err := ctx.DurationFlag("ttl") + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + ttl = &value + } + regionOverride, err := fsExplicitRegionOverride(ctx) + if err != nil { + return tokenmgmt.GenerateScopedOptions{}, err + } + return tokenmgmt.GenerateScopedOptions{Profile: profile, FileSystemID: fileSystemID, Token: token, TokenExplicit: ctx.FlagChanged("fs-token"), Subject: subject, TTL: ttl, Allows: allows, StoreLocally: storeLocally, Replace: replace, RegionOverride: regionOverride}, nil +} + func fsTokenMutationOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.MutationOptions, error) { fileSystemID, err := ctx.StringFlag("file-system-id") if err != nil { @@ -1120,11 +1213,15 @@ func fsTokenMutationOptions(ctx commandContext, profile *config.Profile) (tokenm if err != nil { return tokenmgmt.MutationOptions{}, err } + token, err := ctx.StringFlag("fs-token") + if err != nil { + return tokenmgmt.MutationOptions{}, err + } regionOverride, err := fsExplicitRegionOverride(ctx) if err != nil { return tokenmgmt.MutationOptions{}, err } - return tokenmgmt.MutationOptions{Profile: profile, FileSystemID: fileSystemID, TokenID: tokenID, RegionOverride: regionOverride}, nil + return tokenmgmt.MutationOptions{Profile: profile, FileSystemID: fileSystemID, TokenID: tokenID, Token: token, TokenExplicit: ctx.FlagChanged("fs-token"), RegionOverride: regionOverride}, nil } func fsRefreshTokenOptions(ctx commandContext, profile *config.Profile) (tokenmgmt.RefreshOptions, error) { @@ -2660,6 +2757,21 @@ func fsTokenLocalServiceAndProfile(ctx commandContext) (tokenmgmt.Service, *conf return fsTokenService(ctx, profile) } +func fsTokenManagementServiceAndProfile(ctx commandContext, token string, tokenExplicit bool) (tokenmgmt.Service, *config.Profile, error) { + useBearer := tokenExplicit || strings.TrimSpace(token) != "" + if !useBearer { + envToken, _, _, err := envcompat.ResolveNames(nil, "TI_FS_TOKEN", envcompat.LegacyNameFor("TI_FS_TOKEN")) + if err != nil { + return tokenmgmt.Service{}, nil, err + } + useBearer = strings.TrimSpace(envToken) != "" + } + if useBearer { + return fsTokenLocalServiceAndProfile(ctx) + } + return fsTokenTIServiceAndProfile(ctx) +} + func fsTokenService(ctx commandContext, profile *config.Profile) (tokenmgmt.Service, *config.Profile, error) { debug, err := ctx.BoolFlag("debug") if err != nil { diff --git a/internal/fs/fscred/credential.go b/internal/fs/fscred/credential.go index cf7739d..d456dad 100644 --- a/internal/fs/fscred/credential.go +++ b/internal/fs/fscred/credential.go @@ -2,6 +2,7 @@ package fscred import ( "context" + "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -30,14 +31,20 @@ const ( var migrationMu sync.Mutex type Credential struct { - FileSystemID string `json:"file_system_id" toml:"file_system_id"` - RegionCode string `json:"region_code" toml:"region_code"` - HasLocalToken bool `json:"has_local_token" toml:"-"` - APIKey string `json:"-" toml:"api_key"` - TokenID string `json:"token_id,omitempty" toml:"token_id,omitempty"` - ScopeKind string `json:"scope_kind,omitempty" toml:"scope_kind,omitempty"` - TokenName string `json:"token_name,omitempty" toml:"token_name,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty" toml:"expires_at,omitempty"` + FileSystemID string `json:"file_system_id" toml:"file_system_id"` + RegionCode string `json:"region_code" toml:"region_code"` + HasLocalToken bool `json:"has_local_token" toml:"-"` + APIKey string `json:"-" toml:"api_key"` + TokenID string `json:"token_id,omitempty" toml:"token_id,omitempty"` + ScopeKind string `json:"scope_kind,omitempty" toml:"scope_kind,omitempty"` + TokenName string `json:"token_name,omitempty" toml:"token_name,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty" toml:"expires_at,omitempty"` + Scopes []TokenScope `json:"scopes,omitempty" toml:"scopes,omitempty"` +} + +type TokenScope struct { + Prefix string `json:"prefix" toml:"prefix"` + Ops []string `json:"ops" toml:"ops"` } type CredentialPaths struct { @@ -95,6 +102,7 @@ func StoreCredentialRecord(homeDir string, profile *config.Profile, credential C credential.TokenID = strings.TrimSpace(credential.TokenID) credential.ScopeKind = strings.TrimSpace(credential.ScopeKind) credential.TokenName = strings.TrimSpace(credential.TokenName) + credential.Scopes = normalizeStoredScopes(credential.Scopes) if credential.ExpiresAt != nil { expiresAt := credential.ExpiresAt.UTC() credential.ExpiresAt = &expiresAt @@ -217,12 +225,43 @@ func credentialsEqual(left, right Credential) bool { if left.FileSystemID != right.FileSystemID || left.RegionCode != right.RegionCode || left.APIKey != right.APIKey || left.TokenID != right.TokenID || left.ScopeKind != right.ScopeKind || left.TokenName != right.TokenName { return false } + if !tokenScopesEqual(left.Scopes, right.Scopes) { + return false + } if left.ExpiresAt == nil || right.ExpiresAt == nil { return left.ExpiresAt == nil && right.ExpiresAt == nil } return left.ExpiresAt.Equal(*right.ExpiresAt) } +func normalizeStoredScopes(scopes []TokenScope) []TokenScope { + if len(scopes) == 0 { + return nil + } + result := make([]TokenScope, 0, len(scopes)) + for _, scope := range scopes { + result = append(result, TokenScope{Prefix: strings.TrimSpace(scope.Prefix), Ops: append([]string(nil), scope.Ops...)}) + } + return result +} + +func tokenScopesEqual(left, right []TokenScope) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i].Prefix != right[i].Prefix || len(left[i].Ops) != len(right[i].Ops) { + return false + } + for j := range left[i].Ops { + if left[i].Ops[j] != right[i].Ops[j] { + return false + } + } + } + return true +} + func GetCredential(homeDir, profileName, fileSystemID string) (Credential, error) { fileSystemID, err := ValidateFileSystemID(fileSystemID) if err != nil { @@ -469,6 +508,7 @@ func ResolveCredential(homeDir string, profile *config.Profile, opts ResolveCred if found && credential.RegionCode != placement.Code { return nil, Credential{}, apperr.New("fs.credential_region_mismatch", "config", 2, fmt.Sprintf("file system %q credentials are for %s, not %s", id, credential.RegionCode, placement.Code)) } + metadataMatchesToken := found && subtle.ConstantTimeCompare([]byte(token), []byte(credential.APIKey)) == 1 credential.FileSystemID = id credential.RegionCode = placement.Code credential.APIKey = token @@ -480,7 +520,7 @@ func ResolveCredential(homeDir string, profile *config.Profile, opts ResolveCred selected.FSCloudProvider = placement.Provider selected.FSRegionCode = placement.NativeCode selected.FSAPIKey = token - if found && token == credential.APIKey { + if metadataMatchesToken { selected.FSTokenID = credential.TokenID selected.FSTokenScopeKind = credential.ScopeKind selected.FSTokenName = credential.TokenName diff --git a/internal/fs/fscred/credential_test.go b/internal/fs/fscred/credential_test.go index 307f570..0557b3f 100644 --- a/internal/fs/fscred/credential_test.go +++ b/internal/fs/fscred/credential_test.go @@ -151,6 +151,23 @@ func TestResolveCredentialDerivesIDFromExplicitToken(t *testing.T) { } } +func TestExplicitTokenDoesNotInheritStoredTokenMetadata(t *testing.T) { + home := t.TempDir() + profile := credentialTestProfile() + localToken := wrappedToken(t, "tenant-token") + if _, err := StoreCredentialRecord(home, profile, Credential{FileSystemID: "tenant-token", RegionCode: "aws-us-east-1", APIKey: localToken, TokenID: "owner-id", ScopeKind: "owner", TokenName: "owner"}, false); err != nil { + t.Fatal(err) + } + explicitToken := wrappedTokenWithVersion(t, "tenant-token", 2) + selected, _, err := ResolveCredential(home, profile, ResolveCredentialOptions{Token: explicitToken, TokenExplicit: true, RegionOverride: "aws-us-east-1", TokenRequired: true}) + if err != nil { + t.Fatal(err) + } + if selected.FSTokenID != "" || selected.FSTokenScopeKind != "" || selected.FSTokenName != "" { + t.Fatalf("explicit token inherited local metadata: %#v", selected) + } +} + func TestResolveCredentialReportsMissingLocalTokenForKnownID(t *testing.T) { _, _, err := ResolveCredential(t.TempDir(), credentialTestProfile(), ResolveCredentialOptions{ FileSystemID: "tenant-without-token", FileSystemIDExplicit: true, TokenRequired: true, @@ -332,9 +349,13 @@ func TestMigrateNameRegistryPreflightsDestinationConflictsBeforeAnyWrite(t *test } func wrappedToken(t *testing.T, tenantID string) string { + return wrappedTokenWithVersion(t, tenantID, 1) +} + +func wrappedTokenWithVersion(t *testing.T, tenantID string, version int) string { t.Helper() header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - payloadBytes, err := json.Marshal(map[string]any{"tenant_id": tenantID, "token_version": 1, "iat": 1}) + payloadBytes, err := json.Marshal(map[string]any{"tenant_id": tenantID, "token_version": version, "iat": 1}) if err != nil { t.Fatal(err) } diff --git a/internal/fs/tokenmgmt/service.go b/internal/fs/tokenmgmt/service.go index 9e959b3..0243da6 100644 --- a/internal/fs/tokenmgmt/service.go +++ b/internal/fs/tokenmgmt/service.go @@ -8,9 +8,11 @@ import ( "fmt" "io" "net/http" + pathpkg "path" "strings" "text/tabwriter" "time" + "unicode/utf8" "github.com/tidbcloud/ti-cli/internal/api" "github.com/tidbcloud/ti-cli/internal/api/endpoints" @@ -58,9 +60,24 @@ type GenerateOptions struct { RegionOverride string } +type GenerateScopedOptions struct { + Profile *config.Profile + FileSystemID string + Token string + TokenExplicit bool + Subject string + TTL *time.Duration + Allows []string + StoreLocally bool + Replace bool + RegionOverride string +} + type ListOptions struct { Profile *config.Profile FileSystemID string + Token string + TokenExplicit bool IncludeExpired bool Offset int Limit int @@ -71,6 +88,8 @@ type MutationOptions struct { Profile *config.Profile FileSystemID string TokenID string + Token string + TokenExplicit bool RegionOverride string } @@ -97,6 +116,23 @@ type GenerateResult struct { PreviousTokenNote string `json:"previous_token_note,omitempty"` } +type TokenScope struct { + Prefix string `json:"prefix"` + Ops []string `json:"ops"` +} + +type GenerateScopedResult struct { + FileSystemID string `json:"file_system_id"` + TokenID string `json:"token_id"` + Subject string `json:"subject,omitempty"` + ScopeKind string `json:"scope_kind"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + Scopes []TokenScope `json:"scopes"` + FSToken string `json:"fs_token"` + CredentialsStored bool `json:"credentials_stored"` + PreviousTokenNote string `json:"previous_token_note,omitempty"` +} + type TokenMetadata struct { TokenID string `json:"token_id"` TokenName string `json:"token_name"` @@ -165,6 +201,102 @@ func (s Service) Generate(ctx context.Context, opts GenerateOptions) (GenerateRe return s.generate(ctx, opts, fileSystemID, tokenName, ttlSeconds) } +func (s Service) GenerateScoped(ctx context.Context, opts GenerateScopedOptions) (GenerateScopedResult, error) { + subject, ttlSeconds, scopes, err := validateGenerateScoped(opts) + if err != nil { + return GenerateScopedResult{}, err + } + if opts.StoreLocally { + fileSystemID := strings.TrimSpace(opts.FileSystemID) + if fileSystemID == "" { + token := strings.TrimSpace(opts.Token) + if token == "" { + token, _, _, err = envcompat.ResolveNames(nil, "TI_FS_TOKEN", envcompat.LegacyNameFor("TI_FS_TOKEN")) + if err != nil { + return GenerateScopedResult{}, err + } + } + if strings.TrimSpace(token) == "" { + return GenerateScopedResult{}, apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required when using a locally stored owner token") + } + fileSystemID, err = fscred.FileSystemIDFromToken(token) + if err != nil { + return GenerateScopedResult{}, err + } + } + var result GenerateScopedResult + err := fscred.WithCredentialLock(ctx, s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID, func() error { + resolved, resolveErr := s.resolveBearer(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if resolveErr != nil { + return resolveErr + } + var generateErr error + result, generateErr = s.generateScoped(ctx, opts, resolved, subject, ttlSeconds, scopes) + return generateErr + }) + return result, err + } + resolved, err := s.resolveBearer(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if err != nil { + return GenerateScopedResult{}, err + } + return s.generateScoped(ctx, opts, resolved, subject, ttlSeconds, scopes) +} + +func (s Service) generateScoped(ctx context.Context, opts GenerateScopedOptions, resolved bearerInput, subject string, ttlSeconds int64, scopes []TokenScope) (GenerateScopedResult, error) { + homeDir := s.homeDir(opts.Profile) + if opts.StoreLocally { + if err := fscred.PrepareCredentialTarget(homeDir, profileName(opts.Profile), resolved.fileSystemID); err != nil { + return GenerateScopedResult{}, apperr.Wrap("fs.token_store_preflight", "config", 1, "prepare local FS token storage", err) + } + if _, getErr := fscred.GetCredential(homeDir, profileName(opts.Profile), resolved.fileSystemID); getErr == nil && !opts.Replace { + return GenerateScopedResult{}, apperr.New("fs.token_local_conflict", "config", 2, fmt.Sprintf("a local token is already stored for file system %q; add --replace to select the scoped token locally", resolved.fileSystemID)) + } else if getErr != nil && apperr.CodeFor(getErr) != "fs.credential_not_found" { + return GenerateScopedResult{}, getErr + } + } + client, endpoint, err := s.bearerClient(opts.Profile, resolved, authz.FSTokenIssueScoped, "generate a scoped file system token") + if err != nil { + return GenerateScopedResult{}, err + } + requestScopes := make([]apifs.TokenScope, 0, len(scopes)) + for _, scope := range scopes { + requestScopes = append(requestScopes, apifs.TokenScope{Prefix: scope.Prefix, Ops: append([]string(nil), scope.Ops...)}) + } + response, err := client.IssueScopedToken(ctx, apifs.IssueScopedTokenRequest{Subject: subject, TTLSeconds: ttlSeconds, Scopes: requestScopes}) + if err != nil { + return GenerateScopedResult{}, err + } + responseFileSystemID, err := fscred.FileSystemIDFromToken(response.Token) + if err != nil || responseFileSystemID != resolved.fileSystemID { + return GenerateScopedResult{}, apperr.New("fs.token_response_mismatch", "api", 1, "scoped token response belongs to a different file system") + } + result := GenerateScopedResult{FileSystemID: resolved.fileSystemID, TokenID: response.TokenID, Subject: response.Subject, ScopeKind: response.ScopeKind, ExpiresAt: response.ExpiresAt, FSToken: response.Token, Scopes: make([]TokenScope, 0, len(response.Scopes))} + for _, scope := range response.Scopes { + result.Scopes = append(result.Scopes, TokenScope{Prefix: scope.Prefix, Ops: append([]string(nil), scope.Ops...)}) + } + if !opts.StoreLocally { + return result, nil + } + storedScopes := make([]fscred.TokenScope, 0, len(result.Scopes)) + for _, scope := range result.Scopes { + storedScopes = append(storedScopes, fscred.TokenScope{Prefix: scope.Prefix, Ops: append([]string(nil), scope.Ops...)}) + } + credential := fscred.Credential{FileSystemID: resolved.fileSystemID, RegionCode: endpoint.RegionName, APIKey: response.Token, TokenID: response.TokenID, ScopeKind: response.ScopeKind, TokenName: response.Subject, ExpiresAt: response.ExpiresAt, Scopes: storedScopes} + if _, storeErr := s.storeCredentialRecord(homeDir, opts.Profile, credential, opts.Replace); storeErr != nil { + rollbackErr := client.DeleteTokenWithBearer(ctx, response.TokenID) + if rollbackErr == nil { + return GenerateScopedResult{}, apperr.Wrap("fs.token_store_failed", "runtime", 1, "store generated scoped token locally; the generated remote token was revoked", storeErr) + } + return result, &PartialResultError{Code: "fs.token_partial_success", Message: "the scoped token was generated but local storage and remote rollback both failed; preserve fs_token from stdout, then import or revoke it explicitly", Result: result} + } + result.CredentialsStored = true + if opts.Replace { + result.PreviousTokenNote = "the previously selected remote token remains active until explicitly disabled or deleted" + } + return result, nil +} + func (s Service) generate(ctx context.Context, opts GenerateOptions, fileSystemID, tokenName string, ttlSeconds *int64) (GenerateResult, error) { homeDir := s.homeDir(opts.Profile) if opts.StoreLocally { @@ -228,11 +360,29 @@ func (s Service) List(ctx context.Context, opts ListOptions) (ListResult, error) if opts.Limit <= 0 || opts.Limit > MaxListLimit { return ListResult{}, apperr.New("fs.invalid_token_limit", "usage", 2, fmt.Sprintf("--limit must be between 1 and %d", MaxListLimit)) } - client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenList, "list file system tokens") + apiOpts := apifs.ListTokensOptions{FileSystemID: fileSystemID, IncludeExpired: opts.IncludeExpired, Offset: opts.Offset, Limit: opts.Limit} + var response apifs.ListTokensResponse + useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) if err != nil { return ListResult{}, err } - response, err := client.ListTokens(ctx, creds, apifs.ListTokensOptions{FileSystemID: fileSystemID, IncludeExpired: opts.IncludeExpired, Offset: opts.Offset, Limit: opts.Limit}) + if useBearer { + resolved, resolveErr := s.resolveBearer(opts.Profile, fileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if resolveErr != nil { + return ListResult{}, resolveErr + } + client, _, clientErr := s.bearerClient(opts.Profile, resolved, authz.FSTokenList, "list file system tokens") + if clientErr != nil { + return ListResult{}, clientErr + } + response, err = client.ListTokensWithBearer(ctx, apiOpts) + } else { + client, creds, _, clientErr := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenList, "list file system tokens") + if clientErr != nil { + return ListResult{}, clientErr + } + response, err = client.ListTokens(ctx, creds, apiOpts) + } if err != nil { return ListResult{}, err } @@ -272,11 +422,28 @@ func (s Service) setEnabled(ctx context.Context, opts MutationOptions, enabled b if enabled { permission, action = authz.FSTokenEnable, "enable a file system token" } - client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, action) + var response apifs.TokenMutationResponse + useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) if err != nil { return MutationResult{}, err } - response, err := client.SetTokenEnabled(ctx, creds, fileSystemID, tokenID, enabled) + if useBearer { + resolved, resolveErr := s.resolveBearer(opts.Profile, fileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if resolveErr != nil { + return MutationResult{}, resolveErr + } + client, _, clientErr := s.bearerClient(opts.Profile, resolved, permission, action) + if clientErr != nil { + return MutationResult{}, clientErr + } + response, err = client.SetTokenEnabledWithBearer(ctx, fileSystemID, tokenID, enabled) + } else { + client, creds, _, clientErr := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, action) + if clientErr != nil { + return MutationResult{}, clientErr + } + response, err = client.SetTokenEnabled(ctx, creds, fileSystemID, tokenID, enabled) + } if err != nil { return MutationResult{}, err } @@ -291,11 +458,29 @@ func (s Service) Delete(ctx context.Context, opts MutationOptions) (MutationResu if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { return MutationResult{}, err } - client, creds, _, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenDelete, "delete a file system token") + var response apifs.TokenMutationResponse + useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) if err != nil { return MutationResult{}, err } - response, err := client.DeleteToken(ctx, creds, fileSystemID, tokenID) + if useBearer { + resolved, resolveErr := s.resolveBearer(opts.Profile, fileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if resolveErr != nil { + return MutationResult{}, resolveErr + } + client, _, clientErr := s.bearerClient(opts.Profile, resolved, authz.FSTokenDelete, "delete a file system token") + if clientErr != nil { + return MutationResult{}, clientErr + } + err = client.DeleteTokenWithBearer(ctx, tokenID) + response = apifs.TokenMutationResponse{FileSystemID: fileSystemID, TokenID: tokenID, Status: "revoked"} + } else { + client, creds, _, clientErr := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, authz.FSTokenDelete, "delete a file system token") + if clientErr != nil { + return MutationResult{}, clientErr + } + response, err = client.DeleteToken(ctx, creds, fileSystemID, tokenID) + } if err != nil { return MutationResult{}, err } @@ -364,7 +549,7 @@ func (s Service) Refresh(ctx context.Context, opts RefreshOptions) (RefreshResul return result, err } -func (s Service) refreshRemote(ctx context.Context, profile *config.Profile, resolved refreshInput, ttl *time.Duration) (RefreshResult, error) { +func (s Service) refreshRemote(ctx context.Context, profile *config.Profile, resolved bearerInput, ttl *time.Duration) (RefreshResult, error) { ttlSeconds, err := optionalTTLSeconds(ttl) if err != nil { return RefreshResult{}, err @@ -393,64 +578,97 @@ func (s Service) refreshRemote(ctx context.Context, profile *config.Profile, res return RefreshResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, ScopeKind: response.ScopeKind, ExpiresAt: response.ExpiresAt, FSToken: response.Token}, nil } -type refreshInput struct { +type bearerInput struct { fileSystemID string token string regionCode string local bool + scopeKind string +} + +func (s Service) resolveRefresh(opts RefreshOptions) (bearerInput, error) { + return s.resolveBearer(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, false) } -func (s Service) resolveRefresh(opts RefreshOptions) (refreshInput, error) { - if opts.Profile == nil { - return refreshInput{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") +func (s Service) resolveBearer(profile *config.Profile, requestedFileSystemID, requestedToken string, tokenExplicit bool, regionOverride string, ownerRequired bool) (bearerInput, error) { + optsProfile := profile + if profile == nil { + return bearerInput{}, apperr.New("fs.missing_profile", "config", 2, "active profile is required") } - token := strings.TrimSpace(opts.Token) + token := strings.TrimSpace(requestedToken) sourceLocal := false - if opts.TokenExplicit && token == "" { - return refreshInput{}, apperr.New("fs.empty_token", "usage", 2, "--fs-token cannot be empty") + if tokenExplicit && token == "" { + return bearerInput{}, apperr.New("fs.empty_token", "usage", 2, "--fs-token cannot be empty") } if token == "" { envToken, _, _, err := envcompat.ResolveNames(nil, "TI_FS_TOKEN", envcompat.LegacyNameFor("TI_FS_TOKEN")) if err != nil { - return refreshInput{}, err + return bearerInput{}, err } token = strings.TrimSpace(envToken) } - fileSystemID := strings.TrimSpace(opts.FileSystemID) + fileSystemID := strings.TrimSpace(requestedFileSystemID) + var localCredential fscred.Credential if token == "" { if fileSystemID == "" { - return refreshInput{}, apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required when refresh uses a locally stored token") + return bearerInput{}, apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required when using a locally stored FS token") } - credential, err := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID) + credential, err := fscred.GetCredential(s.homeDir(profile), profileName(profile), fileSystemID) if err != nil { - return refreshInput{}, err + return bearerInput{}, err } + localCredential = credential token = credential.APIKey sourceLocal = true } tokenFileSystemID, err := fscred.FileSystemIDFromToken(token) if err != nil { - return refreshInput{}, err + return bearerInput{}, err } if fileSystemID == "" { fileSystemID = tokenFileSystemID } else if fileSystemID != tokenFileSystemID { - return refreshInput{}, apperr.New("fs.token_file_system_mismatch", "authentication", 3, fmt.Sprintf("FS token belongs to file system %q, not %q", tokenFileSystemID, fileSystemID)) + return bearerInput{}, apperr.New("fs.token_file_system_mismatch", "authentication", 3, fmt.Sprintf("FS token belongs to file system %q, not %q", tokenFileSystemID, fileSystemID)) } - regionCode := strings.TrimSpace(opts.RegionOverride) + if ownerRequired && sourceLocal && localCredential.ScopeKind == "fs_scoped" { + return bearerInput{}, apperr.New("fs.owner_token_required", "authorization", 4, "the selected local token is scoped and cannot generate another scoped token; pass an owner token with --fs-token or TI_FS_TOKEN") + } + regionCode := strings.TrimSpace(regionOverride) if sourceLocal { - credential, err := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID) - if err != nil { - return refreshInput{}, err - } if regionCode == "" { - regionCode = credential.RegionCode + regionCode = localCredential.RegionCode } } if regionCode == "" { - regionCode = opts.Profile.PlacementRegionCode + regionCode = optsProfile.PlacementRegionCode } - return refreshInput{fileSystemID: fileSystemID, token: token, regionCode: regionCode, local: sourceLocal}, nil + return bearerInput{fileSystemID: fileSystemID, token: token, regionCode: regionCode, local: sourceLocal, scopeKind: localCredential.ScopeKind}, nil +} + +func tokenInputPresent(token string, explicit bool) (bool, error) { + if explicit || strings.TrimSpace(token) != "" { + return true, nil + } + envToken, _, _, err := envcompat.ResolveNames(nil, "TI_FS_TOKEN", envcompat.LegacyNameFor("TI_FS_TOKEN")) + if err != nil { + return false, err + } + return strings.TrimSpace(envToken) != "", nil +} + +func (s Service) bearerClient(profile *config.Profile, resolved bearerInput, permission authz.Permission, action string) (*apifs.Client, endpoints.Endpoint, error) { + endpoint, err := s.resolveEndpoint(profile, resolved.regionCode) + if err != nil { + return nil, endpoints.Endpoint{}, err + } + raw, err := api.NewBearerClient(profileName(profile), resolved.token, endpoint, permission, api.Options{ + Action: action, HTTPClient: s.HTTPClient, Transport: s.Transport, Timeout: s.Timeout, + Debug: s.Debug, DebugWriter: s.DebugWriter, UserAgent: "ti fs token management", MaxRetries: -1, + }) + if err != nil { + return nil, endpoints.Endpoint{}, err + } + return apifs.New(raw), endpoint, nil } func (s Service) DryRunGenerate(commandPath string, opts GenerateOptions) (dryrun.Result, error) { @@ -472,6 +690,29 @@ func (s Service) DryRunGenerate(commandPath string, opts GenerateOptions) (dryru return tokenDryRun(commandPath, "generate_file_system_token", http.MethodPost, "/v1/tokens/generate", fileSystemID, opts.Profile, endpoint, authz.FSTokenGenerate), nil } +func (s Service) DryRunGenerateScoped(commandPath string, opts GenerateScopedOptions) (dryrun.Result, error) { + _, _, _, err := validateGenerateScoped(opts) + if err != nil { + return dryrun.Result{}, err + } + resolved, err := s.resolveBearer(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if err != nil { + return dryrun.Result{}, err + } + if opts.StoreLocally { + if _, getErr := fscred.GetCredential(s.homeDir(opts.Profile), profileName(opts.Profile), resolved.fileSystemID); getErr == nil && !opts.Replace { + return dryrun.Result{}, apperr.New("fs.token_local_conflict", "config", 2, "a local token is already stored; add --replace") + } else if getErr != nil && apperr.CodeFor(getErr) != "fs.credential_not_found" { + return dryrun.Result{}, getErr + } + } + endpoint, err := s.resolveEndpoint(opts.Profile, resolved.regionCode) + if err != nil { + return dryrun.Result{}, err + } + return tokenDryRun(commandPath, "generate_file_system_scoped_token", http.MethodPost, "/v1/tokens", resolved.fileSystemID, opts.Profile, endpoint, authz.FSTokenIssueScoped), nil +} + func (s Service) DryRunMutation(commandPath, operation, method, path string, opts MutationOptions, permission authz.Permission, mountGuard bool) (dryrun.Result, error) { fileSystemID, tokenID, err := validateMutation(opts) if err != nil { @@ -482,7 +723,20 @@ func (s Service) DryRunMutation(commandPath, operation, method, path string, opt return dryrun.Result{}, err } } - _, _, endpoint, err := s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, operation) + var endpoint endpoints.Endpoint + useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) + if err != nil { + return dryrun.Result{}, err + } + if useBearer { + resolved, resolveErr := s.resolveBearer(opts.Profile, fileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, true) + if resolveErr != nil { + return dryrun.Result{}, resolveErr + } + endpoint, err = s.resolveEndpoint(opts.Profile, resolved.regionCode) + } else { + _, _, endpoint, err = s.controlClient(opts.Profile, fileSystemID, opts.RegionOverride, permission, operation) + } if err != nil { return dryrun.Result{}, err } @@ -629,6 +883,116 @@ func validateGenerate(opts GenerateOptions) (string, string, *int64, error) { return fileSystemID, tokenName, ttlSeconds, nil } +func validateGenerateScoped(opts GenerateScopedOptions) (string, int64, []TokenScope, error) { + subject := strings.TrimSpace(opts.Subject) + if len(subject) > 64 { + return "", 0, nil, apperr.New("fs.invalid_token_subject", "usage", 2, "--subject must be at most 64 bytes") + } + if opts.TTL == nil { + return "", 0, nil, apperr.New("fs.token_ttl_required", "usage", 2, "--ttl is required for a scoped token") + } + ttlSeconds, err := scopedTTLSeconds(opts.TTL) + if err != nil { + return "", 0, nil, err + } + if len(opts.Allows) == 0 { + return "", 0, nil, apperr.New("fs.token_scope_required", "usage", 2, "at least one --allow : is required") + } + if opts.Replace && !opts.StoreLocally { + return "", 0, nil, apperr.New("fs.token_replace_without_store", "usage", 2, "--replace requires --store-locally") + } + scopes := make([]TokenScope, 0, len(opts.Allows)) + seenPrefixes := make(map[string]struct{}, len(opts.Allows)) + for _, raw := range opts.Allows { + scope, parseErr := parseAllow(raw) + if parseErr != nil { + return "", 0, nil, parseErr + } + if _, exists := seenPrefixes[scope.Prefix]; exists { + return "", 0, nil, apperr.New("fs.duplicate_token_scope", "usage", 2, fmt.Sprintf("duplicate --allow prefix %q", scope.Prefix)) + } + seenPrefixes[scope.Prefix] = struct{}{} + scopes = append(scopes, scope) + } + return subject, *ttlSeconds, scopes, nil +} + +func parseAllow(raw string) (TokenScope, error) { + idx := strings.LastIndex(raw, ":") + if idx <= 0 || idx == len(raw)-1 { + return TokenScope{}, apperr.New("fs.invalid_token_scope", "usage", 2, fmt.Sprintf("invalid --allow %q: expected :", raw)) + } + prefix, err := canonicalScopePrefix(raw[:idx]) + if err != nil { + return TokenScope{}, err + } + ops, err := parseScopeOps(raw[idx+1:]) + if err != nil { + return TokenScope{}, err + } + return TokenScope{Prefix: prefix, Ops: ops}, nil +} + +func canonicalScopePrefix(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || raw == ":" { + return "", apperr.New("fs.invalid_token_scope", "usage", 2, "scope prefix is required") + } + raw = strings.TrimPrefix(raw, ":") + for i := 0; i < len(raw); i++ { + b := raw[i] + if b == 0 || (b < 0x20 && b != '\n' && b != '\t' && b != '\r') { + return "", apperr.New("fs.invalid_token_scope", "usage", 2, "scope prefix contains an unsupported control character") + } + } + if strings.ContainsRune(raw, '\\') || !utf8.ValidString(raw) { + return "", apperr.New("fs.invalid_token_scope", "usage", 2, "scope prefix must be valid UTF-8 and cannot contain backslashes") + } + if !strings.HasPrefix(raw, "/") { + raw = "/" + raw + } + for _, segment := range strings.Split(strings.Trim(raw, "/"), "/") { + if segment == "." || segment == ".." { + return "", apperr.New("fs.invalid_token_scope", "usage", 2, "scope prefix cannot contain . or .. path segments") + } + } + prefix := pathpkg.Clean(raw) + if prefix == "." { + prefix = "/" + } + if prefix == "/" { + return prefix, nil + } + return strings.TrimSuffix(prefix, "/"), nil +} + +func parseScopeOps(raw string) ([]string, error) { + seen := make(map[string]struct{}) + for _, item := range strings.Split(raw, ",") { + op := strings.TrimSpace(item) + switch op { + case "read", "list", "search", "write", "delete": + seen[op] = struct{}{} + case "": + return nil, apperr.New("fs.invalid_token_scope", "usage", 2, "scope operations cannot contain an empty value") + default: + return nil, apperr.New("fs.invalid_token_scope", "usage", 2, fmt.Sprintf("unknown scope operation %q; use read, list, search, write, or delete", op)) + } + } + if _, search := seen["search"]; search { + if _, read := seen["read"]; !read { + return nil, apperr.New("fs.invalid_token_scope", "usage", 2, "the search operation requires read") + } + } + ops := make([]string, 0, len(seen)) + for _, op := range []string{"read", "list", "search", "write", "delete"} { + if _, ok := seen[op]; ok { + ops = append(ops, op) + } + } + return ops, nil +} + func validateMutation(opts MutationOptions) (string, string, error) { fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) if err != nil { @@ -661,6 +1025,20 @@ func optionalTTLSeconds(ttl *time.Duration) (*int64, error) { return &seconds, nil } +func scopedTTLSeconds(ttl *time.Duration) (*int64, error) { + if ttl == nil { + return nil, apperr.New("fs.token_ttl_required", "usage", 2, "--ttl is required for a scoped token") + } + if *ttl <= 0 { + return nil, apperr.New("fs.token_ttl_invalid", "usage", 2, "--ttl must be positive") + } + if *ttl%time.Second != 0 { + return nil, apperr.New("fs.token_ttl_invalid", "usage", 2, "--ttl must resolve to whole seconds") + } + seconds := int64(*ttl / time.Second) + return &seconds, nil +} + func mapGenerate(response apifs.GenerateTokenResponse) GenerateResult { return GenerateResult{FileSystemID: response.FileSystemID, TokenID: response.TokenID, TokenName: response.TokenName, ScopeKind: response.ScopeKind, Status: response.Status, IssuedAt: response.IssuedAt, ExpiresAt: response.ExpiresAt, FSToken: response.Token} } diff --git a/internal/fs/tokenmgmt/service_test.go b/internal/fs/tokenmgmt/service_test.go index 9d74bf9..4e6797e 100644 --- a/internal/fs/tokenmgmt/service_test.go +++ b/internal/fs/tokenmgmt/service_test.go @@ -42,6 +42,10 @@ func TestTokenTTLValidation(t *testing.T) { if _, _, _, err := validateGenerate(base); apperr.CodeFor(err) != "fs.token_lifetime_required" { t.Fatalf("both lifetimes = %v", err) } + longScopedTTL := MaxTTL + time.Second + if seconds, err := scopedTTLSeconds(&longScopedTTL); err != nil || seconds == nil || *seconds != int64(longScopedTTL/time.Second) { + t.Fatalf("scopedTTLSeconds(%s) = %v, %v", longScopedTTL, seconds, err) + } } func TestGenerateAndListMapBackendFieldsAndStoreMetadata(t *testing.T) { @@ -95,6 +99,78 @@ func TestGenerateAndListMapBackendFieldsAndStoreMetadata(t *testing.T) { } } +func TestGenerateScopedUsesOwnerBearerAndStoresScopes(t *testing.T) { + home := t.TempDir() + ownerToken := wrappedToken(t, "fs-1", 1) + scopedToken := wrappedToken(t, "fs-1", 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/tokens" || r.Header.Get("Authorization") != "Bearer "+ownerToken { + t.Errorf("request = %s %s auth=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) + } + if r.Header.Get("X-TiDBCloud-Public-Key") != "" { + t.Error("scoped issue included TiDB Cloud credentials") + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["subject"] != "sandbox" || body["ttl_seconds"] != float64(3600) { + t.Errorf("body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"token":` + jsonString(scopedToken) + `,"token_id":"scoped-1","subject":"sandbox","scope_kind":"fs_scoped","expires_at":"2026-08-13T00:00:00Z","scopes":[{"prefix":"/workspace","ops":["read","list"]}]}`)) + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: ownerToken, TokenID: "owner-1", ScopeKind: "owner"}, false); err != nil { + t.Fatal(err) + } + ttl := time.Hour + result, err := service.GenerateScoped(context.Background(), GenerateScopedOptions{Profile: profile, FileSystemID: "fs-1", Subject: "sandbox", TTL: &ttl, Allows: []string{"/workspace:read,list"}, StoreLocally: true, Replace: true}) + if err != nil { + t.Fatal(err) + } + if result.ScopeKind != "fs_scoped" || !result.CredentialsStored || len(result.Scopes) != 1 || result.Scopes[0].Prefix != "/workspace" { + t.Fatalf("result = %#v", result) + } + credential, err := fscred.GetCredential(home, profile.Name, "fs-1") + if err != nil || credential.APIKey != scopedToken || credential.ScopeKind != "fs_scoped" || len(credential.Scopes) != 1 || strings.Join(credential.Scopes[0].Ops, ",") != "read,list" { + t.Fatalf("credential = %#v, %v", credential, err) + } +} + +func TestGenerateScopedValidationAndKnownScopedOwnerRejection(t *testing.T) { + ttl := time.Hour + base := GenerateScopedOptions{TTL: &ttl} + for _, tc := range []struct { + allow string + code string + }{ + {"", "fs.token_scope_required"}, + {"/workspace:search", "fs.invalid_token_scope"}, + {"/workspace:unknown", "fs.invalid_token_scope"}, + {"/workspace/../secret:read", "fs.invalid_token_scope"}, + } { + opts := base + if tc.allow != "" { + opts.Allows = []string{tc.allow} + } + if _, _, _, err := validateGenerateScoped(opts); apperr.CodeFor(err) != tc.code { + t.Fatalf("allow %q error = %v", tc.allow, err) + } + } + home := t.TempDir() + service, profile := tokenTestService(home, "http://127.0.0.1:1") + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: wrappedToken(t, "fs-1", 1), ScopeKind: "fs_scoped"}, false); err != nil { + t.Fatal(err) + } + _, err := service.GenerateScoped(context.Background(), GenerateScopedOptions{Profile: profile, FileSystemID: "fs-1", TTL: &ttl, Allows: []string{"/workspace:read"}}) + if apperr.CodeFor(err) != "fs.owner_token_required" { + t.Fatalf("known scoped token error = %v", err) + } +} + func TestGenerateStoreConflictPreventsRemoteMutation(t *testing.T) { home := t.TempDir() requests := 0 @@ -195,7 +271,7 @@ func TestGenerateStoreAndRollbackFailureReturnsOneTimeSecret(t *testing.T) { } } -func TestLocalRefreshAtomicallyReplacesCredential(t *testing.T) { +func TestLocalRefreshAtomicallyReplacesCredentialAndPreservesScopes(t *testing.T) { home := t.TempDir() oldToken := wrappedToken(t, "fs-1", 1) newToken := wrappedToken(t, "fs-1", 2) @@ -204,11 +280,11 @@ func TestLocalRefreshAtomicallyReplacesCredential(t *testing.T) { t.Errorf("refresh authentication = %#v", r.Header) } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","scope_kind":"owner","expires_at":null}`)) + _, _ = w.Write([]byte(`{"token":` + jsonString(newToken) + `,"token_id":"token-1","tenant_id":"fs-1","scope_kind":"fs_scoped","expires_at":"2026-08-14T00:00:00Z"}`)) })) defer server.Close() service, profile := tokenTestService(home, server.URL) - if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: oldToken, TokenID: "token-1", TokenName: "preserved", ScopeKind: "owner"}, false); err != nil { + if _, err := fscred.StoreCredentialRecord(home, profile, fscred.Credential{FileSystemID: "fs-1", RegionCode: "aws-us-east-1", APIKey: oldToken, TokenID: "token-1", TokenName: "preserved", ScopeKind: "fs_scoped", Scopes: []fscred.TokenScope{{Prefix: "/workspace", Ops: []string{"read", "list"}}}}, false); err != nil { t.Fatal(err) } result, err := service.Refresh(context.Background(), RefreshOptions{Profile: profile, FileSystemID: "fs-1"}) @@ -222,7 +298,7 @@ func TestLocalRefreshAtomicallyReplacesCredential(t *testing.T) { if err != nil { t.Fatal(err) } - if credential.APIKey != newToken || credential.TokenName != "preserved" || credential.TokenID != "token-1" { + if credential.APIKey != newToken || credential.TokenName != "preserved" || credential.TokenID != "token-1" || credential.ScopeKind != "fs_scoped" || len(credential.Scopes) != 1 || credential.Scopes[0].Prefix != "/workspace" { t.Fatalf("credential = %#v", credential) } }