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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ ti fs generate-file-system-scoped-token \

`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.

An owner FS token authorizes Filesystem use and token management, but it is not a TiDB Cloud administrative credential. Creating, listing, describing, and deleting Filesystem resources require TiDB Cloud API keys. In particular, `ti fs delete-file-system` always requires an explicit `--file-system-id`; `TI_FS_TOKEN` cannot select or authorize deletion of the Filesystem itself. For token list, enable, disable, and delete commands, `--file-system-id` is required only when the command uses TiDB Cloud API keys. When an owner token is supplied through `--fs-token` or `TI_FS_TOKEN`, `ti` derives the Filesystem ID from that token.

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
Expand Down
42 changes: 39 additions & 3 deletions e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,15 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi
t.Fatalf("direct control-plane requests were incomplete: east=%#v west=%#v", eastControl.requests, westControl.requests)
}

deleteScratch := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "delete-file-system", "--file-system-id", "tenant-aws-us-west-2")
requestsBeforeTokenOnlyDelete := westControl.requestCount()
tokenOnlyDelete := runTIWithInput(t, bin, "", append(baseEnv, "TI_FS_TOKEN="+drive9TestToken("tenant-aws-us-west-2")), "--profile", "stage", "--region", "aws-us-west-2", "fs", "delete-file-system")
tokenOnlyDelete.wantExitCode(2)
tokenOnlyDelete.wantStderrContains("FS tokens cannot select or authorize file system deletion")
if got := westControl.requestCount(); got != requestsBeforeTokenOnlyDelete {
t.Fatalf("token-only delete sent a remote request: before=%d after=%d", requestsBeforeTokenOnlyDelete, got)
}

deleteScratch := runTIWithInput(t, bin, "", append(baseEnv, "TI_FS_TOKEN="+drive9TestToken("tenant-aws-us-west-2")), "--profile", "stage", "--region", "aws-us-west-2", "fs", "delete-file-system", "--file-system-id", "tenant-aws-us-west-2")
deleteScratch.wantExitCode(0)
deleteScratch.wantStdoutContains(`"status": "deleting"`)
afterDelete := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems")
Expand Down Expand Up @@ -1052,6 +1060,16 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) {
_, _ = 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/token-scoped/deactivate":
if r.Header.Get("Authorization") != "Bearer "+generatedToken || r.Header.Get("X-TiDBCloud-Public-Key") != "" {
t.Errorf("scoped deactivate authentication headers = %#v", r.Header)
}
_, _ = fmt.Fprint(w, `{"token_id":"token-scoped","tenant_id":"tenant-tokens","status":"disabled"}`)
case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/token-scoped/activate":
if r.Header.Get("Authorization") != "Bearer "+generatedToken || r.Header.Get("X-TiDBCloud-Public-Key") != "" {
t.Errorf("scoped activate authentication headers = %#v", r.Header)
}
_, _ = fmt.Fprint(w, `{"token_id":"token-scoped","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)
Expand Down Expand Up @@ -1110,8 +1128,20 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) {
scopedText.wantStdoutNotContains(`"scope_kind"`)

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 := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "list-file-system-tokens")
bearerListed.wantExitCode(0)
configFreeBearerEnv := []string{
"HOME=" + t.TempDir(), "TI_ALLOW_TEST_ENDPOINTS=1", "TI_TEST_FS_MANIFEST_URL=" + manifestServer.URL,
"TI_REGION_CODE=aws-us-east-1", "TI_FS_TOKEN=" + generatedToken,
}
configFreeBearerList := runTIWithInput(t, bin, "", configFreeBearerEnv, "fs", "list-file-system-tokens")
configFreeBearerList.wantExitCode(0)
bearerDisabled := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "disable-file-system-token", "--token-id", "token-scoped")
bearerDisabled.wantExitCode(0)
bearerDisabled.wantStdoutContains(`"status": "disabled"`)
bearerEnabled := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "enable-file-system-token", "--token-id", "token-scoped")
bearerEnabled.wantExitCode(0)
bearerEnabled.wantStdoutContains(`"status": "active"`)

listed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens", "--output", "text")
listed.wantExitCode(0)
Expand Down Expand Up @@ -1210,7 +1240,7 @@ func (f *fakeFSTenantControlPlane) serveHTTP(w http.ResponseWriter, r *http.Requ
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, fakeFSTenantRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery})
if r.Header.Get("X-TiDBCloud-Public-Key") != "e2e-public" || r.Header.Get("X-TiDBCloud-Private-Key") != "e2e-private" {
if r.Header.Get("X-TiDBCloud-Public-Key") != "e2e-public" || r.Header.Get("X-TiDBCloud-Private-Key") != "e2e-private" || r.Header.Get("Authorization") != "" {
http.Error(w, `{"error":"missing TiDB Cloud credentials"}`, http.StatusUnauthorized)
return
}
Expand Down Expand Up @@ -1280,6 +1310,12 @@ func (f *fakeFSTenantControlPlane) serveHTTP(w http.ResponseWriter, r *http.Requ
}
}

func (f *fakeFSTenantControlPlane) requestCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.requests)
}

func (f *fakeFSTenantControlPlane) hasRequest(method, path string, queryParts ...string) bool {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
13 changes: 6 additions & 7 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,7 @@ func newFSGenerateFileSystemTokenCommand(info version.Info) *cobra.Command {
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("file-system-id", "", "The file system ID that owns the generated token. Owner token generation requires TiDB Cloud API credentials.")
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.")
Expand Down Expand Up @@ -1024,12 +1024,11 @@ func newFSListFileSystemTokensCommand(info version.Info) *cobra.Command {
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("file-system-id", "", "The file system ID whose tokens are listed. Required with TiDB Cloud API credentials; optional with an owner FS token.")
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).")
markUsageRequired(cmd, "file-system-id")
return cmd
}

Expand Down Expand Up @@ -1088,10 +1087,10 @@ func newFSTokenMutationCommand(use, short, operation, method, path string, permi
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("file-system-id", "", "The file system ID that owns the token. Required with TiDB Cloud API credentials; optional with an owner FS 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")
markUsageRequired(cmd, "token-id")
return cmd
}

Expand Down Expand Up @@ -1365,7 +1364,7 @@ func newFSDescribeFileSystemCommand(info version.Info) *cobra.Command {
return service.DescribeFileSystem(ctx.cmd.Context(), profile, fileSystemID)
},
}, info)
cmd.Flags().String("file-system-id", "", "The file system ID.")
cmd.Flags().String("file-system-id", "", "The file system ID. Describing a file system requires TiDB Cloud API credentials.")
markUsageRequired(cmd, "file-system-id")
return cmd
}
Expand Down Expand Up @@ -1405,7 +1404,7 @@ func newFSDeleteFileSystemCommand(info version.Info) *cobra.Command {
})
},
}, info)
cmd.Flags().String("file-system-id", "", "The file system ID.")
cmd.Flags().String("file-system-id", "", "The file system ID. FS tokens cannot select or authorize file system deletion.")
markUsageRequired(cmd, "file-system-id")
return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/fs/fscred/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ func FileSystemIDFromToken(raw string) (string, error) {
func ValidateFileSystemID(value string) (string, error) {
id := strings.TrimSpace(value)
if id == "" {
return "", apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required unless an FS token is supplied")
return "", apperr.New("fs.missing_file_system_id", "usage", 2, "file system ID is required")
}
if len(id) > 128 || strings.ContainsAny(id, "/\\") {
return "", apperr.New("fs.invalid_file_system_id", "usage", 2, "file system ID must be 1-128 characters and must not contain path separators")
Expand Down
10 changes: 10 additions & 0 deletions internal/fs/fscred/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,16 @@ func TestMigrateNameRegistryPreflightsDestinationConflictsBeforeAnyWrite(t *test
}
}

func TestValidateFileSystemIDUsesContextNeutralMissingError(t *testing.T) {
_, err := ValidateFileSystemID("")
if apperr.CodeFor(err) != "fs.missing_file_system_id" || err.Error() != "file system ID is required" {
t.Fatalf("missing ID error = %v", err)
}
if strings.Contains(strings.ToLower(err.Error()), "token") {
t.Fatalf("low-level ID validation described an authentication policy: %v", err)
}
}

func wrappedToken(t *testing.T, tenantID string) string {
return wrappedTokenWithVersion(t, tenantID, 1)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/fs/tenant_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ func (s Service) adminDeleteInputs(opts DeleteFileSystemOptions) (string, *apifs
}

func (s Service) adminItemInputs(profile *config.Profile, fileSystemID string, permission authz.Permission, action string) (string, *apifs.Client, apifs.TiDBCloudCredentials, error) {
if strings.TrimSpace(fileSystemID) == "" {
message := "--file-system-id is required for describe-file-system; describing a file system requires TiDB Cloud API credentials"
if permission == authz.FSVolumeDelete {
message = "--file-system-id is required for delete-file-system; FS tokens cannot select or authorize file system deletion"
}
return "", nil, apifs.TiDBCloudCredentials{}, apperr.New("fs.missing_file_system_id", "usage", 2, message)
}
id, err := fscred.ValidateFileSystemID(fileSystemID)
if err != nil {
return "", nil, apifs.TiDBCloudCredentials{}, err
Expand Down
34 changes: 34 additions & 0 deletions internal/fs/tenant_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,37 @@ func TestTenantControlDescribeAndDeleteUseIDs(t *testing.T) {
}
}

func TestTenantControlDescribeAndDeleteRequireExplicitIDAndTiDBCloudCredentials(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
requests++
}))
defer server.Close()
service := directTenantService(t.TempDir(), server.URL)
profile := testProfile()
t.Setenv("TI_FS_TOKEN", fsTestToken(t, "tenant-from-token"))

if _, err := service.DescribeFileSystem(context.Background(), profile, ""); apperr.CodeFor(err) != "fs.missing_file_system_id" || !strings.Contains(err.Error(), "describing a file system requires TiDB Cloud API credentials") {
t.Fatalf("missing describe ID error = %v", err)
}
if _, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: profile}); apperr.CodeFor(err) != "fs.missing_file_system_id" || !strings.Contains(err.Error(), "FS tokens cannot select or authorize file system deletion") {
t.Fatalf("missing delete ID error = %v", err)
}
if requests != 0 {
t.Fatalf("missing IDs sent %d remote requests", requests)
}

withoutCredentials := *profile
withoutCredentials.TiDBCloudPublicKey = ""
withoutCredentials.TiDBCloudPrivateKey = ""
if _, err := service.DeleteFileSystem(context.Background(), DeleteFileSystemOptions{Profile: &withoutCredentials, FileSystemID: "tenant-1"}); apperr.CodeFor(err) != "auth.missing_credentials" {
t.Fatalf("missing TiDB Cloud credentials error = %v", err)
}
if requests != 0 {
t.Fatalf("missing credentials sent %d remote requests", requests)
}
}

func TestTenantControlValidationRejectsInvalidMetadataBeforeNetwork(t *testing.T) {
validDisplay, labels, err := ParseTenantMetadata("agent-workspace", true, []string{"environment=production", "example.com/empty="})
if err != nil || validDisplay == nil || labels["example.com/empty"] != "" {
Expand Down Expand Up @@ -351,6 +382,9 @@ func assertAdminCredentialHeaders(t *testing.T, request *http.Request) {
if request.Header.Get("X-TiDBCloud-Public-Key") != "public" || request.Header.Get("X-TiDBCloud-Private-Key") != "private" {
t.Fatalf("credential headers = %q/%q", request.Header.Get("X-TiDBCloud-Public-Key"), request.Header.Get("X-TiDBCloud-Private-Key"))
}
if request.Header.Get("Authorization") != "" {
t.Fatalf("control-plane request unexpectedly used bearer authorization: %q", request.Header.Get("Authorization"))
}
}

func tenantQuotaFixture() map[string]any {
Expand Down
Loading