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
21 changes: 15 additions & 6 deletions internal/cmd/auth_createapitokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand All @@ -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
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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, ", ")
}
16 changes: 12 additions & 4 deletions internal/cmd/auth_createapitokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions internal/cmd/auth_listapitokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions internal/turso/apiTokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions internal/turso/apitoken_scope.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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))
}
Loading