From 4c851894e379cfdd9fa96a9d52ecccd0e39291aa Mon Sep 17 00:00:00 2001 From: Cheese Date: Wed, 19 Aug 2026 11:21:08 +0800 Subject: [PATCH 1/2] fix(fs): enforce resource and token auth boundaries --- README.md | 2 + e2e/cli_test.go | 42 +++++- internal/cli/commands.go | 13 +- internal/fs/fscred/credential.go | 2 +- internal/fs/fscred/credential_test.go | 10 ++ internal/fs/tenant_control.go | 7 + internal/fs/tenant_control_test.go | 34 +++++ internal/fs/tokenmgmt/service.go | 191 +++++++++++++------------- internal/fs/tokenmgmt/service_test.go | 75 ++++++++++ 9 files changed, 267 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 0c95d14..02c3802 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/e2e/cli_test.go b/e2e/cli_test.go index be6c5f3..fc54431 100644 --- a/e2e/cli_test.go +++ b/e2e/cli_test.go @@ -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") @@ -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) @@ -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) @@ -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 } @@ -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() diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 9b819f5..1da4f09 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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.") @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/internal/fs/fscred/credential.go b/internal/fs/fscred/credential.go index 14b4dad..81f496f 100644 --- a/internal/fs/fscred/credential.go +++ b/internal/fs/fscred/credential.go @@ -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") diff --git a/internal/fs/fscred/credential_test.go b/internal/fs/fscred/credential_test.go index a93ff56..0b9c917 100644 --- a/internal/fs/fscred/credential_test.go +++ b/internal/fs/fscred/credential_test.go @@ -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) } diff --git a/internal/fs/tenant_control.go b/internal/fs/tenant_control.go index 39fddb8..686a7ad 100644 --- a/internal/fs/tenant_control.go +++ b/internal/fs/tenant_control.go @@ -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 diff --git a/internal/fs/tenant_control_test.go b/internal/fs/tenant_control_test.go index 744a898..9eb799b 100644 --- a/internal/fs/tenant_control_test.go +++ b/internal/fs/tenant_control_test.go @@ -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"] != "" { @@ -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 { diff --git a/internal/fs/tokenmgmt/service.go b/internal/fs/tokenmgmt/service.go index 272a7c8..ff15b70 100644 --- a/internal/fs/tokenmgmt/service.go +++ b/internal/fs/tokenmgmt/service.go @@ -350,45 +350,29 @@ func (s Service) generate(ctx context.Context, opts GenerateOptions, fileSystemI } 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)) } - 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) + management, err := s.resolveManagementAuth(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, authz.FSTokenList, "list file system tokens") if err != nil { return ListResult{}, err } - 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) + apiOpts := apifs.ListTokensOptions{FileSystemID: management.fileSystemID, IncludeExpired: opts.IncludeExpired, Offset: opts.Offset, Limit: opts.Limit} + var response apifs.ListTokensResponse + if management.bearer { + response, err = management.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) + response, err = management.client.ListTokens(ctx, management.credentials, apiOpts) } if err != nil { return ListResult{}, err } - result := ListResult{FileSystemID: fileSystemID, Tokens: make([]TokenMetadata, 0, len(response.Tokens)), NextOffset: response.NextOffset} + result := ListResult{FileSystemID: management.fileSystemID, Tokens: make([]TokenMetadata, 0, len(response.Tokens)), NextOffset: response.NextOffset} for _, item := range response.Tokens { - if item.FileSystemID != "" && item.FileSystemID != fileSystemID { + if item.FileSystemID != "" && item.FileSystemID != management.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{ @@ -409,40 +393,28 @@ func (s Service) Disable(ctx context.Context, opts MutationOptions) (MutationRes } func (s Service) setEnabled(ctx context.Context, opts MutationOptions, enabled bool) (MutationResult, error) { - fileSystemID, tokenID, err := validateMutation(opts) + tokenID, err := validateTokenID(opts.TokenID) 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" } - var response apifs.TokenMutationResponse - useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) + management, err := s.resolveManagementAuth(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, permission, action) if err != nil { return MutationResult{}, err } - 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 + if !enabled { + if err := s.guardTokenIDMount(management.fileSystemID, tokenID); err != nil { + return MutationResult{}, err } - response, err = client.SetTokenEnabledWithBearer(ctx, fileSystemID, tokenID, enabled) + } + var response apifs.TokenMutationResponse + if management.bearer { + response, err = management.client.SetTokenEnabledWithBearer(ctx, management.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) + response, err = management.client.SetTokenEnabled(ctx, management.credentials, management.fileSystemID, tokenID, enabled) } if err != nil { return MutationResult{}, err @@ -451,44 +423,32 @@ func (s Service) setEnabled(ctx context.Context, opts MutationOptions, enabled b } func (s Service) Delete(ctx context.Context, opts MutationOptions) (MutationResult, error) { - fileSystemID, tokenID, err := validateMutation(opts) + tokenID, err := validateTokenID(opts.TokenID) if err != nil { return MutationResult{}, err } - if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { + management, err := s.resolveManagementAuth(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, authz.FSTokenDelete, "delete a file system token") + if err != nil { return MutationResult{}, err } - var response apifs.TokenMutationResponse - useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) - if err != nil { + if err := s.guardTokenIDMount(management.fileSystemID, tokenID); err != nil { return MutationResult{}, err } - 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"} + var response apifs.TokenMutationResponse + if management.bearer { + err = management.client.DeleteTokenWithBearer(ctx, tokenID) + response = apifs.TokenMutationResponse{FileSystemID: management.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) + response, err = management.client.DeleteToken(ctx, management.credentials, management.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 { + cleanupErr := fscred.WithCredentialLock(ctx, s.homeDir(opts.Profile), profileName(opts.Profile), management.fileSystemID, func() error { var err error - updated, reason, err = fscred.DeleteCredentialIfTokenID(s.homeDir(opts.Profile), profileName(opts.Profile), fileSystemID, tokenID) + updated, reason, err = fscred.DeleteCredentialIfTokenID(s.homeDir(opts.Profile), profileName(opts.Profile), management.fileSystemID, tokenID) return err }) if cleanupErr != nil { @@ -586,6 +546,14 @@ type bearerInput struct { scopeKind string } +type managementAuth struct { + fileSystemID string + client *apifs.Client + credentials apifs.TiDBCloudCredentials + endpoint endpoints.Endpoint + bearer bool +} + func (s Service) resolveRefresh(opts RefreshOptions) (bearerInput, error) { return s.resolveBearer(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, false) } @@ -608,6 +576,13 @@ func (s Service) resolveBearer(profile *config.Profile, requestedFileSystemID, r token = strings.TrimSpace(envToken) } fileSystemID := strings.TrimSpace(requestedFileSystemID) + if fileSystemID == "" { + envFileSystemID, _, _, err := envcompat.ResolveNames(nil, "TI_FS_FILE_SYSTEM_ID", envcompat.LegacyNameFor("TI_FS_FILE_SYSTEM_ID")) + if err != nil { + return bearerInput{}, err + } + fileSystemID = strings.TrimSpace(envFileSystemID) + } var localCredential fscred.Credential if token == "" { if fileSystemID == "" { @@ -631,7 +606,7 @@ func (s Service) resolveBearer(profile *config.Profile, requestedFileSystemID, r return bearerInput{}, apperr.New("fs.token_file_system_mismatch", "authentication", 3, fmt.Sprintf("FS token belongs to file system %q, not %q", tokenFileSystemID, fileSystemID)) } 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") + return bearerInput{}, apperr.New("fs.owner_token_required", "authorization", 4, "the selected local token is scoped and cannot manage file system tokens; pass an owner token with --fs-token or TI_FS_TOKEN") } regionCode := strings.TrimSpace(regionOverride) if sourceLocal { @@ -671,6 +646,40 @@ func (s Service) bearerClient(profile *config.Profile, resolved bearerInput, per return apifs.New(raw), endpoint, nil } +func (s Service) resolveManagementAuth(profile *config.Profile, requestedFileSystemID, token string, tokenExplicit bool, regionOverride string, permission authz.Permission, action string) (managementAuth, error) { + useBearer, err := tokenInputPresent(token, tokenExplicit) + if err != nil { + return managementAuth{}, err + } + if useBearer { + resolved, err := s.resolveBearer(profile, requestedFileSystemID, token, tokenExplicit, regionOverride, true) + if err != nil { + return managementAuth{}, err + } + client, endpoint, err := s.bearerClient(profile, resolved, permission, action) + if err != nil { + return managementAuth{}, err + } + return managementAuth{fileSystemID: resolved.fileSystemID, client: client, endpoint: endpoint, bearer: true}, nil + } + fileSystemID, err := requireControlPlaneTokenManagementID(requestedFileSystemID) + if err != nil { + return managementAuth{}, err + } + client, credentials, endpoint, err := s.controlClient(profile, fileSystemID, regionOverride, permission, action) + if err != nil { + return managementAuth{}, err + } + return managementAuth{fileSystemID: fileSystemID, client: client, credentials: credentials, endpoint: endpoint}, nil +} + +func requireControlPlaneTokenManagementID(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required when token management uses TiDB Cloud API credentials; alternatively pass --fs-token or set TI_FS_TOKEN to use an owner FS token") + } + return fscred.ValidateFileSystemID(value) +} + func (s Service) DryRunGenerate(commandPath string, opts GenerateOptions) (dryrun.Result, error) { fileSystemID, _, _, err := validateGenerate(opts) if err != nil { @@ -714,33 +723,20 @@ func (s Service) DryRunGenerateScoped(commandPath string, opts GenerateScopedOpt } func (s Service) DryRunMutation(commandPath, operation, method, path string, opts MutationOptions, permission authz.Permission, mountGuard bool) (dryrun.Result, error) { - fileSystemID, tokenID, err := validateMutation(opts) + tokenID, err := validateTokenID(opts.TokenID) if err != nil { return dryrun.Result{}, err } - if mountGuard { - if err := s.guardTokenIDMount(fileSystemID, tokenID); err != nil { - return dryrun.Result{}, err - } - } - var endpoint endpoints.Endpoint - useBearer, err := tokenInputPresent(opts.Token, opts.TokenExplicit) + management, err := s.resolveManagementAuth(opts.Profile, opts.FileSystemID, opts.Token, opts.TokenExplicit, opts.RegionOverride, permission, operation) 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 + if mountGuard { + if err := s.guardTokenIDMount(management.fileSystemID, tokenID); err != nil { + return dryrun.Result{}, err } - 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 } - return tokenDryRun(commandPath, operation, method, path, fileSystemID, opts.Profile, endpoint, permission), nil + return tokenDryRun(commandPath, operation, method, path, management.fileSystemID, opts.Profile, management.endpoint, permission), nil } func (s Service) DryRunRefresh(commandPath string, opts RefreshOptions) (dryrun.Result, error) { @@ -859,6 +855,9 @@ func activeMountError(path string) error { } func validateGenerate(opts GenerateOptions) (string, string, *int64, error) { + if strings.TrimSpace(opts.FileSystemID) == "" { + return "", "", nil, apperr.New("fs.missing_file_system_id", "usage", 2, "--file-system-id is required to generate an owner FS token; FS tokens cannot generate owner tokens") + } fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) if err != nil { return "", "", nil, err @@ -993,19 +992,15 @@ func parseScopeOps(raw string) ([]string, error) { return ops, nil } -func validateMutation(opts MutationOptions) (string, string, error) { - fileSystemID, err := fscred.ValidateFileSystemID(opts.FileSystemID) - if err != nil { - return "", "", err - } - tokenID := strings.TrimSpace(opts.TokenID) +func validateTokenID(value string) (string, error) { + tokenID := strings.TrimSpace(value) if tokenID == "" { - return "", "", apperr.New("fs.token_id_required", "usage", 2, "--token-id is required") + 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 "", apperr.New("fs.invalid_token_id", "usage", 2, "--token-id must be one path-safe identifier") } - return fileSystemID, tokenID, nil + return tokenID, nil } func optionalTTLSeconds(ttl *time.Duration) (*int64, error) { diff --git a/internal/fs/tokenmgmt/service_test.go b/internal/fs/tokenmgmt/service_test.go index 4e6797e..bab7199 100644 --- a/internal/fs/tokenmgmt/service_test.go +++ b/internal/fs/tokenmgmt/service_test.go @@ -99,6 +99,81 @@ func TestGenerateAndListMapBackendFieldsAndStoreMetadata(t *testing.T) { } } +func TestOwnerTokenManagesTokenInventoryWithoutExplicitFileSystemID(t *testing.T) { + home := t.TempDir() + ownerToken := wrappedToken(t, "fs-1", 1) + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Header.Get("Authorization") != "Bearer "+ownerToken { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + if r.Header.Get("X-TiDBCloud-Public-Key") != "" || r.Header.Get("X-TiDBCloud-Private-Key") != "" { + t.Errorf("bearer request included TiDB Cloud credentials: %#v", r.Header) + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/tokens": + if r.URL.Query().Get("tenant_id") != "fs-1" { + t.Errorf("list query = %q", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"tokens":[{"token_id":"scoped-1","tenant_id":"fs-1","scope_kind":"fs_scoped","status":"active","expired":false,"issued_at":"2026-08-12T00: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/scoped-1/deactivate": + _, _ = w.Write([]byte(`{"token_id":"scoped-1","tenant_id":"fs-1","status":"disabled"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tokens/scoped-1/activate": + _, _ = w.Write([]byte(`{"token_id":"scoped-1","tenant_id":"fs-1","status":"active"}`)) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/tokens/scoped-1": + _, _ = w.Write([]byte(`{"token_id":"scoped-1","tenant_id":"fs-1","status":"revoked"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + service, profile := tokenTestService(home, server.URL) + + listed, err := service.List(context.Background(), ListOptions{Profile: profile, Token: ownerToken, TokenExplicit: true, Limit: DefaultListLimit}) + if err != nil || listed.FileSystemID != "fs-1" || len(listed.Tokens) != 1 { + t.Fatalf("List() result=%#v err=%v", listed, err) + } + disabled, err := service.Disable(context.Background(), MutationOptions{Profile: profile, Token: ownerToken, TokenExplicit: true, TokenID: "scoped-1"}) + if err != nil || disabled.FileSystemID != "fs-1" || disabled.Status != "disabled" { + t.Fatalf("Disable() result=%#v err=%v", disabled, err) + } + enabled, err := service.Enable(context.Background(), MutationOptions{Profile: profile, Token: ownerToken, TokenExplicit: true, TokenID: "scoped-1"}) + if err != nil || enabled.FileSystemID != "fs-1" || enabled.Status != "active" { + t.Fatalf("Enable() result=%#v err=%v", enabled, err) + } + deleted, err := service.Delete(context.Background(), MutationOptions{Profile: profile, Token: ownerToken, TokenExplicit: true, TokenID: "scoped-1"}) + if err != nil || deleted.FileSystemID != "fs-1" || deleted.Status != "revoked" { + t.Fatalf("Delete() result=%#v err=%v", deleted, err) + } + if requests != 4 { + t.Fatalf("requests = %d, want 4", requests) + } +} + +func TestTiDBCloudTokenManagementRequiresExplicitFileSystemID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer server.Close() + service, profile := tokenTestService(t.TempDir(), server.URL) + t.Setenv("TI_FS_TOKEN", "") + + _, err := service.List(context.Background(), ListOptions{Profile: profile, Limit: DefaultListLimit}) + if apperr.CodeFor(err) != "fs.missing_file_system_id" || !strings.Contains(err.Error(), "when token management uses TiDB Cloud API credentials") { + t.Fatalf("List() error = %v", err) + } + _, err = service.Disable(context.Background(), MutationOptions{Profile: profile, TokenID: "scoped-1"}) + if apperr.CodeFor(err) != "fs.missing_file_system_id" { + t.Fatalf("Disable() error = %v", err) + } + if requests != 0 { + t.Fatalf("missing IDs sent %d remote requests", requests) + } +} + func TestGenerateScopedUsesOwnerBearerAndStoresScopes(t *testing.T) { home := t.TempDir() ownerToken := wrappedToken(t, "fs-1", 1) From e495a72b2a325c46fee33b8e047cd2e9db0054ee Mon Sep 17 00:00:00 2001 From: Cheese Date: Wed, 19 Aug 2026 11:21:19 +0800 Subject: [PATCH 2/2] chore(ref): update filesystem references --- ref/drive9 | 2 +- ref/fs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ref/drive9 b/ref/drive9 index 5f6779c..645f5ce 160000 --- a/ref/drive9 +++ b/ref/drive9 @@ -1 +1 @@ -Subproject commit 5f6779c4549f925845176a20e011c2021ecf680b +Subproject commit 645f5ce8bb14bfcbae801eb90b641c890620db48 diff --git a/ref/fs b/ref/fs index 4e467e3..f6669db 160000 --- a/ref/fs +++ b/ref/fs @@ -1 +1 @@ -Subproject commit 4e467e300136a9fefad5cc7728dab716127efbec +Subproject commit f6669db3d41e2e5295f6b68d4d26aaecd9b780da