diff --git a/internal/cmd/auth_createapitokens.go b/internal/cmd/auth_createapitokens.go index 9189e994..ed7bc0af 100644 --- a/internal/cmd/auth_createapitokens.go +++ b/internal/cmd/auth_createapitokens.go @@ -21,7 +21,7 @@ func init() { apiTokensCmd.AddCommand(createApiTokensCmd) createApiTokensCmd.Flags().StringVar(&mintOrgFlag, "org", "", "Organization to restrict the token to (defaults to the selected organization).") createApiTokensCmd.Flags().StringVar(&mintGroupFlag, "group", "", "Group inside --org to restrict the token to. Implies --org and requires at least one scope.") - createApiTokensCmd.Flags().StringArrayVar(&mintScopeFlags, "scope", nil, "Permission scope to grant to a group-scoped token. May be repeated. Allowed values: "+scopeFlagListing()+".") + createApiTokensCmd.Flags().StringArrayVar(&mintScopeFlags, "scope", nil, "Permission scope to grant to the token. May be repeated. Allowed values: "+scopeFlagListing()+".") createApiTokensCmd.Flags().BoolVar(&mintReadOnlyFlag, "read-only", false, "Shorthand for --scope read.") createApiTokensCmd.Flags().BoolVar(&mintFullAccessFlag, "full-access", false, "Shorthand for granting every scope. Use with care; equivalent to a deployer that can create, delete, configure, mint and rotate.") } @@ -33,8 +33,11 @@ var createApiTokensCmd = &cobra.Command{ "API tokens are revocable non-expiring tokens that authenticate holders as the user who minted them.\n" + "They can be used to implement automations with the " + internal.Emph("turso") + " CLI or the platform API.\n" + "\n" + - "With --group, the token is restricted to a single group inside the organization and to the\n" + - "set of scopes you pass via --scope (or the --read-only / --full-access shorthands).", + "With --scope (or the --read-only / --full-access shorthands), the token is restricted to that set\n" + + "of scopes. Without --group it covers every group in the organization, but never organization\n" + + "management (members, billing changes, minting tokens).\n" + + "\n" + + "With --group, the token is restricted to a single group inside the organization and requires scopes.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true @@ -58,8 +61,11 @@ var createApiTokensCmd = &cobra.Command{ if len(scopes) == 0 { return fmt.Errorf("--group requires at least one scope (use --scope, --read-only, or --full-access)") } - } else if len(scopes) > 0 { - return fmt.Errorf("--scope / --read-only / --full-access are only meaningful with --group") + for _, s := range scopes { + if turso.IsOrgLevelScope(s) { + return fmt.Errorf("scope %s is only valid without --group", internal.Emph(s)) + } + } } if mintOrgFlag != "" { @@ -157,9 +163,12 @@ func validateGroupExists(client *turso.Client, orgSlug, groupName string) error } func scopeFlagListing() string { - parts := make([]string, 0, len(turso.AllScopes)) + parts := make([]string, 0, len(turso.AllScopes)+len(turso.OrgLevelScopes)) for _, s := range turso.AllScopes { parts = append(parts, string(s)) } + for _, s := range turso.OrgLevelScopes { + parts = append(parts, string(s)+" (without --group only)") + } return strings.Join(parts, ", ") } diff --git a/internal/cmd/auth_createapitokens_test.go b/internal/cmd/auth_createapitokens_test.go index 0ad37041..e31d5866 100644 --- a/internal/cmd/auth_createapitokens_test.go +++ b/internal/cmd/auth_createapitokens_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -31,12 +32,15 @@ func TestMintApiTokenOrganization(t *testing.T) { for _, tc := range []struct { name, flag, want string + readOnly bool }{ - {"selected organization", "", "selected-org"}, - {"explicit organization", "other-org", "other-org"}, + {"selected organization", "", "selected-org", false}, + {"explicit organization", "other-org", "other-org", false}, + {"read-only organization", "other-org", "other-org", true}, } { t.Run(tc.name, func(t *testing.T) { mintOrgFlag = tc.flag + mintReadOnlyFlag = tc.readOnly server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/auth/validate": @@ -50,8 +54,12 @@ func TestMintApiTokenOrganization(t *testing.T) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Errorf("missing or invalid request body: %v", err) - } else if len(body) != 1 || body["organization"] != tc.want { - t.Errorf("request body = %v, want only organization %q", body, tc.want) + } else if body["organization"] != tc.want { + t.Errorf("request body = %v, want organization %q", body, tc.want) + } else if tc.readOnly && fmt.Sprint(body["scopes"]) != "[read-only]" { + t.Errorf("request body = %v, want scopes [read-only]", body) + } else if !tc.readOnly && len(body) != 1 { + t.Errorf("request body = %v, want only organization", body) } w.Write([]byte(`{"token":{"value":"minted"}}`)) default: diff --git a/internal/cmd/auth_listapitokens.go b/internal/cmd/auth_listapitokens.go index 99da4706..b8d8feec 100644 --- a/internal/cmd/auth_listapitokens.go +++ b/internal/cmd/auth_listapitokens.go @@ -66,7 +66,7 @@ func formatTokenOrgScope(t turso.ApiToken) string { // formatTokenPermissions summarizes the scope list for the "Permissions" // column. The platform expands presets to their underlying scopes server-side, // so we recognize the canonical preset shapes here ("read-only" = just -// `read`, "full-access" = every known scope) for a friendlier label; +// `read`, "full-access" = every group-level scope) for a friendlier label; // anything else is listed verbatim. func formatTokenPermissions(t turso.ApiToken) string { if len(t.Scopes) == 0 { @@ -77,18 +77,18 @@ func formatTokenPermissions(t turso.ApiToken) string { if len(scopes) == 1 && scopes[0] == string(turso.ScopeRead) { return "read-only" } - if isFullAccessScopeSet(scopes) { + if isScopeSet(scopes, turso.AllScopes) { return "full-access" } return strings.Join(scopes, ", ") } -func isFullAccessScopeSet(sortedScopes []string) bool { - if len(sortedScopes) != len(turso.AllScopes) { +func isScopeSet(sortedScopes []string, set []turso.Scope) bool { + if len(sortedScopes) != len(set) { return false } - reference := make([]string, 0, len(turso.AllScopes)) - for _, s := range turso.AllScopes { + reference := make([]string, 0, len(set)) + for _, s := range set { reference = append(reference, string(s)) } sort.Strings(reference) diff --git a/internal/turso/apiTokens.go b/internal/turso/apiTokens.go index 9bb6dfd3..18d356b5 100644 --- a/internal/turso/apiTokens.go +++ b/internal/turso/apiTokens.go @@ -54,9 +54,9 @@ func (a *ApiTokensClient) CreateWithOrg(name string, organization string) (Creat // CreateScoped mints an API token with optional restrictions. Empty // organization minted an unrestricted token. organization + empty group -// produces an organization-scoped token. organization + group + non-empty -// scopes produces a group-scoped token; the platform requires the scopes -// list to be non-empty in that case. +// produces an organization-scoped token, limited to scopes when non-empty. +// organization + group + non-empty scopes produces a group-scoped token; the +// platform requires the scopes list to be non-empty in that case. // // scopes may contain individual scope labels (see AllScopes) or the // preset names "read-only" / "full-access" — the platform expands the diff --git a/internal/turso/apitoken_scope.go b/internal/turso/apitoken_scope.go index 99794997..71d45f2e 100644 --- a/internal/turso/apitoken_scope.go +++ b/internal/turso/apitoken_scope.go @@ -1,6 +1,8 @@ package turso -// Scope is a permission label carried by a group-scoped platform API token. +import "slices" + +// Scope is a permission label carried by a scoped platform API token. // The vocabulary mirrors api/service/scope/scope.go on the platform side; if // the platform adds or renames scopes, this file must follow. type Scope string @@ -15,6 +17,9 @@ const ( ScopeGroupConfigure Scope = "group:configure" ScopeGroupMintToken Scope = "group:mint-token" ScopeGroupRotateCreds Scope = "group:rotate-creds" + ScopeGroupCreate Scope = "group:create" + ScopeGroupDelete Scope = "group:delete" + ScopeOrgRead Scope = "org:read" ) // AllScopes is the canonical ordering used by --help output and the @@ -33,13 +38,20 @@ var AllScopes = []Scope{ ScopeGroupRotateCreds, } +// OrgLevelScopes may only be granted to organization-scoped tokens. +var OrgLevelScopes = []Scope{ + ScopeGroupCreate, + ScopeGroupDelete, + ScopeOrgRead, +} + // IsValidScope reports whether s is a known scope label. Used to surface // typos client-side instead of waiting for the platform 400. func IsValidScope(s string) bool { - for _, sc := range AllScopes { - if string(sc) == s { - return true - } - } - return false + return slices.Contains(AllScopes, Scope(s)) || IsOrgLevelScope(s) +} + +// IsOrgLevelScope reports whether s may only be granted to org-scoped tokens. +func IsOrgLevelScope(s string) bool { + return slices.Contains(OrgLevelScopes, Scope(s)) }