Skip to content

Commit 48add54

Browse files
authored
feat: database access allow rules by IP/CIDR and AWS VPC endpoint ID (#1050)
## Summary Adds CLI support for the database access restrictions recently added to the platform (allow rules by IP/CIDR and AWS VPC endpoint ID), with the same developer experience as database-level delete protection. New command group: ``` turso db config allow-rules show <db> turso db config allow-rules set <db> --ip 203.0.113.7 --ip 10.0.0.0/8 --aws-vpc vpce-0fe6c8807461bba49 turso db config allow-rules clear <db> [--ips] [--aws-vpcs] ``` - `set` replaces each list whose flag is provided; a list whose flag is not given is left unchanged. Entries are trimmed, deduplicated, and validated client-side to match the server rules (individual IPs or CIDR blocks via `net.ParseIP`/`net.ParseCIDR`, VPC endpoint ids must start with `vpce-`). - `clear` clears both lists by default, or selectively with `--ips` / `--aws-vpcs`. - When both lists are configured, connections must satisfy both (AND semantics): an allowed IP arriving through an allowed AWS VPC endpoint. Help texts spell this out. - `turso db show` now displays `Allowed IPs` and `Allowed VPC IDs` when configured. This uses the existing database configuration endpoint (`GET`/`PATCH .../databases/{db}/configuration`) with the `allowed_ips` and `allowed_aws_vpc_ids` fields. The new `DatabaseConfig` fields are `*[]string` with `omitempty`, which is load-bearing: the server treats an explicit `null` as "clear the list", so the fields are only serialized when deliberately set — keeping unrelated config updates (e.g. delete protection) from wiping the allow rules. Allow rules are database-level only (no group-level support), matching the platform API. ## Test plan - `go build ./...` and `go vet` pass - New unit tests for entry normalization and IP/CIDR/VPC-id validation (`go test ./internal/cmd -run 'TestNormalizeAllowRuleEntries|TestValidateAllowed'`) - Help output for all subcommands verified manually - Note: `TestGetReservedBytes` fails in `internal/cmd`, but fails identically on a clean checkout of main — pre-existing and unrelated - Worth a live round-trip against an AWS database before relying on it (server rejects allow rules on Fly databases and the starter plan; the CLI surfaces those errors as-is) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 845d69d + 8635573 commit 48add54

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

internal/cmd/db_allow_rules.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/tursodatabase/turso-cli/internal"
10+
"github.com/tursodatabase/turso-cli/internal/turso"
11+
)
12+
13+
var allowRulesIPsFlag []string
14+
var allowRulesVpcsFlag []string
15+
var clearAllowRulesIPsFlag bool
16+
var clearAllowRulesVpcsFlag bool
17+
18+
func init() {
19+
dbConfigCmd.AddCommand(dbAllowRulesCmd)
20+
dbAllowRulesCmd.AddCommand(dbShowAllowRulesCmd)
21+
dbAllowRulesCmd.AddCommand(dbSetAllowRulesCmd)
22+
dbAllowRulesCmd.AddCommand(dbClearAllowRulesCmd)
23+
dbSetAllowRulesCmd.Flags().StringSliceVar(&allowRulesIPsFlag, "ip", nil, "IP address or CIDR block to allow. Can be repeated. Replaces the current list of allowed IPs.")
24+
dbSetAllowRulesCmd.Flags().StringSliceVar(&allowRulesVpcsFlag, "aws-vpc", nil, "AWS VPC endpoint ID (vpce-...) to allow. Can be repeated. Replaces the current list of allowed VPC endpoints.")
25+
dbClearAllowRulesCmd.Flags().BoolVar(&clearAllowRulesIPsFlag, "ips", false, "Clear only the list of allowed IPs")
26+
dbClearAllowRulesCmd.Flags().BoolVar(&clearAllowRulesVpcsFlag, "aws-vpcs", false, "Clear only the list of allowed AWS VPC endpoint IDs")
27+
}
28+
29+
var dbAllowRulesCmd = &cobra.Command{
30+
Use: "allow-rules",
31+
Short: "Manage the access allow rules of a database",
32+
Long: "Manage the access allow rules of a database. A connection must satisfy every configured rule list: " +
33+
"if allowed IPs are set, the client IP must be on the list; if allowed AWS VPC endpoints are set, " +
34+
"the connection must arrive through one of them.",
35+
ValidArgsFunction: noSpaceArg,
36+
}
37+
38+
var dbShowAllowRulesCmd = &cobra.Command{
39+
Use: "show <database-name>",
40+
Short: "Shows the access allow rules of a database",
41+
Args: cobra.ExactArgs(1),
42+
ValidArgsFunction: dbNameArg,
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
cmd.SilenceUsage = true
45+
client, err := authedTursoClient()
46+
if err != nil {
47+
return err
48+
}
49+
database, err := getDatabase(client, args[0], true)
50+
if err != nil {
51+
return err
52+
}
53+
config, err := getDatabaseConfig(client, database.Name)
54+
if err != nil {
55+
return err
56+
}
57+
fmt.Print(allowRulesMessage(&config))
58+
return nil
59+
},
60+
}
61+
62+
var dbSetAllowRulesCmd = &cobra.Command{
63+
Use: "set <database-name> [--ip <address-or-cidr>]... [--aws-vpc <vpce-id>]...",
64+
Short: "Sets the access allow rules of a database",
65+
Long: "Sets the access allow rules of a database. Each provided flag replaces the corresponding list; " +
66+
"a list whose flag is not provided is left unchanged. When both lists are set, connections must " +
67+
"satisfy both: an allowed IP arriving through an allowed AWS VPC endpoint.",
68+
Example: " turso db config allow-rules set my-db --ip 203.0.113.7 --ip 10.0.0.0/8\n" +
69+
" turso db config allow-rules set my-db --aws-vpc vpce-0fe6c8807461bba49",
70+
Args: cobra.ExactArgs(1),
71+
ValidArgsFunction: dbNameArg,
72+
RunE: func(cmd *cobra.Command, args []string) error {
73+
cmd.SilenceUsage = true
74+
setIPs := cmd.Flags().Changed("ip")
75+
setVpcs := cmd.Flags().Changed("aws-vpc")
76+
if !setIPs && !setVpcs {
77+
return fmt.Errorf("specify at least one of --ip or --aws-vpc. To remove restrictions, use %s", internal.Emph("turso db config allow-rules clear"))
78+
}
79+
80+
config := turso.DatabaseConfig{}
81+
if setIPs {
82+
ips := normalizeAllowRuleEntries(allowRulesIPsFlag)
83+
if err := validateAllowedIPs(ips); err != nil {
84+
return err
85+
}
86+
config.AllowedIPs = &ips
87+
}
88+
if setVpcs {
89+
vpcs := normalizeAllowRuleEntries(allowRulesVpcsFlag)
90+
if err := validateAllowedVpcIDs(vpcs); err != nil {
91+
return err
92+
}
93+
config.AllowedAwsVpcIDs = &vpcs
94+
}
95+
96+
client, err := authedTursoClient()
97+
if err != nil {
98+
return err
99+
}
100+
database, err := getDatabase(client, args[0], true)
101+
if err != nil {
102+
return err
103+
}
104+
if err := client.Databases.UpdateConfig(database.Name, config); err != nil {
105+
return err
106+
}
107+
fmt.Printf("Updated access allow rules for database %s\n", internal.Emph(database.Name))
108+
fmt.Print(allowRulesMessage(&config))
109+
return nil
110+
},
111+
}
112+
113+
var dbClearAllowRulesCmd = &cobra.Command{
114+
Use: "clear <database-name>",
115+
Short: "Clears the access allow rules of a database, allowing connections from any source",
116+
Args: cobra.ExactArgs(1),
117+
ValidArgsFunction: dbNameArg,
118+
RunE: func(cmd *cobra.Command, args []string) error {
119+
cmd.SilenceUsage = true
120+
clearIPs, clearVpcs := clearAllowRulesIPsFlag, clearAllowRulesVpcsFlag
121+
if !clearIPs && !clearVpcs {
122+
clearIPs, clearVpcs = true, true
123+
}
124+
125+
config := turso.DatabaseConfig{}
126+
empty := []string{}
127+
if clearIPs {
128+
config.AllowedIPs = &empty
129+
}
130+
if clearVpcs {
131+
config.AllowedAwsVpcIDs = &empty
132+
}
133+
134+
client, err := authedTursoClient()
135+
if err != nil {
136+
return err
137+
}
138+
database, err := getDatabase(client, args[0], true)
139+
if err != nil {
140+
return err
141+
}
142+
if err := client.Databases.UpdateConfig(database.Name, config); err != nil {
143+
return err
144+
}
145+
146+
cleared := []string{}
147+
if clearIPs {
148+
cleared = append(cleared, "allowed IPs")
149+
}
150+
if clearVpcs {
151+
cleared = append(cleared, "allowed AWS VPC endpoint IDs")
152+
}
153+
fmt.Printf("Cleared %s for database %s\n", strings.Join(cleared, " and "), internal.Emph(database.Name))
154+
return nil
155+
},
156+
}
157+
158+
// normalizeAllowRuleEntries trims whitespace, drops empty entries, and
159+
// removes duplicates while preserving order.
160+
func normalizeAllowRuleEntries(entries []string) []string {
161+
seen := make(map[string]bool, len(entries))
162+
result := []string{}
163+
for _, entry := range entries {
164+
entry = strings.TrimSpace(entry)
165+
if entry == "" || seen[entry] {
166+
continue
167+
}
168+
seen[entry] = true
169+
result = append(result, entry)
170+
}
171+
return result
172+
}
173+
174+
func validateAllowedIPs(entries []string) error {
175+
for _, entry := range entries {
176+
if strings.Contains(entry, "/") {
177+
if _, _, err := net.ParseCIDR(entry); err != nil {
178+
return fmt.Errorf("invalid CIDR block %s. Valid entries are individual IP addresses or CIDR blocks", internal.Emph(entry))
179+
}
180+
continue
181+
}
182+
if net.ParseIP(entry) == nil {
183+
return fmt.Errorf("invalid IP address %s. Valid entries are individual IP addresses or CIDR blocks", internal.Emph(entry))
184+
}
185+
}
186+
return nil
187+
}
188+
189+
func validateAllowedVpcIDs(entries []string) error {
190+
for _, entry := range entries {
191+
if !strings.HasPrefix(entry, "vpce-") || len(entry) == len("vpce-") {
192+
return fmt.Errorf("invalid AWS VPC endpoint ID %s: must start with %s", internal.Emph(entry), internal.Emph("vpce-"))
193+
}
194+
}
195+
return nil
196+
}
197+
198+
func allowRulesMessage(config *turso.DatabaseConfig) string {
199+
ips := config.AllowedIPList()
200+
vpcs := config.AllowedVpcIDList()
201+
if len(ips) == 0 && len(vpcs) == 0 {
202+
return fmt.Sprintf("Access allow rules are %s: connections from any source are accepted\n", internal.Emph("empty"))
203+
}
204+
var b strings.Builder
205+
if len(ips) > 0 {
206+
b.WriteString("Allowed IPs:\n")
207+
for _, ip := range ips {
208+
fmt.Fprintf(&b, " %s\n", ip)
209+
}
210+
}
211+
if len(vpcs) > 0 {
212+
b.WriteString("Allowed AWS VPC endpoint IDs:\n")
213+
for _, vpc := range vpcs {
214+
fmt.Fprintf(&b, " %s\n", vpc)
215+
}
216+
}
217+
return b.String()
218+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package cmd
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestNormalizeAllowRuleEntries(t *testing.T) {
9+
got := normalizeAllowRuleEntries([]string{" 10.0.0.1 ", "", "10.0.0.1", "vpce-123", " "})
10+
want := []string{"10.0.0.1", "vpce-123"}
11+
if !reflect.DeepEqual(got, want) {
12+
t.Errorf("normalizeAllowRuleEntries() = %v, want %v", got, want)
13+
}
14+
}
15+
16+
func TestValidateAllowedIPs(t *testing.T) {
17+
valid := []string{"10.0.0.1", "203.0.113.7", "10.0.0.0/8", "192.168.0.0/24", "::1", "2001:db8::/32"}
18+
if err := validateAllowedIPs(valid); err != nil {
19+
t.Errorf("validateAllowedIPs(%v) = %v, want nil", valid, err)
20+
}
21+
22+
for _, entry := range []string{"not-an-ip", "10.0.0.256", "10.0.0.0/33", "10.0.0.1/", "vpce-123"} {
23+
if err := validateAllowedIPs([]string{entry}); err == nil {
24+
t.Errorf("validateAllowedIPs(%q) = nil, want error", entry)
25+
}
26+
}
27+
}
28+
29+
func TestValidateAllowedVpcIDs(t *testing.T) {
30+
if err := validateAllowedVpcIDs([]string{"vpce-0fe6c8807461bba49"}); err != nil {
31+
t.Errorf("validateAllowedVpcIDs() = %v, want nil", err)
32+
}
33+
34+
for _, entry := range []string{"vpc-123", "vpce-", "10.0.0.1", "VPCE-123"} {
35+
if err := validateAllowedVpcIDs([]string{entry}); err == nil {
36+
t.Errorf("validateAllowedVpcIDs(%q) = nil, want error", entry)
37+
}
38+
}
39+
}

internal/cmd/db_show.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ var showCmd = &cobra.Command{
116116
fmt.Println("Is Schema: ", formatBool(db.IsSchema))
117117
fmt.Println("Type: ", databaseType(dbUsage.UUID))
118118
fmt.Println("Delete Protection: ", formatBool(config.IsDeleteProtected()))
119+
if ips := config.AllowedIPList(); len(ips) > 0 {
120+
fmt.Println("Allowed IPs: ", strings.Join(ips, ", "))
121+
}
122+
if vpcs := config.AllowedVpcIDList(); len(vpcs) > 0 {
123+
fmt.Println("Allowed VPC IDs: ", strings.Join(vpcs, ", "))
124+
}
119125
if db.Schema != "" {
120126
fmt.Println("Schema: ", db.Schema)
121127
}

internal/turso/databases.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,11 @@ type Pagination struct {
577577
type DatabaseConfig struct {
578578
AllowAttach *bool `json:"allow_attach"`
579579
DeleteProtection *bool `json:"delete_protection"`
580+
// AllowedIPs and AllowedAwsVpcIDs must keep omitempty: the server treats
581+
// an explicit null as "clear the list", so they may only be sent when
582+
// deliberately set.
583+
AllowedIPs *[]string `json:"allowed_ips,omitempty"`
584+
AllowedAwsVpcIDs *[]string `json:"allowed_aws_vpc_ids,omitempty"`
580585
}
581586

582587
func (d *DatabaseConfig) IsDeleteProtected() bool {
@@ -586,6 +591,20 @@ func (d *DatabaseConfig) IsDeleteProtected() bool {
586591
return *d.DeleteProtection
587592
}
588593

594+
func (d *DatabaseConfig) AllowedIPList() []string {
595+
if d.AllowedIPs == nil {
596+
return nil
597+
}
598+
return *d.AllowedIPs
599+
}
600+
601+
func (d *DatabaseConfig) AllowedVpcIDList() []string {
602+
if d.AllowedAwsVpcIDs == nil {
603+
return nil
604+
}
605+
return *d.AllowedAwsVpcIDs
606+
}
607+
589608
func (d *DatabaseConfig) AttachAllowed() bool {
590609
if d.AllowAttach == nil {
591610
return false

0 commit comments

Comments
 (0)