Skip to content

Commit 845d69d

Browse files
authored
feat: group-scoped API tokens with per-operation scopes (#1049)
## Summary Adds CLI support for group-scoped platform API tokens. Companion to turso-platform PRs #3755 / #3808 and dashboard PR tursodatabase/dashboard#811. ### Mint ``` turso auth api-tokens mint <name> --org <slug> --group <group> --scope <label> ... turso auth api-tokens mint <name> --org <slug> --group <group> --read-only turso auth api-tokens mint <name> --org <slug> --group <group> --full-access ``` - `--group <name>` pins the token to a single group inside `--org`. - `--scope <label>` (repeatable) grants individual scopes. Allowed values: `read`, `db:create`, `db:delete`, `db:configure`, `db:mint-token`, `db:rotate-creds`, `group:configure`, `group:mint-token`, `group:rotate-creds`. - `--read-only` / `--full-access` are preset shorthands — the platform expands them server-side. - Mint and rotate are separate scopes so granting "issue SQL credentials" does not also grant "invalidate every running app's credentials." - Unknown scopes are rejected client-side so typos surface without a round-trip. - Group existence is validated before the create POST (`Groups.Get` against the requested `--org`), giving a clear local error instead of a 404 from the token endpoint. ### List The token table now shows: - "Organization" column → **Scope**: `all` for unrestricted, `<org>` for org-scoped, `<org>/<group>` for group-scoped. - New **Permissions** column: `—` for tokens without a scope set, `read-only` when the only scope is `read`, `full-access` when every known scope is present, otherwise the comma-separated scope list. Preset recognition runs over the expanded scope set the platform stores; the preset names themselves are inputs only and never round-trip. ## Files - `internal/turso/apitoken_scope.go` (new) — scope vocabulary mirroring the platform's `api/service/scope/scope.go` - `internal/turso/apiTokens.go` — `ApiToken` gains `Group` + `Scopes`; new `CreateScoped(name, org, group, scopes)`; old `CreateWithOrg` kept as a thin wrapper - `internal/cmd/auth_createapitokens.go` — new flags + validation + group existence check - `internal/cmd/auth_listapitokens.go` — Scope + Permissions columns ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] Manual smoke test by author: minted org-scoped, group-scoped `--read-only`, group-scoped custom, group-scoped `--full-access`; `list` renders all four flavors correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents fa3ade4 + 0135b10 commit 845d69d

4 files changed

Lines changed: 250 additions & 31 deletions

File tree

internal/cmd/auth_createapitokens.go

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,35 @@ import (
66

77
"github.com/spf13/cobra"
88
"github.com/tursodatabase/turso-cli/internal"
9+
"github.com/tursodatabase/turso-cli/internal/turso"
910
)
1011

11-
var mintOrgFlag string
12+
var (
13+
mintOrgFlag string
14+
mintGroupFlag string
15+
mintScopeFlags []string
16+
mintReadOnlyFlag bool
17+
mintFullAccessFlag bool
18+
)
1219

1320
func init() {
1421
apiTokensCmd.AddCommand(createApiTokensCmd)
15-
createApiTokensCmd.Flags().StringVar(&mintOrgFlag, "org", "", "Organization to restrict the token to")
22+
createApiTokensCmd.Flags().StringVar(&mintOrgFlag, "org", "", "Organization to restrict the token to.")
23+
createApiTokensCmd.Flags().StringVar(&mintGroupFlag, "group", "", "Group inside --org to restrict the token to. Implies --org and requires at least one scope.")
24+
createApiTokensCmd.Flags().StringArrayVar(&mintScopeFlags, "scope", nil, "Permission scope to grant to a group-scoped token. May be repeated. Allowed values: "+scopeFlagListing()+".")
25+
createApiTokensCmd.Flags().BoolVar(&mintReadOnlyFlag, "read-only", false, "Shorthand for --scope read.")
26+
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.")
1627
}
1728

1829
var createApiTokensCmd = &cobra.Command{
1930
Use: "mint <api-token-name>",
2031
Short: "Mint an API token.",
2132
Long: "" +
2233
"API tokens are revocable non-expiring tokens that authenticate holders as the user who minted them.\n" +
23-
"They can be used to implement automations with the " + internal.Emph("turso") + " CLI or the platform API.",
34+
"They can be used to implement automations with the " + internal.Emph("turso") + " CLI or the platform API.\n" +
35+
"\n" +
36+
"With --group, the token is restricted to a single group inside the organization and to the\n" +
37+
"set of scopes you pass via --scope (or the --read-only / --full-access shorthands).",
2438
Args: cobra.ExactArgs(1),
2539
RunE: func(cmd *cobra.Command, args []string) error {
2640
cmd.SilenceUsage = true
@@ -32,27 +46,35 @@ var createApiTokensCmd = &cobra.Command{
3246

3347
name := strings.TrimSpace(args[0])
3448

35-
// Validate organization if provided
36-
if mintOrgFlag != "" {
37-
orgs, err := client.Organizations.List()
38-
if err != nil {
39-
return fmt.Errorf("failed to list organizations: %w", err)
49+
scopes, err := resolveMintScopes()
50+
if err != nil {
51+
return err
52+
}
53+
54+
if mintGroupFlag != "" {
55+
if mintOrgFlag == "" {
56+
return fmt.Errorf("--group requires --org")
4057
}
58+
if len(scopes) == 0 {
59+
return fmt.Errorf("--group requires at least one scope (use --scope, --read-only, or --full-access)")
60+
}
61+
} else if len(scopes) > 0 {
62+
return fmt.Errorf("--scope / --read-only / --full-access are only meaningful with --group")
63+
}
4164

42-
found := false
43-
for _, org := range orgs {
44-
if org.Slug == mintOrgFlag {
45-
found = true
46-
break
47-
}
65+
if mintOrgFlag != "" {
66+
if err := validateOrgExists(client, mintOrgFlag); err != nil {
67+
return err
4868
}
69+
}
4970

50-
if !found {
51-
return fmt.Errorf("organization %s not found", internal.Emph(mintOrgFlag))
71+
if mintGroupFlag != "" {
72+
if err := validateGroupExists(client, mintOrgFlag, mintGroupFlag); err != nil {
73+
return err
5274
}
5375
}
5476

55-
data, err := client.ApiTokens.CreateWithOrg(name, mintOrgFlag)
77+
data, err := client.ApiTokens.CreateScoped(name, mintOrgFlag, mintGroupFlag, scopes)
5678
if err != nil {
5779
return err
5880
}
@@ -61,3 +83,79 @@ var createApiTokensCmd = &cobra.Command{
6183
return nil
6284
},
6385
}
86+
87+
// resolveMintScopes turns the --scope / --read-only / --full-access flags
88+
// into the list of scope strings sent to the platform. Conflicting flag
89+
// combinations (e.g. --read-only with --full-access) are rejected, and
90+
// individual scope labels are validated client-side so typos surface
91+
// without a round-trip.
92+
func resolveMintScopes() ([]string, error) {
93+
if mintReadOnlyFlag && mintFullAccessFlag {
94+
return nil, fmt.Errorf("--read-only and --full-access are mutually exclusive")
95+
}
96+
if (mintReadOnlyFlag || mintFullAccessFlag) && len(mintScopeFlags) > 0 {
97+
return nil, fmt.Errorf("--scope cannot be combined with --read-only or --full-access; use one or the other")
98+
}
99+
if mintReadOnlyFlag {
100+
return []string{"read-only"}, nil
101+
}
102+
if mintFullAccessFlag {
103+
return []string{"full-access"}, nil
104+
}
105+
if len(mintScopeFlags) == 0 {
106+
return nil, nil
107+
}
108+
out := make([]string, 0, len(mintScopeFlags))
109+
seen := make(map[string]struct{}, len(mintScopeFlags))
110+
for _, raw := range mintScopeFlags {
111+
s := strings.TrimSpace(raw)
112+
if s == "" {
113+
continue
114+
}
115+
if !turso.IsValidScope(s) {
116+
return nil, fmt.Errorf("unknown scope %s. Allowed: %s", internal.Emph(s), scopeFlagListing())
117+
}
118+
if _, dup := seen[s]; dup {
119+
continue
120+
}
121+
seen[s] = struct{}{}
122+
out = append(out, s)
123+
}
124+
return out, nil
125+
}
126+
127+
func validateOrgExists(client *turso.Client, slug string) error {
128+
orgs, err := client.Organizations.List()
129+
if err != nil {
130+
return fmt.Errorf("failed to list organizations: %w", err)
131+
}
132+
for _, org := range orgs {
133+
if org.Slug == slug {
134+
return nil
135+
}
136+
}
137+
return fmt.Errorf("organization %s not found", internal.Emph(slug))
138+
}
139+
140+
// validateGroupExists confirms that the named group lives inside the given
141+
// organization slug. The Groups client URL-builds from client.Org, so we
142+
// temporarily point the client at the target org for the lookup, then
143+
// restore. (Single-command invocation, no concurrency to worry about.)
144+
func validateGroupExists(client *turso.Client, orgSlug, groupName string) error {
145+
savedOrg := client.Org
146+
client.Org = orgSlug
147+
defer func() { client.Org = savedOrg }()
148+
149+
if _, err := client.Groups.Get(groupName); err != nil {
150+
return fmt.Errorf("group %s not found in organization %s: %w", internal.Emph(groupName), internal.Emph(orgSlug), err)
151+
}
152+
return nil
153+
}
154+
155+
func scopeFlagListing() string {
156+
parts := make([]string, 0, len(turso.AllScopes))
157+
for _, s := range turso.AllScopes {
158+
parts = append(parts, string(s))
159+
}
160+
return strings.Join(parts, ", ")
161+
}

internal/cmd/auth_listapitokens.go

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package cmd
22

33
import (
4+
"sort"
5+
"strings"
6+
47
"github.com/spf13/cobra"
58
"github.com/tursodatabase/turso-cli/internal"
9+
"github.com/tursodatabase/turso-cli/internal/turso"
610
)
711

812
func init() {
@@ -31,14 +35,67 @@ var listApiTokensCmd = &cobra.Command{
3135

3236
data := [][]string{}
3337
for _, apiToken := range apiTokens {
34-
org := apiToken.Organization
35-
if org == "" {
36-
org = "all"
37-
}
38-
data = append(data, []string{apiToken.Name, org, apiToken.CreatedAt})
38+
data = append(data, []string{
39+
apiToken.Name,
40+
formatTokenOrgScope(apiToken),
41+
formatTokenPermissions(apiToken),
42+
apiToken.CreatedAt,
43+
})
3944
}
40-
printTable([]string{"Name", "Organization", "Created At"}, data)
45+
printTable([]string{"Name", "Scope", "Permissions", "Created At"}, data)
4146

4247
return nil
4348
},
4449
}
50+
51+
// formatTokenOrgScope renders the token's binding for the "Scope" column:
52+
// unrestricted tokens read "all", org-scoped tokens show the org slug, and
53+
// group-scoped tokens show "<org>/<group>". The slash form keeps the column
54+
// narrow while making the boundary visible at a glance.
55+
func formatTokenOrgScope(t turso.ApiToken) string {
56+
switch {
57+
case t.Organization == "":
58+
return "all"
59+
case t.Group == "":
60+
return t.Organization
61+
default:
62+
return t.Organization + "/" + t.Group
63+
}
64+
}
65+
66+
// formatTokenPermissions summarizes the scope list for the "Permissions"
67+
// column. The platform expands presets to their underlying scopes server-side,
68+
// so we recognize the canonical preset shapes here ("read-only" = just
69+
// `read`, "full-access" = every known scope) for a friendlier label;
70+
// anything else is listed verbatim.
71+
func formatTokenPermissions(t turso.ApiToken) string {
72+
if len(t.Scopes) == 0 {
73+
return "—"
74+
}
75+
scopes := append([]string(nil), t.Scopes...)
76+
sort.Strings(scopes)
77+
if len(scopes) == 1 && scopes[0] == string(turso.ScopeRead) {
78+
return "read-only"
79+
}
80+
if isFullAccessScopeSet(scopes) {
81+
return "full-access"
82+
}
83+
return strings.Join(scopes, ", ")
84+
}
85+
86+
func isFullAccessScopeSet(sortedScopes []string) bool {
87+
if len(sortedScopes) != len(turso.AllScopes) {
88+
return false
89+
}
90+
reference := make([]string, 0, len(turso.AllScopes))
91+
for _, s := range turso.AllScopes {
92+
reference = append(reference, string(s))
93+
}
94+
sort.Strings(reference)
95+
for i, s := range sortedScopes {
96+
if s != reference[i] {
97+
return false
98+
}
99+
}
100+
return true
101+
}

internal/turso/apiTokens.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import (
88
)
99

1010
type ApiToken struct {
11-
ID string `json:"id"`
12-
Name string `json:"name"`
13-
Organization string `json:"organization,omitempty"`
14-
CreatedAt string `json:"created_at"`
15-
Owner uint `json:"-"`
16-
PubKey []byte `json:"-"`
11+
ID string `json:"id"`
12+
Name string `json:"name"`
13+
Organization string `json:"organization,omitempty"`
14+
Group string `json:"group,omitempty"`
15+
Scopes []string `json:"scopes,omitempty"`
16+
CreatedAt string `json:"created_at"`
17+
Owner uint `json:"-"`
18+
PubKey []byte `json:"-"`
1719
}
1820

1921
type ApiTokensClient client
@@ -47,16 +49,33 @@ func (a *ApiTokensClient) Create(name string) (CreateApiToken, error) {
4749
}
4850

4951
func (a *ApiTokensClient) CreateWithOrg(name string, organization string) (CreateApiToken, error) {
52+
return a.CreateScoped(name, organization, "", nil)
53+
}
54+
55+
// CreateScoped mints an API token with optional restrictions. Empty
56+
// organization minted an unrestricted token. organization + empty group
57+
// produces an organization-scoped token. organization + group + non-empty
58+
// scopes produces a group-scoped token; the platform requires the scopes
59+
// list to be non-empty in that case.
60+
//
61+
// scopes may contain individual scope labels (see AllScopes) or the
62+
// preset names "read-only" / "full-access" — the platform expands the
63+
// presets server-side.
64+
func (a *ApiTokensClient) CreateScoped(name, organization, group string, scopes []string) (CreateApiToken, error) {
5065
url := fmt.Sprintf("/v2/auth/api-tokens/%s", name)
5166

5267
var res *http.Response
5368
var err error
5469

55-
if organization != "" {
70+
if organization != "" || group != "" || len(scopes) > 0 {
5671
reqBody := struct {
57-
Organization string `json:"organization"`
72+
Organization string `json:"organization,omitempty"`
73+
Group string `json:"group,omitempty"`
74+
Scopes []string `json:"scopes,omitempty"`
5875
}{
5976
Organization: organization,
77+
Group: group,
78+
Scopes: scopes,
6079
}
6180
jsonData, marshalErr := json.Marshal(reqBody)
6281
if marshalErr != nil {

internal/turso/apitoken_scope.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package turso
2+
3+
// Scope is a permission label carried by a group-scoped platform API token.
4+
// The vocabulary mirrors api/service/scope/scope.go on the platform side; if
5+
// the platform adds or renames scopes, this file must follow.
6+
type Scope string
7+
8+
const (
9+
ScopeRead Scope = "read"
10+
ScopeDbCreate Scope = "db:create"
11+
ScopeDbDelete Scope = "db:delete"
12+
ScopeDbConfigure Scope = "db:configure"
13+
ScopeDbMintToken Scope = "db:mint-token"
14+
ScopeDbRotateCreds Scope = "db:rotate-creds"
15+
ScopeGroupConfigure Scope = "group:configure"
16+
ScopeGroupMintToken Scope = "group:mint-token"
17+
ScopeGroupRotateCreds Scope = "group:rotate-creds"
18+
)
19+
20+
// AllScopes is the canonical ordering used by --help output and the
21+
// individual-scope validator. Presets ("read-only", "full-access") are
22+
// accepted by the platform but not listed here — they're handled as
23+
// separate CLI flags.
24+
var AllScopes = []Scope{
25+
ScopeRead,
26+
ScopeDbCreate,
27+
ScopeDbDelete,
28+
ScopeDbConfigure,
29+
ScopeDbMintToken,
30+
ScopeDbRotateCreds,
31+
ScopeGroupConfigure,
32+
ScopeGroupMintToken,
33+
ScopeGroupRotateCreds,
34+
}
35+
36+
// IsValidScope reports whether s is a known scope label. Used to surface
37+
// typos client-side instead of waiting for the platform 400.
38+
func IsValidScope(s string) bool {
39+
for _, sc := range AllScopes {
40+
if string(sc) == s {
41+
return true
42+
}
43+
}
44+
return false
45+
}

0 commit comments

Comments
 (0)