From 2fed7f94bbd7421d6adfed134b4ee5ee47d2c161 Mon Sep 17 00:00:00 2001 From: Yolean gle-qa Date: Thu, 1 Oct 2026 20:27:48 +0000 Subject: [PATCH 01/15] registry: identify the BucketyAccess in GrantRequest A driver that creates one backend principal per access (the mysql driver's database users) needs a stable, unique name for it, and GrantRequest carried only the Buckety side. The access's namespace and name are both immutable for its lifetime and unique together, so they are what such a driver derives the principal from; drivers with shared principals ignore the new fields. Co-Authored-By: Claude Opus 5.5 --- pkg/controller/bucketyaccess/reconciler.go | 2 ++ pkg/controller/bucketyaccess/reconciler_test.go | 3 +++ pkg/drivers/registry/registry.go | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/pkg/controller/bucketyaccess/reconciler.go b/pkg/controller/bucketyaccess/reconciler.go index 3863eca..4e74d6a 100644 --- a/pkg/controller/bucketyaccess/reconciler.go +++ b/pkg/controller/bucketyaccess/reconciler.go @@ -275,6 +275,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu res, err := backend.Driver.GrantAccess(ctx, registry.GrantRequest{ BucketyName: bky.Status.BackendResourceName, + AccessNamespace: access.Namespace, + AccessName: access.Name, Role: string(access.Spec.Role), Parameters: access.Spec.Parameters, BucketyParameters: bkyParams, diff --git a/pkg/controller/bucketyaccess/reconciler_test.go b/pkg/controller/bucketyaccess/reconciler_test.go index 258a164..6c6a20f 100644 --- a/pkg/controller/bucketyaccess/reconciler_test.go +++ b/pkg/controller/bucketyaccess/reconciler_test.go @@ -206,6 +206,9 @@ func TestGrantGatedOnSecretAndFedExistingData(t *testing.T) { if rec.req.BucketyName != "t1-orders" { t.Errorf("BucketyName: %q", rec.req.BucketyName) } + if rec.req.AccessNamespace != "t1" || rec.req.AccessName != "reader" { + t.Errorf("access identity: %q/%q", rec.req.AccessNamespace, rec.req.AccessName) + } var secret corev1.Secret if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "t1", Name: "reader-creds"}, &secret); err != nil { t.Fatalf("minted secret: %v", err) diff --git a/pkg/drivers/registry/registry.go b/pkg/drivers/registry/registry.go index 648380a..f334138 100644 --- a/pkg/drivers/registry/registry.go +++ b/pkg/drivers/registry/registry.go @@ -138,6 +138,11 @@ type GrantRequest struct { // BucketyName is the resolved backend resource name the // access is being minted for. BucketyName string + // AccessNamespace and AccessName identify the BucketyAccess. + // Drivers that create one backend principal per access (mysql + // users) derive its name from them; the others ignore them. + AccessNamespace string + AccessName string // Role is BucketyAccess.spec.role. v1alpha1 drivers // typically ignore this and return Scoped=false. Role string From 2d8e3206ae8ca72afb54e860a0c0ee00b3e3161e Mon Sep 17 00:00:00 2001 From: Yolean gle-qa Date: Thu, 1 Oct 2026 20:28:02 +0000 Subject: [PATCH 02/15] mysql: driver for MariaDB/MySQL databases and per-access users A Buckety is a database (CREATE DATABASE IF NOT EXISTS, optional characterSet/collation), a BucketyAccess is a user of its own with the role's privileges on that one database: Reader gets SELECT, ReadWrite the data changes plus the DDL schema migrations need. Writer is refused rather than given a guessed grant set. Delete drops the database; users are dropped with their access under every retention policy. Security model. The controller's account needs CREATE USER on *.*, which neither server can scope by name and which also allows ALTER/RENAME/DROP USER on every account. The backend's namePrefix is therefore the boundary, enforced in one place (checkManaged): every database and user name, including principals read back from status, must start with it, be lowercase [a-z0-9_-] within the limits, and not be a system name; the prefix must end in "_", stay off system names, and exclude the admin account. Grants use the database name with LIKE wildcards escaped (an unescaped "_" in b_t1_orders would also match b_t1xorders). Account names and passwords travel as ? arguments with client-side interpolation, because CREATE USER/GRANT are not parameterisable server-side; identifiers are validated and backtick-quoted; privilege names read from the server are checked before they reach REVOKE. Names. Databases are the resolved spec.name, which must carry the prefix, so status.backendResourceName and the Secret's database key are the real name. Users are _, or a hashed form when that does not fit MySQL 8's 32-character limit (MariaDB allows 80; one scheme keeps backends portable). Kubernetes names have no "_", so the two forms cannot collide. Drift without reading the mysql schema. The admin account cannot see other accounts or their grants, so the driver logs in as each user with the Secret's password: success reads the user's own SCHEMA_PRIVILEGES and aligns grants (revoking extras and GRANT OPTION), writing nothing when nothing differs, which matters on Galera where account changes replicate as TOI. A refused login means a missing user or a changed password: CREATE or ALTER USER, grant, and verify with a short retry for Galera lag. Passwords are generated from crypto/rand at the first grant and read back from the Secret afterwards, so a password written into the Secret is applied and deleting the Secret rotates it. Errors from statements carrying a password are redacted; no DSN strings are built. Tested with the env-gated integration test against MariaDB 10.11.14 and MySQL 8.0.46 run from distro packages in userspace. Co-Authored-By: Claude Opus 5.5 --- cmd/buckety/main.go | 1 + go.mod | 2 + go.sum | 4 + .../schema/buckety-controller.schema.json | 4 +- pkg/drivers/mysql/config.go | 151 +++++ pkg/drivers/mysql/config_test.go | 191 ++++++ pkg/drivers/mysql/conn.go | 141 +++++ pkg/drivers/mysql/driver.go | 423 +++++++++++++ pkg/drivers/mysql/driver_test.go | 563 ++++++++++++++++++ pkg/drivers/mysql/integration_test.go | 390 ++++++++++++ pkg/drivers/mysql/names.go | 179 ++++++ pkg/drivers/mysql/names_test.go | 209 +++++++ pkg/drivers/mysql/password.go | 70 +++ pkg/drivers/mysql/password_test.go | 62 ++ .../mysql/schema/v0.1/config.schema.json | 59 ++ .../mysql/schema/v0.1/parameters.schema.json | 21 + pkg/drivers/mysql/statements.go | 124 ++++ pkg/drivers/mysql/statements_test.go | 84 +++ schema/README.md | 1 + schema/buckety-mysql.schema.json | 211 +++++++ schema/buckety.schema.json | 2 +- scripts/gen-cr-schemas/main.go | 5 +- 22 files changed, 2893 insertions(+), 4 deletions(-) create mode 100644 pkg/drivers/mysql/config.go create mode 100644 pkg/drivers/mysql/config_test.go create mode 100644 pkg/drivers/mysql/conn.go create mode 100644 pkg/drivers/mysql/driver.go create mode 100644 pkg/drivers/mysql/driver_test.go create mode 100644 pkg/drivers/mysql/integration_test.go create mode 100644 pkg/drivers/mysql/names.go create mode 100644 pkg/drivers/mysql/names_test.go create mode 100644 pkg/drivers/mysql/password.go create mode 100644 pkg/drivers/mysql/password_test.go create mode 100644 pkg/drivers/mysql/schema/v0.1/config.schema.json create mode 100644 pkg/drivers/mysql/schema/v0.1/parameters.schema.json create mode 100644 pkg/drivers/mysql/statements.go create mode 100644 pkg/drivers/mysql/statements_test.go create mode 100644 schema/buckety-mysql.schema.json diff --git a/cmd/buckety/main.go b/cmd/buckety/main.go index 98f761d..3e9deb1 100644 --- a/cmd/buckety/main.go +++ b/cmd/buckety/main.go @@ -46,6 +46,7 @@ import ( // this import list. _ "github.com/Yolean/buckety-controller/pkg/drivers/gcs" _ "github.com/Yolean/buckety-controller/pkg/drivers/kadm" + _ "github.com/Yolean/buckety-controller/pkg/drivers/mysql" _ "github.com/Yolean/buckety-controller/pkg/drivers/s3" ) diff --git a/go.mod b/go.mod index f5876d8..ecd80d6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.24 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/aws/smithy-go v1.27.2 + github.com/go-sql-driver/mysql v1.10.1 github.com/twmb/franz-go v1.21.2 github.com/twmb/franz-go/pkg/kadm v1.18.0 github.com/twmb/franz-go/pkg/kmsg v1.13.1 @@ -32,6 +33,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect diff --git a/go.sum b/go.sum index aba69dc..7dded0b 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ cloud.google.com/go/storage v1.63.1 h1:CYXILV9G4CH0C18IQ9+V0h4XiqD2LhKnMLO0o7uJW cloud.google.com/go/storage v1.63.1/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= @@ -104,6 +106,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= +github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/pkg/config/schema/buckety-controller.schema.json b/pkg/config/schema/buckety-controller.schema.json index 5c1915e..083978e 100644 --- a/pkg/config/schema/buckety-controller.schema.json +++ b/pkg/config/schema/buckety-controller.schema.json @@ -21,8 +21,8 @@ }, "driver": { "type": "string", - "enum": ["kadm", "s3", "gcs"], - "description": "Registered driver name. The compiled-in set for this repo's binary is kadm, s3 and gcs." + "enum": ["kadm", "s3", "gcs", "mysql"], + "description": "Registered driver name. The compiled-in set for this repo's binary is kadm, s3, gcs and mysql." }, "config": { "type": "object", diff --git a/pkg/drivers/mysql/config.go b/pkg/drivers/mysql/config.go new file mode 100644 index 0000000..85597fd --- /dev/null +++ b/pkg/drivers/mysql/config.go @@ -0,0 +1,151 @@ +package mysql + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/y-cluster/pkg/envsubst" + yaml "sigs.k8s.io/yaml" +) + +// Config defaults, applied when the key is absent. +const ( + defaultPort = 3306 + defaultNamePrefix = "b_" + defaultUserHost = "%" +) + +// Config is the typed shape of the `config:` block under a mysql +// backend. Mirrors schema/v0.1/config.schema.json. +type Config struct { + // Host is the server address, used by the controller and + // written to access Secrets. + Host string `json:"host"` + // Port defaults to 3306. + Port int `json:"port,omitempty"` + // AdminUser / AdminPassword are the controller's account; see + // docs/mysql.md for the grants it needs. + AdminUser string `json:"adminUser" envsubst:"true"` + AdminPassword string `json:"adminPassword" envsubst:"true"` + // NamePrefix scopes every database and user the driver touches. + // Defaults to "b_". + NamePrefix string `json:"namePrefix,omitempty"` + // UserHost is the host part of created accounts. Defaults to "%". + UserHost string `json:"userHost,omitempty"` + // TLS, when present, requires verified TLS for the controller's + // connections (admin and login checks). + TLS *TLSConfig `json:"tls,omitempty"` +} + +// TLSConfig enables verified TLS towards the server. +type TLSConfig struct { + // CAFile is a PEM bundle to verify the server with; system + // roots when empty. + CAFile string `json:"caFile,omitempty"` + // ServerName overrides the name verified in the server + // certificate; defaults to Host. + ServerName string `json:"serverName,omitempty"` +} + +func decodeConfig(raw json.RawMessage) (*Config, error) { + var c Config + if err := yaml.UnmarshalStrict(raw, &c); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + if err := envsubst.Apply(&c, envsubst.OSEnv); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + if c.Port == 0 { + c.Port = defaultPort + } + if c.NamePrefix == "" { + c.NamePrefix = defaultNamePrefix + } + if c.UserHost == "" { + c.UserHost = defaultUserHost + } + if err := c.validate(); err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + return &c, nil +} + +func (c *Config) validate() error { + switch { + case c.Host == "": + return fmt.Errorf("missing required field %q", "host") + case strings.ContainsAny(c.Host, "/@?# \t"), + strings.Contains(c.Host, ":") && net.ParseIP(c.Host) == nil: + return fmt.Errorf("host %q must be a bare host name or IP, without scheme, port or path", c.Host) + case c.Port < 1 || c.Port > 65535: + return fmt.Errorf("port %d is out of range", c.Port) + case c.AdminUser == "": + return fmt.Errorf("missing required field %q", "adminUser") + case c.AdminPassword == "": + return fmt.Errorf("missing required field %q", "adminPassword") + } + if err := checkPrefix(c.NamePrefix); err != nil { + return err + } + if strings.HasPrefix(c.AdminUser, c.NamePrefix) { + return fmt.Errorf("adminUser %q starts with namePrefix %q; the controller's own account must be outside the names it manages", c.AdminUser, c.NamePrefix) + } + if err := checkHost(c.UserHost); err != nil { + return fmt.Errorf("userHost: %w", err) + } + return nil +} + +// Connection timeouts. Reads are generous because DROP DATABASE on +// a large database returns only when the files are gone. +const ( + dialTimeout = 10 * time.Second + ioTimeout = 60 * time.Second +) + +// clientConfig builds the go-sql-driver config for the admin +// account. Logins as created users clone it. InterpolateParams +// makes the client escape ? arguments into string literals, which +// is what lets account names and passwords travel as arguments in +// statements (CREATE USER, GRANT) that the server-side prepared +// statement protocol does not parameterise. +func (c *Config) clientConfig() (*gomysql.Config, error) { + mc := gomysql.NewConfig() + mc.User = c.AdminUser + mc.Passwd = c.AdminPassword + mc.Net = "tcp" + mc.Addr = net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) + mc.Timeout = dialTimeout + mc.ReadTimeout = ioTimeout + mc.WriteTimeout = ioTimeout + mc.InterpolateParams = true + mc.Collation = "utf8mb4_general_ci" + if c.TLS != nil { + tc := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.TLS.ServerName} + if tc.ServerName == "" { + tc.ServerName = c.Host + } + if c.TLS.CAFile != "" { + pem, err := os.ReadFile(c.TLS.CAFile) + if err != nil { + return nil, fmt.Errorf("tls.caFile: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("tls.caFile %q holds no PEM certificates", c.TLS.CAFile) + } + tc.RootCAs = pool + } + mc.TLS = tc + } + return mc, nil +} diff --git a/pkg/drivers/mysql/config_test.go b/pkg/drivers/mysql/config_test.go new file mode 100644 index 0000000..cd62220 --- /dev/null +++ b/pkg/drivers/mysql/config_test.go @@ -0,0 +1,191 @@ +package mysql + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" + yaml "sigs.k8s.io/yaml" +) + +// testAdminPassword stands in for the env-substituted secret; the +// tests assert it never shows up in an error. +const testAdminPassword = "Admin-Pw-From-Env-0123" + +func rawConfig(t *testing.T, y string) json.RawMessage { + t.Helper() + j, err := yaml.YAMLToJSON([]byte(y)) + if err != nil { + t.Fatal(err) + } + return j +} + +func TestDecodeConfigDefaults(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + c, err := decodeConfig(rawConfig(t, ` +host: mysql.mysql.svc.cluster.local +adminUser: buckety +adminPassword: ${MYSQL_BUCKETY_PASSWORD} +`)) + if err != nil { + t.Fatal(err) + } + if c.Port != 3306 || c.NamePrefix != "b_" || c.UserHost != "%" || c.TLS != nil { + t.Errorf("defaults: %+v", *c) + } + if c.AdminPassword != testAdminPassword { + t.Error("adminPassword not substituted from the environment") + } +} + +func TestDecodeConfigExplicit(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + t.Setenv("MYSQL_BUCKETY_USER", "buckety") + c, err := decodeConfig(rawConfig(t, ` +host: 10.0.0.5 +port: 3307 +adminUser: ${MYSQL_BUCKETY_USER} +adminPassword: ${MYSQL_BUCKETY_PASSWORD} +namePrefix: app_ +userHost: 10.42.% +`)) + if err != nil { + t.Fatal(err) + } + if c.Port != 3307 || c.NamePrefix != "app_" || c.UserHost != "10.42.%" || c.AdminUser != "buckety" { + t.Errorf("explicit values: %+v", *c) + } +} + +func TestDecodeConfigErrors(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + base := "host: db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n" + cases := map[string]string{ + "unknown field": base + "adminPasword: x\n", + "missing host": "adminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "missing adminUser": "host: db\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "missing adminPassword": "host: db\nadminUser: buckety\n", + "undefined variable": "host: db\nadminUser: buckety\nadminPassword: ${NOT_SET_ANYWHERE_42}\n", + "untagged substitution": "host: ${MYSQL_BUCKETY_PASSWORD}\nadminUser: buckety\nadminPassword: x\n", + "host with path": "host: db/x\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "host with scheme": "host: mysql://db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "host with port": "host: db:3306\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "port range": base + "port: 70000\n", + "prefix without _": base + "namePrefix: b\n", + "empty-ish prefix": base + "namePrefix: _\n", + "system prefix": base + "namePrefix: performance_\n", + "admin inside prefix": "host: db\nadminUser: b_admin\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", + "bad userHost": base + "userHost: \"%' OR 1\"\n", + "tls unknown field": base + "tls:\n insecure: true\n", + } + for name, y := range cases { + _, err := decodeConfig(rawConfig(t, y)) + if err == nil { + t.Errorf("%s: accepted", name) + continue + } + if strings.Contains(err.Error(), testAdminPassword) { + t.Errorf("%s: error contains the admin password", name) + } + } +} + +func TestFactoryRegisteredAndLazy(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) + f, ok := registry.Lookup(DriverName) + if !ok { + t.Fatal("mysql driver not registered") + } + // No server at this address: the factory must not connect. + d, err := f(rawConfig(t, "host: 127.0.0.1\nport: 1\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n")) + if err != nil { + t.Fatalf("factory: %v", err) + } + if d.Name() != "mysql" || d.Version() == "" { + t.Fatalf("name/version: %q %q", d.Name(), d.Version()) + } +} + +func TestClientConfig(t *testing.T) { + c := &Config{Host: "db.example", Port: 3306, AdminUser: "buckety", AdminPassword: testAdminPassword, NamePrefix: "b_", UserHost: "%"} + mc, err := c.clientConfig() + if err != nil { + t.Fatal(err) + } + if mc.Addr != "db.example:3306" || !mc.InterpolateParams || mc.MultiStatements || mc.AllowAllFiles || mc.TLS != nil || mc.AllowCleartextPasswords { + t.Errorf("client config: addr=%s interpolate=%v multi=%v files=%v tls=%v cleartext=%v", + mc.Addr, mc.InterpolateParams, mc.MultiStatements, mc.AllowAllFiles, mc.TLS != nil, mc.AllowCleartextPasswords) + } + c.Host = "fd00::1" + if mc, _ := c.clientConfig(); mc.Addr != "[fd00::1]:3306" { + t.Errorf("IPv6 addr = %s", mc.Addr) + } +} + +func TestClientConfigTLS(t *testing.T) { + dir := t.TempDir() + ca := filepath.Join(dir, "ca.crt") + writeTestCA(t, ca) + c := &Config{Host: "db.example", Port: 3306, AdminUser: "buckety", AdminPassword: "x", TLS: &TLSConfig{CAFile: ca}} + mc, err := c.clientConfig() + if err != nil { + t.Fatal(err) + } + if mc.TLS == nil || mc.TLS.RootCAs == nil || mc.TLS.ServerName != "db.example" || mc.TLS.InsecureSkipVerify { + t.Fatalf("tls config: %+v", mc.TLS) + } + c.TLS.ServerName = "mariadb.mysql.svc" + if mc, _ := c.clientConfig(); mc.TLS.ServerName != "mariadb.mysql.svc" { + t.Errorf("serverName override: %s", mc.TLS.ServerName) + } + + c.TLS = &TLSConfig{CAFile: filepath.Join(dir, "missing.crt")} + if _, err := c.clientConfig(); err == nil { + t.Error("missing caFile accepted") + } + notPEM := filepath.Join(dir, "not.pem") + if err := os.WriteFile(notPEM, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + c.TLS = &TLSConfig{CAFile: notPEM} + if _, err := c.clientConfig(); err == nil { + t.Error("caFile without certificates accepted") + } + // System roots when no caFile. + c.TLS = &TLSConfig{} + if mc, err := c.clientConfig(); err != nil || mc.TLS == nil || mc.TLS.RootCAs != nil { + t.Errorf("system roots: %v", err) + } +} + +func writeTestCA(t *testing.T, path string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-ca"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/drivers/mysql/conn.go b/pkg/drivers/mysql/conn.go new file mode 100644 index 0000000..c4717c8 --- /dev/null +++ b/pkg/drivers/mysql/conn.go @@ -0,0 +1,141 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + gomysql "github.com/go-sql-driver/mysql" +) + +// MySQL/MariaDB error numbers the driver branches on. +const ( + erAccessDenied = 1045 // login refused: unknown user or wrong password + erCannotUser = 1396 // CREATE USER of an existing account, and similar +) + +var ( + // errLoginDenied is what conn.login returns for erAccessDenied. + errLoginDenied = errors.New("login denied") + // errShadowed: the login matched another account with the same + // user name and a more specific host, so the managed account + // could not be inspected. Seen while userHost changes. + errShadowed = errors.New("login matched another account") +) + +// schemaInfo is a database's defaults as SCHEMATA reports them. +type schemaInfo struct { + charset string + collation string +} + +// grants is what a created user holds on its database. +type grants struct { + privileges []string + grantOption bool +} + +// conn is the driver's view of the server; sqlConn implements it, +// tests substitute a fake. +type conn interface { + // exec runs one statement as the admin account. + exec(ctx context.Context, query string, args ...any) error + // lookupSchema returns nil when the database does not exist or + // is not visible to the admin account. + lookupSchema(ctx context.Context, name string) (*schemaInfo, error) + // countContent counts tables, views, routines and events. + countContent(ctx context.Context, name string) (int, error) + // login connects as acct and reads its grants on the escaped + // database pattern. errLoginDenied when the server refuses. + login(ctx context.Context, acct account, password, pattern string) (*grants, error) +} + +type sqlConn struct { + admin *sql.DB + // base is the admin client config; logins clone it. + base *gomysql.Config +} + +func newSQLConn(base *gomysql.Config) (*sqlConn, error) { + connector, err := gomysql.NewConnector(base) + if err != nil { + return nil, err + } + db := sql.OpenDB(connector) + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(2) + db.SetConnMaxLifetime(5 * time.Minute) + return &sqlConn{admin: db, base: base}, nil +} + +func (c *sqlConn) exec(ctx context.Context, query string, args ...any) error { + _, err := c.admin.ExecContext(ctx, query, args...) + return err +} + +func (c *sqlConn) lookupSchema(ctx context.Context, name string) (*schemaInfo, error) { + var s schemaInfo + err := c.admin.QueryRowContext(ctx, schemaSQL, name).Scan(&s.charset, &s.collation) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func (c *sqlConn) countContent(ctx context.Context, name string) (int, error) { + var n int + err := c.admin.QueryRowContext(ctx, contentSQL, name, name, name).Scan(&n) + return n, err +} + +func (c *sqlConn) login(ctx context.Context, acct account, password, pattern string) (*grants, error) { + cfg := c.base.Clone() + cfg.User = acct.User + cfg.Passwd = password + cfg.DBName = "" + connector, err := gomysql.NewConnector(cfg) + if err != nil { + return nil, err + } + db := sql.OpenDB(connector) + defer db.Close() + db.SetMaxOpenConns(1) + + var current string + if err := db.QueryRowContext(ctx, currentUserSQL).Scan(¤t); err != nil { + if isMySQLError(err, erAccessDenied) { + return nil, errLoginDenied + } + return nil, err + } + if current != acct.String() { + return nil, fmt.Errorf("%w: logged in as %s instead of %s", errShadowed, current, acct) + } + rows, err := db.QueryContext(ctx, privilegesSQL, acct.grantee(), pattern) + if err != nil { + return nil, err + } + defer rows.Close() + g := &grants{} + for rows.Next() { + var priv, grantable string + if err := rows.Scan(&priv, &grantable); err != nil { + return nil, err + } + g.privileges = append(g.privileges, priv) + if grantable == "YES" { + g.grantOption = true + } + } + return g, rows.Err() +} + +func isMySQLError(err error, number uint16) bool { + var me *gomysql.MySQLError + return errors.As(err, &me) && me.Number == number +} diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go new file mode 100644 index 0000000..230cd6a --- /dev/null +++ b/pkg/drivers/mysql/driver.go @@ -0,0 +1,423 @@ +// Package mysql is the MariaDB/MySQL driver: a Buckety is a +// database, a BucketyAccess is a user with grants on that one +// database. +// +// The controller's account holds CREATE USER globally (MariaDB and +// MySQL cannot scope it by name) plus ALL on the prefix pattern. +// The driver therefore treats the backend's namePrefix as its +// security boundary: every database and account name passes +// checkManaged before it reaches SQL, and anything outside the +// prefix is refused (names.go). docs/mysql.md has the account +// setup, the naming rules and the decisions behind them. +// +// Credentials: each access gets its own user with a generated +// password, written to its Secret. GrantAccess reuses the Secret's +// password, so a password changed in the Secret is applied to the +// server, and deleting the Secret rotates it. Drift is detected by +// logging in as the user: a refused login means the user is +// missing or its password differs (both repaired by +// CREATE/ALTER USER), and a successful one reads the user's own +// grants, which the admin account cannot see without SELECT on the +// mysql schema. +package mysql + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +// DriverName matches backends[].driver in buckety-controller.yaml. +const DriverName = "mysql" + +// version is the driver SemVer. Injected at build time via +// +// -ldflags '-X github.com/Yolean/buckety-controller/pkg/drivers/mysql.version=0.1.0' +// +// per SPEC §Driver versioning. Default keeps tests building. +var version = "0.1.0" + +func init() { + registry.Register(DriverName, version, factory) +} + +func factory(raw json.RawMessage) (registry.Driver, error) { + cfg, err := decodeConfig(raw) + if err != nil { + return nil, err + } + return newDriver(cfg) +} + +// newDriver connects lazily: a server that is down at controller +// start fails reconciles, not startup. +func newDriver(cfg *Config) (*Driver, error) { + mc, err := cfg.clientConfig() + if err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + c, err := newSQLConn(mc) + if err != nil { + return nil, fmt.Errorf("mysql config: %w", err) + } + return &Driver{cfg: cfg, conn: c}, nil +} + +// Driver implements registry.Driver for MariaDB/MySQL. +type Driver struct { + cfg *Config + conn conn +} + +func (d *Driver) Name() string { return DriverName } +func (d *Driver) Version() string { return version } + +// InspectBuckety reports whether the database exists and whether +// it holds tables, views, routines or events. +func (d *Driver) InspectBuckety(ctx context.Context, name string) (registry.Inspection, error) { + if err := d.checkDatabase(name); err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: %w", err) + } + s, err := d.conn.lookupSchema(ctx, name) + if err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: look up database %q: %w", name, err) + } + if s == nil { + return registry.Inspection{}, nil + } + n, err := d.conn.countContent(ctx, name) + if err != nil { + return registry.Inspection{}, fmt.Errorf("mysql: count objects in database %q: %w", name, err) + } + return registry.Inspection{Exists: true, Empty: n == 0}, nil +} + +// EnsureBuckety creates the database when missing and reports a +// character set or collation that differs from the parameters as +// ParameterDrift. The driver never alters an existing database's +// defaults: ALTER DATABASE changes them for new tables only, so +// converging silently would hide that existing tables still differ. +func (d *Driver) EnsureBuckety(ctx context.Context, req registry.EnsureRequest) error { + if err := d.checkDatabase(req.Name); err != nil { + return fmt.Errorf("mysql: %w", err) + } + if err := d.ValidateParameters(req.Parameters); err != nil { + return fmt.Errorf("mysql: %w", err) + } + charset, collation := req.Parameters["characterSet"], req.Parameters["collation"] + s, err := d.conn.lookupSchema(ctx, req.Name) + if err != nil { + return fmt.Errorf("mysql: look up database %q: %w", req.Name, err) + } + if s == nil { + if err := d.conn.exec(ctx, createDatabaseSQL(req.Name, charset, collation)); err != nil { + return fmt.Errorf("mysql: create database %q: %w", req.Name, err) + } + if s, err = d.conn.lookupSchema(ctx, req.Name); err != nil { + return fmt.Errorf("mysql: look up database %q: %w", req.Name, err) + } + if s == nil { + return fmt.Errorf("mysql: database %q exists but is not visible to the controller account; its grants must cover the namePrefix %q", req.Name, d.cfg.NamePrefix) + } + } + var drift []string + if charset != "" && !strings.EqualFold(charset, s.charset) { + drift = append(drift, fmt.Sprintf("characterSet: current=%q requested=%q", s.charset, charset)) + } + if collation != "" && !strings.EqualFold(collation, s.collation) { + drift = append(drift, fmt.Sprintf("collation: current=%q requested=%q", s.collation, collation)) + } + if len(drift) > 0 { + return ®istry.ErrParameterDrift{Reason: fmt.Sprintf( + "database %q %s; the driver does not change an existing database's defaults or convert its tables: ALTER DATABASE (and the tables) out of band, or recreate the Buckety", + req.Name, strings.Join(drift, ", "))} + } + return nil +} + +// DeleteBuckety drops the database and everything in it. +// Idempotent; one statement, so there is no in-progress state. +func (d *Driver) DeleteBuckety(ctx context.Context, req registry.DeleteRequest) error { + if err := d.checkDatabase(req.Name); err != nil { + return fmt.Errorf("mysql: %w", err) + } + if err := d.conn.exec(ctx, dropDatabaseSQL(req.Name)); err != nil { + return fmt.Errorf("mysql: drop database %q: %w", req.Name, err) + } + return nil +} + +// GrantAccess ensures the access's user exists with the Secret's +// password (or a generated one when the Secret has none) and holds +// exactly the role's privileges on the database, then returns the +// Secret payload. +// +// Secret keys: host, port, database (resource-type key), username, +// password, jdbcUrl, url. +func (d *Driver) GrantAccess(ctx context.Context, req registry.GrantRequest) (registry.GrantResult, error) { + want, err := rolePrivileges(req.Role) + if err != nil { + return registry.GrantResult{}, err + } + db := req.BucketyName + if err := d.checkDatabase(db); err != nil { + return registry.GrantResult{}, fmt.Errorf("mysql: %w", err) + } + user, err := d.userName(req.AccessNamespace, req.AccessName) + if err != nil { + return registry.GrantResult{}, err + } + acct := account{User: user, Host: d.cfg.UserHost} + + password, fromSecret := string(req.ExistingSecretData["password"]), true + if password == "" { + fromSecret = false + if password, err = generatePassword(); err != nil { + return registry.GrantResult{}, err + } + } else if err := validatePassword(password); err != nil { + return registry.GrantResult{}, fmt.Errorf("mysql: the access Secret's password: %w", err) + } + + created, err := d.ensureAccount(ctx, acct, password, fromSecret, grantPattern(db), want) + if err != nil { + if created { + // Nothing records a user created in a failed pass: drop + // it, the next pass creates it again. + _ = d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host) + } + return registry.GrantResult{}, err + } + return registry.GrantResult{ + SecretData: d.secretData(db, user, password), + Principal: acct.String(), + Scoped: true, + Revocable: true, + Minted: created, + }, nil +} + +// Login verification after a password change: Galera applies +// account changes on the other nodes a moment after the statement +// returns on the one the controller wrote to. +const verifyAttempts = 5 + +var verifyBackoff = time.Second + +// ensureAccount converges acct and reports whether it created the +// user. fromSecret says the password is already stored in the +// access Secret; only then is a failed verification an error, +// because a generated password must reach the Secret first. +func (d *Driver) ensureAccount(ctx context.Context, acct account, password string, fromSecret bool, pattern string, want []string) (created bool, err error) { + if fromSecret { + have, err := d.conn.login(ctx, acct, password, pattern) + if err == nil { + return false, d.alignGrants(ctx, acct, pattern, want, have) + } + if !errors.Is(err, errLoginDenied) && !errors.Is(err, errShadowed) { + return false, fmt.Errorf("mysql: log in as %s to check it: %w", acct, err) + } + // Missing user, a password that differs from the Secret, or + // an account for another userHost matched instead. + } + if created, err = d.setPassword(ctx, acct, password); err != nil { + return false, err + } + if err := d.alignGrants(ctx, acct, pattern, want, nil); err != nil { + return created, err + } + if !fromSecret { + return created, nil + } + for attempt := 1; ; attempt++ { + have, err := d.conn.login(ctx, acct, password, pattern) + if err == nil { + return created, d.alignGrants(ctx, acct, pattern, want, have) + } + if errors.Is(err, errShadowed) { + // The account for the previous userHost is more specific + // and still exists; the reconciler drops it once this + // grant's principal is recorded, and the next pass + // verifies. + return created, nil + } + if !errors.Is(err, errLoginDenied) || attempt == verifyAttempts { + return created, fmt.Errorf("mysql: set the password of %s from its Secret, but the controller cannot log in with it (userHost %q must admit the controller's address): %w", acct, d.cfg.UserHost, err) + } + select { + case <-ctx.Done(): + return created, ctx.Err() + case <-time.After(verifyBackoff): + } + } +} + +// setPassword creates the user, or sets the password of an existing +// one. Errors are redacted: the statements carry the password. +func (d *Driver) setPassword(ctx context.Context, acct account, password string) (created bool, err error) { + err = d.conn.exec(ctx, createUserSQL, acct.User, acct.Host, password) + if err == nil { + return true, nil + } + if !isMySQLError(err, erCannotUser) { + return false, fmt.Errorf("mysql: create user %s: %w", acct, redact(err, password)) + } + if err := d.conn.exec(ctx, alterUserSQL, acct.User, acct.Host, password); err != nil { + return false, fmt.Errorf("mysql: set password of %s: %w", acct, redact(err, password)) + } + return false, nil +} + +// alignGrants grants what is missing from want and revokes what is +// beyond it, including GRANT OPTION. have is nil when the current +// grants are unknown (the user was just created or re-keyed): then +// the whole set is granted and the rest of the managed set revoked, +// and a later pass with a working login revokes anything else. +func (d *Driver) alignGrants(ctx context.Context, acct account, pattern string, want []string, have *grants) error { + var missing, extra []string + grantOption := false + if have == nil { + missing = want + extra = without(readWritePrivileges, want) + } else { + missing = without(want, have.privileges) + extra = without(have.privileges, want) + grantOption = have.grantOption + } + if len(missing) > 0 { + q, err := grantSQL(missing, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: grant %s to %s: %w", strings.Join(missing, ", "), acct, err) + } + } + if len(extra) > 0 { + q, err := revokeSQL(extra, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: revoke %s from %s: %w", strings.Join(extra, ", "), acct, err) + } + } + if grantOption { + if err := d.conn.exec(ctx, revokeGrantOptionSQL(pattern), acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: revoke GRANT OPTION from %s: %w", acct, err) + } + } + return nil +} + +func (d *Driver) secretData(db, user, password string) map[string][]byte { + port := strconv.Itoa(d.cfg.Port) + hostPort := net.JoinHostPort(d.cfg.Host, port) + u := url.URL{Scheme: "mysql", User: url.UserPassword(user, password), Host: hostPort, Path: "/" + db} + return map[string][]byte{ + "host": []byte(d.cfg.Host), + "port": []byte(port), + "database": []byte(db), + "username": []byte(user), + "password": []byte(password), + "jdbcUrl": []byte("jdbc:mariadb://" + hostPort + "/" + db), + "url": []byte(u.String()), + } +} + +// RevokeAccess drops the access's user. Idempotent; an empty +// principal (never granted) has nothing to drop. +func (d *Driver) RevokeAccess(ctx context.Context, principal string) error { + if principal == "" { + return nil + } + acct, err := d.parsePrincipal(principal) + if err != nil { + return err + } + if err := d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: drop user %s: %w", acct, err) + } + return nil +} + +var ( + charsetRE = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}$`) + collationRE = regexp.MustCompile(`^[a-z][a-z0-9_]{1,63}$`) +) + +// ValidateParameters accepts characterSet and collation. "utf8" is +// refused: it means utf8mb3 today and is slated to mean utf8mb4, +// so the reported value would never match. +func (d *Driver) ValidateParameters(params map[string]string) error { + for k, v := range params { + switch k { + case "characterSet": + if !charsetRE.MatchString(v) { + return fmt.Errorf("parameters.characterSet %q must be a lowercase character set name, e.g. utf8mb4", v) + } + if v == "utf8" { + return fmt.Errorf("parameters.characterSet \"utf8\" is ambiguous; use utf8mb4 (or utf8mb3)") + } + case "collation": + if !collationRE.MatchString(v) { + return fmt.Errorf("parameters.collation %q must be a lowercase collation name, e.g. utf8mb4_unicode_ci", v) + } + if strings.HasPrefix(v, "utf8_") { + return fmt.Errorf("parameters.collation %q is ambiguous; use the utf8mb4_ (or utf8mb3_) name", v) + } + default: + return fmt.Errorf("unknown parameter %q (mysql v0.1 accepts: characterSet, collation)", k) + } + } + return nil +} + +// ValidateUpdateParameters: both parameters are set at creation and +// immutable, like a gcs bucket's location; adding or removing one +// counts as a change. +func (d *Driver) ValidateUpdateParameters(oldParams, newParams map[string]string) error { + if err := d.ValidateParameters(newParams); err != nil { + return err + } + for _, k := range []string{"characterSet", "collation"} { + if o, n := oldParams[k], newParams[k]; o != n { + return fmt.Errorf("parameters.%s is immutable post-create (current=%q, requested=%q); the driver does not convert existing tables", k, o, n) + } + } + return nil +} + +func (d *Driver) ValidateAccessParameters(params map[string]string) error { + if len(params) == 0 { + return nil + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + return fmt.Errorf("mysql accepts no BucketyAccess parameters; got: %s", strings.Join(keys, ", ")) +} + +// ValidateResourceName enforces the prefix boundary and MySQL's +// limits on the resolved spec.name. +func (d *Driver) ValidateResourceName(name string) error { + if err := d.checkDatabase(name); err != nil { + if !strings.HasPrefix(name, d.cfg.NamePrefix) { + return fmt.Errorf("%w (set spec.name, e.g. \"%s${namespace}_${name}\")", err, d.cfg.NamePrefix) + } + return err + } + return nil +} diff --git a/pkg/drivers/mysql/driver_test.go b/pkg/drivers/mysql/driver_test.go new file mode 100644 index 0000000..fa45ddb --- /dev/null +++ b/pkg/drivers/mysql/driver_test.go @@ -0,0 +1,563 @@ +package mysql + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" + "sort" + "strings" + "testing" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +// fakeConn records statements and plays scripted answers. It never +// prints what it records: tests compare passwords, they don't log +// them. +type fakeConn struct { + execs []execCall + onExec func(query string, args []any) error + schemas map[string]*schemaInfo + content map[string]int + // logins are consumed in order; the last one repeats. + logins []loginResult + loginCalls []loginCall + calls int +} + +type execCall struct { + query string + args []any +} + +type loginResult struct { + grants *grants + err error +} + +type loginCall struct { + acct account + password string + pattern string +} + +func (f *fakeConn) exec(_ context.Context, query string, args ...any) error { + f.calls++ + f.execs = append(f.execs, execCall{query, args}) + if f.onExec != nil { + return f.onExec(query, args) + } + return nil +} + +func (f *fakeConn) lookupSchema(_ context.Context, name string) (*schemaInfo, error) { + f.calls++ + return f.schemas[name], nil +} + +func (f *fakeConn) countContent(_ context.Context, name string) (int, error) { + f.calls++ + return f.content[name], nil +} + +func (f *fakeConn) login(_ context.Context, acct account, password, pattern string) (*grants, error) { + f.calls++ + f.loginCalls = append(f.loginCalls, loginCall{acct, password, pattern}) + if len(f.logins) == 0 { + return nil, errLoginDenied + } + r := f.logins[0] + if len(f.logins) > 1 { + f.logins = f.logins[1:] + } + return r.grants, r.err +} + +func (f *fakeConn) queries() []string { + out := make([]string, len(f.execs)) + for i, e := range f.execs { + out[i] = e.query + } + return out +} + +func allRW() *grants { return &grants{privileges: slices.Clone(readWritePrivileges)} } + +func grantReq(role string, secret map[string][]byte) registry.GrantRequest { + return registry.GrantRequest{ + BucketyName: "b_t1_orders", AccessNamespace: "t1", AccessName: "app", + Role: role, ExistingSecretData: secret, + } +} + +const secretPassword = "Secret-Pw-0123456789" + +func withPassword(p string) map[string][]byte { + return map[string][]byte{"password": []byte(p), "username": []byte("ignored")} +} + +func TestValidateParameters(t *testing.T) { + d := testDriver(nil) + if err := d.ValidateParameters(map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}); err != nil { + t.Fatal(err) + } + if err := d.ValidateParameters(nil); err != nil { + t.Fatal(err) + } + for _, p := range []map[string]string{ + {"charset": "utf8mb4"}, + {"characterSet": "utf8"}, + {"characterSet": "UTF8MB4"}, + {"characterSet": "utf8mb4; DROP DATABASE mysql"}, + {"characterSet": "utf8mb4`"}, + {"collation": "utf8_general_ci"}, + {"collation": "utf8mb4 COLLATE x"}, + {"collation": ""}, + } { + if err := d.ValidateParameters(p); err == nil { + t.Errorf("parameters %v accepted", p) + } + } +} + +func TestValidateUpdateParameters(t *testing.T) { + d := testDriver(nil) + same := map[string]string{"characterSet": "utf8mb4"} + if err := d.ValidateUpdateParameters(same, same); err != nil { + t.Fatal(err) + } + for _, c := range [][2]map[string]string{ + {{"characterSet": "utf8mb4"}, {"characterSet": "latin1"}}, + {{}, {"collation": "utf8mb4_bin"}}, + {{"collation": "utf8mb4_bin"}, {}}, + } { + if err := d.ValidateUpdateParameters(c[0], c[1]); err == nil { + t.Errorf("%v -> %v accepted", c[0], c[1]) + } + } +} + +func TestValidateAccessParameters(t *testing.T) { + d := testDriver(nil) + if err := d.ValidateAccessParameters(nil); err != nil { + t.Fatal(err) + } + if err := d.ValidateAccessParameters(map[string]string{"host": "x"}); err == nil { + t.Fatal("access parameters accepted") + } +} + +func TestInspectBuckety(t *testing.T) { + fc := &fakeConn{ + schemas: map[string]*schemaInfo{"b_full": {}, "b_empty": {}}, + content: map[string]int{"b_full": 3}, + } + d := testDriver(fc) + for name, want := range map[string]registry.Inspection{ + "b_missing": {}, + "b_empty": {Exists: true, Empty: true}, + "b_full": {Exists: true, Empty: false}, + } { + got, err := d.InspectBuckety(context.Background(), name) + if err != nil || got != want { + t.Errorf("InspectBuckety(%s) = %+v, %v", name, got, err) + } + } + fc.calls = 0 + if _, err := d.InspectBuckety(context.Background(), "mysql"); err == nil || fc.calls != 0 { + t.Errorf("outside the prefix: err=%v calls=%d", err, fc.calls) + } +} + +func TestEnsureBucketyCreates(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{}} + fc.onExec = func(q string, _ []any) error { + fc.schemas["b_t1_orders"] = &schemaInfo{charset: "utf8mb4", collation: "utf8mb4_unicode_ci"} + return nil + } + err := testDriver(fc).EnsureBuckety(context.Background(), registry.EnsureRequest{ + Name: "b_t1_orders", + Parameters: map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}, + }) + if err != nil { + t.Fatal(err) + } + want := []string{"CREATE DATABASE IF NOT EXISTS `b_t1_orders` CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`"} + if !slices.Equal(fc.queries(), want) { + t.Fatalf("statements: %q", fc.queries()) + } +} + +func TestEnsureBucketyExisting(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{"b_x": {charset: "utf8mb4", collation: "utf8mb4_unicode_ci"}}} + d := testDriver(fc) + ctx := context.Background() + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"}}); err != nil { + t.Fatal(err) + } + // Server spelling may differ in case. + fc.schemas["b_x"] = &schemaInfo{charset: "UTF8MB4", collation: "UTF8MB4_UNICODE_CI"} + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4"}}); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 0 { + t.Fatalf("existing database altered: %q", fc.queries()) + } +} + +func TestEnsureBucketyDrift(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{"b_x": {charset: "latin1", collation: "latin1_swedish_ci"}}} + d := testDriver(fc) + for _, p := range []map[string]string{{"characterSet": "utf8mb4"}, {"collation": "latin1_bin"}} { + err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x", Parameters: p}) + if !registry.IsParameterDrift(err) { + t.Errorf("%v: want ParameterDrift, got %v", p, err) + } + } + if len(fc.execs) != 0 { + t.Fatalf("drift was 'repaired': %q", fc.queries()) + } + // Parameters absent: nothing managed, nothing drifts. + if err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x"}); err != nil { + t.Fatal(err) + } +} + +func TestEnsureBucketyRefusals(t *testing.T) { + fc := &fakeConn{schemas: map[string]*schemaInfo{}} + d := testDriver(fc) + for _, req := range []registry.EnsureRequest{ + {Name: "mysql"}, + {Name: "keycloak"}, + {Name: "b_x`; DROP DATABASE mysql; --"}, + {Name: "b_x", Parameters: map[string]string{"characterSet": "utf8mb4 COLLATE x"}}, + } { + if err := d.EnsureBuckety(context.Background(), req); err == nil { + t.Errorf("%+v accepted", req) + } + } + if fc.calls != 0 { + t.Fatalf("refused requests reached the server: %d calls", fc.calls) + } + // Created but invisible: the admin grants do not cover it. + if err := d.EnsureBuckety(context.Background(), registry.EnsureRequest{Name: "b_x"}); err == nil || !strings.Contains(err.Error(), "not visible") { + t.Fatalf("invisible database: %v", err) + } +} + +func TestDeleteBuckety(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + if err := d.DeleteBuckety(context.Background(), registry.DeleteRequest{Name: "b_t1_orders"}); err != nil { + t.Fatal(err) + } + if !slices.Equal(fc.queries(), []string{"DROP DATABASE IF EXISTS `b_t1_orders`"}) { + t.Fatalf("statements: %q", fc.queries()) + } + for _, n := range []string{"mysql", "sys", "keycloak", ""} { + if err := d.DeleteBuckety(context.Background(), registry.DeleteRequest{Name: n}); err == nil { + t.Errorf("DeleteBuckety(%q) accepted", n) + } + } + if len(fc.execs) != 1 { + t.Fatalf("refused deletes reached the server: %q", fc.queries()) + } +} + +func TestGrantFirstMint(t *testing.T) { + fc := &fakeConn{} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err != nil { + t.Fatal(err) + } + if len(fc.loginCalls) != 0 { + t.Errorf("first mint logged in %d times; nothing to verify yet", len(fc.loginCalls)) + } + if len(fc.execs) != 2 || fc.execs[0].query != createUserSQL || !strings.HasPrefix(fc.execs[1].query, "GRANT SELECT, INSERT") { + t.Fatalf("statements: %q", fc.queries()) + } + pw := string(res.SecretData["password"]) + if !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%", pw}) { + t.Error("CREATE USER arguments are not (user, host, the Secret's password)") + } + if !slices.Equal(fc.execs[1].args, []any{"b_t1_app", "%"}) { + t.Errorf("GRANT arguments: %v", fc.execs[1].args) + } + if !strings.Contains(fc.execs[1].query, "ON `b\\_t1\\_orders`.* TO ?@?") { + t.Errorf("GRANT is not scoped to the escaped database: %s", fc.execs[1].query) + } + if err := validatePassword(pw); err != nil || len(pw) != passwordLen { + t.Errorf("generated password: %v", err) + } + if res.Principal != "b_t1_app@%" || !res.Scoped || !res.Revocable || !res.Minted { + t.Errorf("result: principal=%q scoped=%v revocable=%v minted=%v", res.Principal, res.Scoped, res.Revocable, res.Minted) + } +} + +func TestGrantSecretContents(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: allRW()}}} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + keys := make([]string, 0, len(res.SecretData)) + for k := range res.SecretData { + keys = append(keys, k) + } + sort.Strings(keys) + if !slices.Equal(keys, []string{"database", "host", "jdbcUrl", "password", "port", "url", "username"}) { + t.Fatalf("keys: %v", keys) + } + want := map[string]string{ + "host": "mysql.mysql.svc.cluster.local", + "port": "3306", + "database": "b_t1_orders", + "username": "b_t1_app", + "jdbcUrl": "jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/b_t1_orders", + } + for k, v := range want { + if got := string(res.SecretData[k]); got != v { + t.Errorf("%s = %q, want %q", k, got, v) + } + } + if string(res.SecretData["password"]) != secretPassword { + t.Error("password is not the Secret's") + } + u, err := url.Parse(string(res.SecretData["url"])) + if err != nil { + t.Fatal("url does not parse") + } + p, _ := u.User.Password() + if u.Scheme != "mysql" || u.User.Username() != "b_t1_app" || p != secretPassword || u.Host != "mysql.mysql.svc.cluster.local:3306" || u.Path != "/b_t1_orders" { + t.Error("url does not carry user, password, host:port and database") + } + if res.Minted { + t.Error("existing user reported as minted") + } +} + +func TestGrantSteadyStateWritesNothing(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: allRW()}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("", withPassword(secretPassword))); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 0 { + t.Fatalf("steady state wrote: %q", fc.queries()) + } + lc := fc.loginCalls[0] + if lc.acct != (account{"b_t1_app", "%"}) || lc.password != secretPassword || lc.pattern != `b\_t1\_orders` { + t.Errorf("login: acct=%v pattern=%q secretPassword=%v", lc.acct, lc.pattern, lc.password == secretPassword) + } +} + +func TestGrantRepairsGrants(t *testing.T) { + cases := []struct { + name string + role string + have *grants + want []string + }{ + {"missing INSERT", "ReadWrite", &grants{privileges: without(readWritePrivileges, []string{"INSERT"})}, + []string{"GRANT INSERT ON `b\\_t1\\_orders`.* TO ?@?"}}, + {"no grants at all", "Reader", &grants{}, + []string{"GRANT SELECT ON `b\\_t1\\_orders`.* TO ?@?"}}, + {"extra privileges and grant option", "ReadWrite", &grants{privileges: append(slices.Clone(readWritePrivileges), "EXECUTE", "CREATE VIEW"), grantOption: true}, + []string{"REVOKE EXECUTE, CREATE VIEW ON `b\\_t1\\_orders`.* FROM ?@?", "REVOKE GRANT OPTION ON `b\\_t1\\_orders`.* FROM ?@?"}}, + {"role downgraded to Reader", "Reader", allRW(), + []string{"REVOKE INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES ON `b\\_t1\\_orders`.* FROM ?@?"}}, + } + for _, c := range cases { + fc := &fakeConn{logins: []loginResult{{grants: c.have}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq(c.role, withPassword(secretPassword))); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if !slices.Equal(fc.queries(), c.want) { + t.Errorf("%s:\n got %q\nwant %q", c.name, fc.queries(), c.want) + } + } +} + +func TestGrantRefusesUnexpectedPrivilegeNames(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{grants: &grants{privileges: append(slices.Clone(readWritePrivileges), "EXECUTE ON *.* TO x; --")}}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))); err == nil { + t.Fatal("privilege name from the server spliced unchecked") + } + if len(fc.execs) != 0 { + t.Fatalf("statements: %q", fc.queries()) + } +} + +func TestGrantRecreatesMissingUser(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}, {grants: allRW()}}} + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + if len(fc.execs) != 2 || fc.execs[0].query != createUserSQL || fc.execs[0].args[2] != secretPassword { + t.Fatalf("statements: %q", fc.queries()) + } + if len(fc.loginCalls) != 2 { + t.Errorf("login calls: %d (check + verify)", len(fc.loginCalls)) + } + if !res.Minted || string(res.SecretData["password"]) != secretPassword { + t.Errorf("minted=%v, password kept=%v", res.Minted, string(res.SecretData["password"]) == secretPassword) + } +} + +func TestGrantAppliesChangedPassword(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}, {grants: allRW()}}} + fc.onExec = func(q string, _ []any) error { + if q == createUserSQL { + return &gomysql.MySQLError{Number: erCannotUser, Message: "Operation CREATE USER failed for 'b_t1_app'@'%'"} + } + return nil + } + res, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + q := fc.queries() + if len(q) < 2 || q[0] != createUserSQL || q[1] != alterUserSQL || fc.execs[1].args[2] != secretPassword { + t.Fatalf("statements: %q", q) + } + if res.Minted { + t.Error("re-keyed user reported as minted") + } +} + +func TestGrantVerificationFailure(t *testing.T) { + orig := verifyBackoff + verifyBackoff = 0 + t.Cleanup(func() { verifyBackoff = orig }) + fc := &fakeConn{logins: []loginResult{{err: errLoginDenied}}} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err == nil || !strings.Contains(err.Error(), "userHost") { + t.Fatalf("want an error pointing at userHost, got %v", err) + } + if strings.Contains(err.Error(), secretPassword) { + t.Fatal("error contains the password") + } + if len(fc.loginCalls) != 1+verifyAttempts { + t.Errorf("login calls: %d", len(fc.loginCalls)) + } + // The user this pass created is dropped again. + last := fc.execs[len(fc.execs)-1] + if last.query != dropUserSQL || !slices.Equal(last.args, []any{"b_t1_app", "%"}) { + t.Errorf("last statement: %s %v", last.query, last.args) + } +} + +func TestGrantLoginErrorIsNotRepair(t *testing.T) { + fc := &fakeConn{logins: []loginResult{{err: errors.New("dial tcp: connection refused")}}} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))); err == nil { + t.Fatal("login error swallowed") + } + if len(fc.execs) != 0 { + t.Fatalf("an unreachable server is no reason to re-key: %q", fc.queries()) + } +} + +func TestGrantRedactsPassword(t *testing.T) { + fc := &fakeConn{} + fc.onExec = func(q string, args []any) error { + return &gomysql.MySQLError{Number: 1064, Message: fmt.Sprintf("syntax error near '%v'", args[len(args)-1])} + } + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err == nil || !strings.Contains(err.Error(), "") { + t.Fatalf("want a redacted error, got %v", err) + } + if strings.Contains(err.Error(), string(fc.execs[0].args[2].(string))) { + t.Fatal("error contains the generated password") + } +} + +func TestGrantRefusals(t *testing.T) { + cases := map[string]registry.GrantRequest{ + "Writer role": grantReq("Writer", nil), + "unknown role": grantReq("Admin", nil), + "database outside": {BucketyName: "keycloak", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, + "system database": {BucketyName: "mysql", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, + "no access name": {BucketyName: "b_t1_orders", Role: "ReadWrite"}, + "unsafe password": grantReq("ReadWrite", withPassword("Robert'); DROP USER root; --")), + "short password": grantReq("ReadWrite", withPassword("Short-1")), + } + for name, req := range cases { + fc := &fakeConn{} + _, err := testDriver(fc).GrantAccess(context.Background(), req) + if err == nil { + t.Errorf("%s: accepted", name) + continue + } + if fc.calls != 0 { + t.Errorf("%s: reached the server (%d calls)", name, fc.calls) + } + if strings.Contains(err.Error(), "DROP USER root") { + t.Errorf("%s: error echoes the Secret's password", name) + } + } +} + +func TestRevokeAccess(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + ctx := context.Background() + if err := d.RevokeAccess(ctx, ""); err != nil || fc.calls != 0 { + t.Fatalf("empty principal: %v, %d calls", err, fc.calls) + } + if err := d.RevokeAccess(ctx, "b_t1_app@%"); err != nil { + t.Fatal(err) + } + if len(fc.execs) != 1 || fc.execs[0].query != dropUserSQL || !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%"}) { + t.Fatalf("statements: %v", fc.execs) + } + for _, p := range []string{"root@localhost", "buckety@%", "keycloak@%", "b_x@'%'", "nonsense"} { + if err := d.RevokeAccess(ctx, p); err == nil { + t.Errorf("RevokeAccess(%q) accepted", p) + } + } + if len(fc.execs) != 1 { + t.Fatalf("refused revokes reached the server: %v", fc.execs) + } +} + +// The principal is user@host with the host it was created with, so +// a userHost change revokes the old account, not a new one. +func TestPrincipalCarriesHost(t *testing.T) { + fc := &fakeConn{} + d := testDriver(fc) + d.cfg.UserHost = "10.42.%" + res, err := d.GrantAccess(context.Background(), grantReq("Reader", nil)) + if err != nil { + t.Fatal(err) + } + if res.Principal != "b_t1_app@10.42.%" { + t.Fatalf("principal %q", res.Principal) + } +} + +// After a userHost change the login matches the account for the +// previous host: repair creates the new account, and a verification +// that still lands on the old one succeeds without looping. +func TestGrantAcrossUserHostChange(t *testing.T) { + shadowed := fmt.Errorf("%w: logged in as b_t1_app@%% instead of b_t1_app@10.42.%%", errShadowed) + fc := &fakeConn{logins: []loginResult{{err: shadowed}}} + d := testDriver(fc) + d.cfg.UserHost = "10.42.%" + res, err := d.GrantAccess(context.Background(), grantReq("ReadWrite", withPassword(secretPassword))) + if err != nil { + t.Fatal(err) + } + if fc.execs[0].query != createUserSQL || !slices.Equal(fc.execs[0].args[:2], []any{"b_t1_app", "10.42.%"}) { + t.Fatalf("statements: %q %v", fc.queries(), fc.execs[0].args[:2]) + } + if len(fc.loginCalls) != 2 { + t.Errorf("login calls: %d (check + one verify)", len(fc.loginCalls)) + } + if res.Principal != "b_t1_app@10.42.%" || !res.Minted { + t.Errorf("principal %q minted=%v", res.Principal, res.Minted) + } +} diff --git a/pkg/drivers/mysql/integration_test.go b/pkg/drivers/mysql/integration_test.go new file mode 100644 index 0000000..447bac0 --- /dev/null +++ b/pkg/drivers/mysql/integration_test.go @@ -0,0 +1,390 @@ +package mysql + +// Integration test against a real MariaDB or MySQL server, skipped +// unless BUCKETY_MYSQL_TEST_DSN holds the controller account's DSN +// in go-sql-driver form, for an account set up as in docs/mysql.md: +// +// BUCKETY_MYSQL_TEST_DSN="buckety:${PW}@tcp(127.0.0.1:3306)/" \ +// go test ./pkg/drivers/mysql -run TestIntegration -v -count=1 +// +// Optional: BUCKETY_MYSQL_TEST_PREFIX (default b_) must match the +// account's grant pattern; BUCKETY_MYSQL_TEST_USER_HOST (default %) +// must admit the address the test connects from; +// BUCKETY_MYSQL_TEST_ALT_USER_HOST (e.g. 127.0.0.1 through a +// port-forward) enables the userHost-change step. +// +// The test creates one database and two users named after a random +// run id under the prefix, and drops them at the end. It never +// prints a password. + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "maps" + "net" + "os" + "slices" + "strconv" + "testing" + + gomysql "github.com/go-sql-driver/mysql" + + "github.com/Yolean/buckety-controller/pkg/drivers/registry" +) + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func TestIntegration(t *testing.T) { + dsn := os.Getenv("BUCKETY_MYSQL_TEST_DSN") + if dsn == "" { + t.Skip("BUCKETY_MYSQL_TEST_DSN not set; see the comment at the top of integration_test.go") + } + parsed, err := gomysql.ParseDSN(dsn) + if err != nil { + t.Fatal("BUCKETY_MYSQL_TEST_DSN does not parse as a go-sql-driver DSN") + } + host, portStr, err := net.SplitHostPort(parsed.Addr) + if err != nil { + t.Fatalf("DSN address %q: %v", parsed.Addr, err) + } + port, _ := strconv.Atoi(portStr) + cfg := &Config{ + Host: host, Port: port, + AdminUser: parsed.User, AdminPassword: parsed.Passwd, + NamePrefix: envOr("BUCKETY_MYSQL_TEST_PREFIX", defaultNamePrefix), + UserHost: envOr("BUCKETY_MYSQL_TEST_USER_HOST", defaultUserHost), + } + if err := cfg.validate(); err != nil { + t.Fatal(err) + } + d, err := newDriver(cfg) + if err != nil { + t.Fatal(err) + } + sc := d.conn.(*sqlConn) + admin := sc.admin + ctx := context.Background() + + idb := make([]byte, 4) + _, _ = rand.Read(idb) + run := "it" + hex.EncodeToString(idb) + db := cfg.NamePrefix + run + "_db" + pattern := quoteIdent(grantPattern(db)) + params := map[string]string{"characterSet": "utf8mb4", "collation": "utf8mb4_unicode_ci"} + rwReq := registry.GrantRequest{BucketyName: db, AccessNamespace: run, AccessName: "rw", Role: "ReadWrite"} + // A long namespace exercises the hashed user name form. + roReq := registry.GrantRequest{BucketyName: db, AccessNamespace: run + "-a-rather-long-namespace", AccessName: "ro", Role: "Reader"} + + var principals []string + t.Cleanup(func() { + for _, p := range principals { + _ = d.RevokeAccess(ctx, p) + } + _ = d.DeleteBuckety(ctx, registry.DeleteRequest{Name: db}) + }) + + // connect logs in with a Secret's coordinates. + connect := func(t *testing.T, secret map[string][]byte) *sql.DB { + t.Helper() + c := sc.base.Clone() + c.User = string(secret["username"]) + c.Passwd = string(secret["password"]) + c.Addr = net.JoinHostPort(string(secret["host"]), string(secret["port"])) + c.DBName = string(secret["database"]) + conn, err := gomysql.NewConnector(c) + if err != nil { + t.Fatal(err) + } + u := sql.OpenDB(conn) + u.SetMaxOpenConns(1) + t.Cleanup(func() { u.Close() }) + return u + } + mustExec := func(t *testing.T, conn *sql.DB, q string, args ...any) { + t.Helper() + if _, err := conn.ExecContext(ctx, q, args...); err != nil { + t.Fatalf("%s: %v", q, err) + } + } + denied := func(t *testing.T, conn *sql.DB, q string) { + t.Helper() + _, err := conn.ExecContext(ctx, q) + var me *gomysql.MySQLError + if !errors.As(err, &me) || (me.Number != 1142 && me.Number != 1044 && me.Number != 1227) { + t.Fatalf("%s: want a privilege error, got %v", q, err) + } + } + loginDenied := func(t *testing.T, secret map[string][]byte) { + t.Helper() + err := connect(t, secret).PingContext(ctx) + if !isMySQLError(err, erAccessDenied) { + t.Fatalf("login as %s: want access denied, got %v", secret["username"], err) + } + } + grant := func(t *testing.T, req registry.GrantRequest, existing map[string][]byte) registry.GrantResult { + t.Helper() + req.ExistingSecretData = existing + res, err := d.GrantAccess(ctx, req) + if err != nil { + t.Fatalf("GrantAccess %s/%s: %v", req.AccessNamespace, req.AccessName, err) + } + if !slices.Contains(principals, res.Principal) { + principals = append(principals, res.Principal) + } + return res + } + + // Steps build on each other: stop at the first failure. + step := func(name string, f func(t *testing.T)) { + t.Helper() + if !t.Run(name, f) { + t.FailNow() + } + } + + step("database is created", func(t *testing.T) { + if in, err := d.InspectBuckety(ctx, db); err != nil || in.Exists { + t.Fatalf("before create: %+v, %v", in, err) + } + for i := 0; i < 2; i++ { + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}); err != nil { + t.Fatalf("EnsureBuckety #%d: %v", i, err) + } + } + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || !in.Empty { + t.Fatalf("after create: %+v, %v", in, err) + } + }) + + rw := grant(t, rwReq, nil) + step("ReadWrite user can migrate and write", func(t *testing.T) { + if !rw.Minted || !rw.Scoped || !rw.Revocable { + t.Fatalf("result flags: %+v", rw.Principal) + } + want := "jdbc:mariadb://" + net.JoinHostPort(host, portStr) + "/" + db + if string(rw.SecretData["jdbcUrl"]) != want || string(rw.SecretData["database"]) != db { + t.Fatalf("Secret coordinates: jdbcUrl=%s database=%s", rw.SecretData["jdbcUrl"], rw.SecretData["database"]) + } + u := connect(t, rw.SecretData) + mustExec(t, u, "CREATE TABLE items (id INT PRIMARY KEY, name VARCHAR(20))") + mustExec(t, u, "INSERT INTO items VALUES (1, 'one')") + mustExec(t, u, "ALTER TABLE items ADD COLUMN note VARCHAR(10)") + mustExec(t, u, "CREATE INDEX items_name ON items (name)") + mustExec(t, u, "CREATE TEMPORARY TABLE scratch (x INT)") + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 1 { + t.Fatalf("select: %d, %v", n, err) + } + // Scoped to its database: no other schema, no new + // databases - including one an unescaped "_" in the grant + // pattern would have matched. + denied(t, u, "SELECT * FROM mysql.user") + denied(t, u, "CREATE DATABASE "+quoteIdent(cfg.NamePrefix+run+"_other")) + lookalike := []byte(db) + for i := range lookalike { + if lookalike[i] == '_' { + lookalike[i] = '-' + } + } + denied(t, u, "CREATE DATABASE "+quoteIdent(string(lookalike))) + denied(t, u, "CREATE USER 'nope'@'%'") + }) + + step("database with tables is not empty", func(t *testing.T) { + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || in.Empty { + t.Fatalf("%+v, %v", in, err) + } + }) + + ro := grant(t, roReq, nil) + step("Reader user can only read", func(t *testing.T) { + if u := string(ro.SecretData["username"]); len(u) > maxUserNameLen { + t.Fatalf("user name %q too long", u) + } + u := connect(t, ro.SecretData) + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 1 { + t.Fatalf("select: %d, %v", n, err) + } + denied(t, u, "INSERT INTO items (id, name) VALUES (2, 'two')") + denied(t, u, "CREATE TABLE t2 (x INT)") + denied(t, u, "DROP TABLE items") + }) + + step("steady state keeps the Secret", func(t *testing.T) { + again := grant(t, rwReq, rw.SecretData) + if again.Minted || !maps.EqualFunc(again.SecretData, rw.SecretData, func(a, b []byte) bool { return string(a) == string(b) }) { + t.Fatal("a reconcile with an intact Secret changed it") + } + }) + + step("missing grant is restored", func(t *testing.T) { + mustExec(t, admin, "REVOKE INSERT ON "+pattern+".* FROM ?@?", string(rw.SecretData["username"]), cfg.UserHost) + u := connect(t, rw.SecretData) + denied(t, u, "INSERT INTO items (id, name) VALUES (3, 'three')") + grant(t, rwReq, rw.SecretData) + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (3, 'three')") + }) + + step("extra privileges and grant option are revoked", func(t *testing.T) { + mustExec(t, admin, "GRANT EXECUTE ON "+pattern+".* TO ?@?", string(rw.SecretData["username"]), cfg.UserHost) + mustExec(t, admin, "GRANT SELECT ON "+pattern+".* TO ?@? WITH GRANT OPTION", string(rw.SecretData["username"]), cfg.UserHost) + grant(t, rwReq, rw.SecretData) + acct := account{User: string(rw.SecretData["username"]), Host: cfg.UserHost} + g, err := sc.login(ctx, acct, string(rw.SecretData["password"]), grantPattern(db)) + if err != nil { + t.Fatal(err) + } + if g.grantOption || slices.Contains(g.privileges, "EXECUTE") || len(without(readWritePrivileges, g.privileges)) != 0 { + t.Fatalf("privileges after repair: %v grantOption=%v", g.privileges, g.grantOption) + } + }) + + step("dropped user is recreated with the Secret's password", func(t *testing.T) { + mustExec(t, admin, "DROP USER ?@?", string(rw.SecretData["username"]), cfg.UserHost) + loginDenied(t, rw.SecretData) + res := grant(t, rwReq, rw.SecretData) + if !res.Minted || string(res.SecretData["password"]) != string(rw.SecretData["password"]) { + t.Fatal("recreated user: want minted with the Secret's password") + } + var n int + if err := connect(t, rw.SecretData).QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil || n != 2 { + t.Fatalf("data after recreate: %d, %v", n, err) + } + }) + + step("password changed in the Secret is applied", func(t *testing.T) { + pw, err := generatePassword() + if err != nil { + t.Fatal(err) + } + edited := maps.Clone(rw.SecretData) + edited["password"] = []byte(pw) + res := grant(t, rwReq, edited) + if string(res.SecretData["password"]) != pw || res.Minted { + t.Fatal("the Secret's new password was not kept") + } + if err := connect(t, edited).PingContext(ctx); err != nil { + t.Fatalf("login with the new password: %v", err) + } + loginDenied(t, rw.SecretData) + rw = res + }) + + step("role change re-grants", func(t *testing.T) { + toReader := rwReq + toReader.Role = "Reader" + grant(t, toReader, rw.SecretData) + denied(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") + grant(t, rwReq, rw.SecretData) + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") + }) + + step("character set drift is reported, not repaired", func(t *testing.T) { + mustExec(t, admin, "ALTER DATABASE "+quoteIdent(db)+" CHARACTER SET latin1 COLLATE latin1_swedish_ci") + err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}) + if !registry.IsParameterDrift(err) { + t.Fatalf("want ParameterDrift, got %v", err) + } + s, err := sc.lookupSchema(ctx, db) + if err != nil || s.charset != "latin1" { + t.Fatalf("driver altered the database: %+v, %v", s, err) + } + mustExec(t, admin, "ALTER DATABASE "+quoteIdent(db)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}); err != nil { + t.Fatal(err) + } + }) + + step("names outside the prefix are refused", func(t *testing.T) { + if err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: "mysql"}); err == nil { + t.Error("EnsureBuckety(mysql) accepted") + } + if err := d.DeleteBuckety(ctx, registry.DeleteRequest{Name: "mysql"}); err == nil { + t.Error("DeleteBuckety(mysql) accepted") + } + if err := d.RevokeAccess(ctx, "root@localhost"); err == nil { + t.Error("RevokeAccess(root@localhost) accepted") + } + if err := d.RevokeAccess(ctx, cfg.AdminUser+"@%"); err == nil { + t.Error("RevokeAccess of the admin account accepted") + } + }) + + // Optional: a userHost other than the default that also admits + // the test's address (127.0.0.1 through a port-forward). + if alt := os.Getenv("BUCKETY_MYSQL_TEST_ALT_USER_HOST"); alt != "" { + step("userHost change moves the account", func(t *testing.T) { + altCfg := *cfg + altCfg.UserHost = alt + d2 := &Driver{cfg: &altCfg, conn: d.conn} + moved, err := d2.GrantAccess(ctx, registry.GrantRequest{BucketyName: db, AccessNamespace: run, AccessName: "rw", Role: "ReadWrite", ExistingSecretData: rw.SecretData}) + if err != nil { + t.Fatalf("grant under the new userHost: %v", err) + } + principals = append(principals, moved.Principal) + if moved.Principal == rw.Principal || !moved.Minted { + t.Fatalf("principal %q minted=%v", moved.Principal, moved.Minted) + } + // What the reconciler does next: revoke the replaced principal. + if err := d.RevokeAccess(ctx, rw.Principal); err != nil { + t.Fatal(err) + } + mustExec(t, connect(t, moved.SecretData), "INSERT INTO items (id, name) VALUES (5, 'five')") + // And back, which passes through the shadowed case when + // the alternative host is the more specific one. + back := grant(t, rwReq, moved.SecretData) + if err := d.RevokeAccess(ctx, moved.Principal); err != nil { + t.Fatal(err) + } + if back.Principal != rw.Principal { + t.Fatalf("principal after moving back: %q", back.Principal) + } + back = grant(t, rwReq, moved.SecretData) + mustExec(t, connect(t, back.SecretData), "DELETE FROM items WHERE id = 5") + rw = back + }) + } + + step("revoke drops the users", func(t *testing.T) { + for _, res := range []registry.GrantResult{rw, ro} { + for i := 0; i < 2; i++ { + if err := d.RevokeAccess(ctx, res.Principal); err != nil { + t.Fatalf("RevokeAccess #%d: %v", i, err) + } + } + loginDenied(t, res.SecretData) + } + }) + + step("Retain keeps the database and its data", func(t *testing.T) { + // Retain means the controller never calls DeleteBuckety; + // the accesses are gone and the data must still be there. + var n int + if err := admin.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+quoteIdent(db)+".items").Scan(&n); err != nil || n != 3 { + t.Fatalf("retained rows: %d, %v", n, err) + } + if in, err := d.InspectBuckety(ctx, db); err != nil || !in.Exists || in.Empty { + t.Fatalf("retained database: %+v, %v", in, err) + } + }) + + step("Delete drops the database", func(t *testing.T) { + for i := 0; i < 2; i++ { + if err := d.DeleteBuckety(ctx, registry.DeleteRequest{Name: db}); err != nil { + t.Fatalf("DeleteBuckety #%d: %v", i, err) + } + } + if in, err := d.InspectBuckety(ctx, db); err != nil || in.Exists { + t.Fatalf("after delete: %+v, %v", in, err) + } + }) +} diff --git a/pkg/drivers/mysql/names.go b/pkg/drivers/mysql/names.go new file mode 100644 index 0000000..bf2923a --- /dev/null +++ b/pkg/drivers/mysql/names.go @@ -0,0 +1,179 @@ +package mysql + +import ( + "crypto/sha256" + "encoding/base32" + "fmt" + "regexp" + "slices" + "strings" +) + +// Name limits. MariaDB allows 80-character user names but MySQL 8 +// only 32; the driver keeps to the common limit so one naming +// scheme works on both. +const ( + maxDatabaseNameLen = 64 + maxUserNameLen = 32 + maxHostLen = 255 + maxPrefixLen = 10 + userHashLen = 16 +) + +var ( + // prefixRE: a letter, then letters/digits, ending in "_". The + // trailing underscore keeps the prefix off every system schema + // and account name. + prefixRE = regexp.MustCompile(`^[a-z][a-z0-9]*_$`) + // nameRE is the charset for databases and users. Lowercase + // only, so lower_case_table_names cannot alias two names. + nameRE = regexp.MustCompile(`^[a-z0-9_-]+$`) + // hostRE covers host names, IPs, netmasks and the % and _ + // wildcards an operator may use in userHost. + hostRE = regexp.MustCompile(`^[A-Za-z0-9.%_:/-]+$`) + // k8sPartRE is a namespace or name usable verbatim in a user + // name: Kubernetes characters without dots. + k8sPartRE = regexp.MustCompile(`^[a-z0-9-]+$`) +) + +// systemNames are schemas, accounts and roles the driver never +// touches, whatever the prefix. +var systemNames = []string{ + "mysql", "information_schema", "performance_schema", "sys", + "root", "mariadb.sys", "mysql.sys", "mysql.session", "mysql.infoschema", + "public", "healthcheck", +} + +func checkPrefix(p string) error { + if len(p) > maxPrefixLen || !prefixRE.MatchString(p) { + return fmt.Errorf("namePrefix %q must be 2-%d characters: a lowercase letter, then lowercase letters or digits, ending with \"_\"", p, maxPrefixLen) + } + for _, s := range systemNames { + if strings.HasPrefix(s, p) { + return fmt.Errorf("namePrefix %q matches the system name %q", p, s) + } + } + return nil +} + +func checkHost(h string) error { + if h == "" || len(h) > maxHostLen || !hostRE.MatchString(h) { + return fmt.Errorf("host %q must be 1-%d characters of letters, digits and . %% _ : / -", h, maxHostLen) + } + return nil +} + +// checkManaged is the prefix boundary: every database and user +// name reaches SQL only through here. +func (d *Driver) checkManaged(kind, name string, maxLen int) error { + p := d.cfg.NamePrefix + switch { + case !strings.HasPrefix(name, p): + return fmt.Errorf("%s name %q is outside this backend's namePrefix %q; the driver only touches names that start with it", kind, name, p) + case len(name) == len(p): + return fmt.Errorf("%s name %q is only the namePrefix", kind, name) + case len(name) > maxLen: + return fmt.Errorf("%s name %q is %d characters; the limit is %d", kind, name, len(name), maxLen) + case !nameRE.MatchString(name): + return fmt.Errorf("%s name %q may only contain lowercase letters, digits, \"_\" and \"-\"", kind, name) + case slices.Contains(systemNames, name): + return fmt.Errorf("%s name %q is reserved", kind, name) + } + return nil +} + +func (d *Driver) checkDatabase(name string) error { + return d.checkManaged("database", name, maxDatabaseNameLen) +} + +func (d *Driver) checkUser(name string) error { + if err := d.checkManaged("user", name, maxUserNameLen); err != nil { + return err + } + if name == d.cfg.AdminUser { + return fmt.Errorf("user name %q is the controller's own account", name) + } + return nil +} + +// userName derives the account name for a BucketyAccess. +// +// Plain form: _, when both parts are +// dot-free and the result fits. Otherwise the hashed form: +// __, where readable is the dot-free, +// underscore-free start of namespace-name and hash is 16 base32 +// characters (80 bits) of sha256(namespace/name). +// +// Kubernetes namespaces and names contain no "_", so the plain +// form has exactly one underscore after the prefix and the hashed +// form exactly two, adjacent: the forms cannot collide, and two +// accesses share a user only through a sha256 prefix collision. +func (d *Driver) userName(namespace, name string) (string, error) { + if namespace == "" || name == "" { + return "", fmt.Errorf("mysql: the grant request names no BucketyAccess namespace/name; the controller binary is older than this driver") + } + p := d.cfg.NamePrefix + u := p + namespace + "_" + name + if !k8sPartRE.MatchString(namespace) || !k8sPartRE.MatchString(name) || len(u) > maxUserNameLen { + sum := sha256.Sum256([]byte(namespace + "/" + name)) + h := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]))[:userHashLen] + readable := strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + return r + } + return '-' + }, namespace+"-"+name) + room := maxUserNameLen - len(p) - 2 - userHashLen + if len(readable) > room { + readable = readable[:room] + } + u = p + readable + "__" + h + } + if err := d.checkUser(u); err != nil { + return "", fmt.Errorf("mysql: derived %w", err) + } + return u, nil +} + +// account is a MySQL account: 'user'@'host'. +type account struct { + User string + Host string +} + +// String is the principal form stamped into status.principal. +func (a account) String() string { return a.User + "@" + a.Host } + +// grantee is the GRANTEE column format of information_schema. +func (a account) grantee() string { return "'" + a.User + "'@'" + a.Host + "'" } + +// parsePrincipal turns a status.principal back into an account, +// refusing anything outside the prefix. Principals come from +// status, so they are validated like any other input. +func (d *Driver) parsePrincipal(principal string) (account, error) { + user, host, ok := strings.Cut(principal, "@") + if !ok { + return account{}, fmt.Errorf("mysql: principal %q is not user@host; refusing to drop it", principal) + } + if err := d.checkUser(user); err != nil { + return account{}, fmt.Errorf("mysql: refusing to drop principal %q: %w", principal, err) + } + if err := checkHost(host); err != nil { + return account{}, fmt.Errorf("mysql: refusing to drop principal %q: %w", principal, err) + } + return account{User: user, Host: host}, nil +} + +// quoteIdent backtick-quotes an identifier. Callers pass validated +// names; the doubling keeps the quoting correct regardless. +func quoteIdent(s string) string { + return "`" + strings.ReplaceAll(s, "`", "``") + "`" +} + +// grantPattern escapes the LIKE wildcards that GRANT ... ON db.* +// interprets in database names: an unescaped "_" in b_t1_orders +// would also grant on b_t1xorders. The escaped form is also what +// information_schema.SCHEMA_PRIVILEGES reports as TABLE_SCHEMA. +func grantPattern(db string) string { + return strings.NewReplacer(`\`, `\\`, `_`, `\_`, `%`, `\%`).Replace(db) +} diff --git a/pkg/drivers/mysql/names_test.go b/pkg/drivers/mysql/names_test.go new file mode 100644 index 0000000..ed6fadc --- /dev/null +++ b/pkg/drivers/mysql/names_test.go @@ -0,0 +1,209 @@ +package mysql + +import ( + "crypto/sha256" + "encoding/base32" + "fmt" + "strings" + "testing" +) + +func testDriver(fc *fakeConn) *Driver { + return &Driver{ + cfg: &Config{ + Host: "mysql.mysql.svc.cluster.local", Port: 3306, + AdminUser: "buckety", AdminPassword: "not-used-by-fakes", + NamePrefix: "b_", UserHost: "%", + }, + conn: fc, + } +} + +func TestCheckPrefix(t *testing.T) { + for _, p := range []string{"b_", "buckety_", "x1_", "abcdefghi_"} { + if err := checkPrefix(p); err != nil { + t.Errorf("checkPrefix(%q): %v", p, err) + } + } + for _, p := range []string{ + "", "b", "_", "B_", "1_", "b-", "b__x", "b.", "abcdefghij_", + "performance_", "information_", // system schemas start with these + } { + if err := checkPrefix(p); err == nil { + t.Errorf("checkPrefix(%q) accepted", p) + } + } +} + +func TestCheckDatabase(t *testing.T) { + d := testDriver(nil) + ok := []string{"b_t1_orders", "b_shop_orders", "b_x", "b_" + strings.Repeat("a", 62)} + for _, n := range ok { + if err := d.checkDatabase(n); err != nil { + t.Errorf("checkDatabase(%q): %v", n, err) + } + } + bad := []string{ + "orders", "mysql", "information_schema", "keycloak", "", "b_", + "B_x", "b_X", "b_x;drop database mysql", "b_x`", "b_x'", "b_x y", `b_x\`, "b_x.y", + "b_x%", "b_é", "b_x\n", + "b_" + strings.Repeat("a", 63), + } + for _, n := range bad { + if err := d.checkDatabase(n); err == nil { + t.Errorf("checkDatabase(%q) accepted", n) + } + } +} + +func TestValidateResourceNameHintsTemplate(t *testing.T) { + err := testDriver(nil).ValidateResourceName("orders") + if err == nil || !strings.Contains(err.Error(), `spec.name, e.g. "b_${namespace}_${name}"`) { + t.Fatalf("missing-prefix error should suggest a template: %v", err) + } +} + +func TestUserNamePlain(t *testing.T) { + d := testDriver(nil) + u, err := d.userName("shop", "orders") + if err != nil || u != "b_shop_orders" { + t.Fatalf("userName = %q, %v", u, err) + } +} + +func TestUserNameHashed(t *testing.T) { + d := testDriver(nil) + cases := [][2]string{ + {"a-rather-long-namespace-name", "orders"}, // too long + {"t1", "orders.v2"}, // dot + {strings.Repeat("n", 63), strings.Repeat("m", 253)}, + } + for _, c := range cases { + u, err := d.userName(c[0], c[1]) + if err != nil { + t.Fatalf("userName(%q, %q): %v", c[0], c[1], err) + } + if len(u) > maxUserNameLen || !strings.HasPrefix(u, "b_") || strings.Count(u[2:], "_") != 2 || !strings.Contains(u, "__") { + t.Errorf("userName(%q, %q) = %q: not the hashed form", c[0], c[1], u) + } + again, _ := d.userName(c[0], c[1]) + if again != u { + t.Errorf("userName not deterministic: %q vs %q", u, again) + } + } +} + +// An access named after another access's hash must not land on +// that access's user: the plain form has one underscore after the +// prefix, the hashed form two. +func TestUserNameFormsCannotCollide(t *testing.T) { + d := testDriver(nil) + victim, err := d.userName("a-rather-long-namespace-name", "orders") + if err != nil { + t.Fatal(err) + } + body := strings.TrimPrefix(victim, "b_") + readable, hash, _ := strings.Cut(body, "__") + attacker, err := d.userName(readable, hash) + if err != nil { + t.Fatal(err) + } + if attacker == victim { + t.Fatalf("namespace %q name %q maps onto %q", readable, hash, victim) + } + // Same with a single underscore spliced in by a plain-form + // lookalike. + attacker2, _ := d.userName(readable+"-", "-"+hash) + if attacker2 == victim { + t.Fatalf("lookalike maps onto %q", victim) + } +} + +func TestUserNameUnique(t *testing.T) { + d := testDriver(nil) + seen := map[string]string{} + for i := 0; i < 3000; i++ { + for _, pair := range [][2]string{ + {fmt.Sprintf("ns%d", i%37), fmt.Sprintf("a%d", i)}, + {fmt.Sprintf("long-namespace-%d", i%41), fmt.Sprintf("access-%d", i)}, + {"t1", fmt.Sprintf("x.%d", i)}, + {"t1", fmt.Sprintf("x-%d", i)}, + } { + u, err := d.userName(pair[0], pair[1]) + if err != nil { + t.Fatal(err) + } + key := pair[0] + "/" + pair[1] + if prev, dup := seen[u]; dup && prev != key { + t.Fatalf("%q and %q both map to %q", prev, key, u) + } + seen[u] = key + } + } +} + +func TestUserNameHashIsSHA256Prefix(t *testing.T) { + d := testDriver(nil) + u, _ := d.userName("a-rather-long-namespace-name", "orders") + sum := sha256.Sum256([]byte("a-rather-long-namespace-name/orders")) + want := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]))[:userHashLen] + if !strings.HasSuffix(u, "__"+want) { + t.Fatalf("%q does not end with the sha256 prefix %q", u, want) + } +} + +func TestUserNameNeedsIdentity(t *testing.T) { + if _, err := testDriver(nil).userName("", "x"); err == nil { + t.Fatal("empty namespace accepted") + } +} + +func TestCheckUserRefusesAdmin(t *testing.T) { + d := testDriver(nil) + d.cfg.AdminUser = "b_admin" // config validation forbids this; the check holds regardless + if err := d.checkUser("b_admin"); err == nil { + t.Fatal("admin account accepted as a managed user") + } +} + +func TestParsePrincipal(t *testing.T) { + d := testDriver(nil) + a, err := d.parsePrincipal("b_t1_reader@%") + if err != nil || a != (account{"b_t1_reader", "%"}) { + t.Fatalf("parsePrincipal = %+v, %v", a, err) + } + if a.String() != "b_t1_reader@%" || a.grantee() != "'b_t1_reader'@'%'" { + t.Fatalf("formats: %q %q", a.String(), a.grantee()) + } + if _, err := d.parsePrincipal("b_t1_reader@10.42.0.0/255.255.0.0"); err != nil { + t.Errorf("netmask host: %v", err) + } + for _, p := range []string{ + "root@localhost", "buckety@%", "keycloak@%", "b_x", "@%", "b_x@", + "b_x@'%'", "b_x@%' OR 1=1", "b_x@% ", "B_x@%", "b_@%", "mysql.sys@localhost", + } { + if _, err := d.parsePrincipal(p); err == nil { + t.Errorf("parsePrincipal(%q) accepted", p) + } + } +} + +func TestQuoting(t *testing.T) { + if got := quoteIdent("a`b"); got != "`a``b`" { + t.Errorf("quoteIdent = %s", got) + } + if got := grantPattern("b_t1_orders"); got != `b\_t1\_orders` { + t.Errorf("grantPattern = %s", got) + } + if got := grantPattern(`b_a%\`); got != `b\_a\%\\` { + t.Errorf("grantPattern = %s", got) + } +} + +// The example in docs/mysql.md. +func TestUserNameDocExample(t *testing.T) { + u, err := testDriver(nil).userName("e2e-mysql-mariadb-mysql-happy-path", "orders") + if err != nil || u != "b_e2e-mysql-ma__eug6f3psticceco5" { + t.Fatalf("userName = %q, %v", u, err) + } +} diff --git a/pkg/drivers/mysql/password.go b/pkg/drivers/mysql/password.go new file mode 100644 index 0000000..2e6e772 --- /dev/null +++ b/pkg/drivers/mysql/password.go @@ -0,0 +1,70 @@ +package mysql + +import ( + "crypto/rand" + "fmt" + "math/big" + "regexp" + "strings" +) + +// Passwords are drawn from the URL-unreserved characters: they need +// no escaping in a SQL string literal, a mysql:// URL, a shell +// heredoc or an env var. Generated ones contain all four character +// classes so password-policy plugins (simple_password_check) accept +// them. +const ( + passwordLen = 32 + minPasswordLen = 16 + maxPasswordLen = 128 + passwordSpecials = "-._~" + passwordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + passwordSpecials +) + +var passwordRE = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`) + +func generatePassword() (string, error) { + max := big.NewInt(int64(len(passwordAlphabet))) + b := make([]byte, passwordLen) + for { + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("mysql: generate password: %w", err) + } + b[i] = passwordAlphabet[n.Int64()] + } + if hasAllClasses(string(b)) { + return string(b), nil + } + } +} + +func hasAllClasses(s string) bool { + return strings.ContainsAny(s, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") && + strings.ContainsAny(s, "abcdefghijklmnopqrstuvwxyz") && + strings.ContainsAny(s, "0123456789") && + strings.ContainsAny(s, passwordSpecials) +} + +// validatePassword checks a password taken from an access Secret. +// The error never contains the value. +func validatePassword(p string) error { + if len(p) < minPasswordLen || len(p) > maxPasswordLen || !passwordRE.MatchString(p) { + return fmt.Errorf("password must be %d-%d characters of A-Z a-z 0-9 and %s", minPasswordLen, maxPasswordLen, passwordSpecials) + } + return nil +} + +// redact removes secret from an error's text. Server errors can +// quote the statement, and the statement can carry a password. +func redact(err error, secret string) error { + if err == nil || secret == "" || !strings.Contains(err.Error(), secret) { + return err + } + return redactedError(strings.ReplaceAll(err.Error(), secret, "")) +} + +type redactedError string + +func (e redactedError) Error() string { return string(e) } diff --git a/pkg/drivers/mysql/password_test.go b/pkg/drivers/mysql/password_test.go new file mode 100644 index 0000000..e32dc23 --- /dev/null +++ b/pkg/drivers/mysql/password_test.go @@ -0,0 +1,62 @@ +package mysql + +import ( + "errors" + "strings" + "testing" +) + +func TestGeneratePassword(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 200; i++ { + p, err := generatePassword() + if err != nil { + t.Fatal(err) + } + if len(p) != passwordLen || !passwordRE.MatchString(p) || !hasAllClasses(p) { + t.Fatalf("generated password %d does not meet the policy", i) + } + if err := validatePassword(p); err != nil { + t.Fatalf("generated password rejected: %v", err) + } + if seen[p] { + t.Fatal("generated password repeated") + } + seen[p] = true + } +} + +func TestValidatePassword(t *testing.T) { + if err := validatePassword("Abcdefgh-1234567"); err != nil { + t.Errorf("16 safe characters: %v", err) + } + bad := []string{ + "", "short-1A", strings.Repeat("a", 129), + "Abcdefgh-123456'", `Abcdefgh-123456\`, "Abcdefgh-123456 ", "Abcdefgh-123456@", + "Abcdefgh-123456;", "Abcdefgh-123456`", "Abcdefgh-123456ä", "Abcdefgh-123456\n", + } + for _, p := range bad { + err := validatePassword(p) + if err == nil { + t.Errorf("password %d accepted", len(p)) + continue + } + if p != "" && strings.Contains(err.Error(), p) { + t.Errorf("validation error echoes the password") + } + } +} + +func TestRedact(t *testing.T) { + err := redact(errors.New("near 'Secret-Value-123' at line 1"), "Secret-Value-123") + if strings.Contains(err.Error(), "Secret-Value-123") || !strings.Contains(err.Error(), "") { + t.Fatalf("redact = %v", err) + } + orig := errors.New("unrelated") + if redact(orig, "x-y-z") != orig { + t.Fatal("redact replaced an error without the secret") + } + if redact(nil, "x") != nil { + t.Fatal("redact(nil)") + } +} diff --git a/pkg/drivers/mysql/schema/v0.1/config.schema.json b/pkg/drivers/mysql/schema/v0.1/config.schema.json new file mode 100644 index 0000000..06807f1 --- /dev/null +++ b/pkg/drivers/mysql/schema/v0.1/config.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "mysql driver backend config", + "description": "Schema for the config: block under a backend entry whose driver is mysql (MariaDB 10.11+, MySQL 8). The controller connects with the admin account below; every database and user it manages starts with namePrefix, and it refuses names outside it. See docs/mysql.md for the grants the admin account needs and why CREATE USER makes the prefix the security boundary.", + "type": "object", + "additionalProperties": false, + "required": ["host", "adminUser", "adminPassword"], + "properties": { + "host": { + "type": "string", + "minLength": 1, + "description": "Server host name or IP, without scheme or port, e.g. mysql.mysql.svc.cluster.local. Also written to access Secrets (host, jdbcUrl, url)." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "default": 3306, + "description": "Server port. Also written to access Secrets." + }, + "adminUser": { + "type": "string", + "minLength": 1, + "description": "The controller's account. Must not start with namePrefix. envsubst:\"true\" - supports ${VAR} interpolation at controller startup." + }, + "adminPassword": { + "type": "string", + "minLength": 1, + "description": "The controller's password; use ${VAR} interpolation from a Secret-sourced env var rather than a literal. envsubst:\"true\"." + }, + "namePrefix": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{0,8}_$", + "default": "b_", + "description": "Every database and user the driver creates or touches starts with this prefix; Buckety names (spec.name) must resolve to it, e.g. b_${namespace}_${name}. The admin account's database grant is scoped to the same prefix (GRANT ALL ON `b\\_%`.*). Fixed for the backend's lifetime: resources stamped under one prefix are refused under another." + }, + "userHost": { + "type": "string", + "pattern": "^[A-Za-z0-9.%_:/-]{1,255}$", + "default": "%", + "description": "Host part of created accounts ('user'@''). Must also admit the controller's own address: the driver verifies passwords and grants by logging in as each user. Changing it moves accesses to new accounts on their next reconcile and drops the old ones." + }, + "tls": { + "type": "object", + "additionalProperties": false, + "description": "Present (even as {}) to require verified TLS on the controller's connections. Absent means plaintext. Does not add REQUIRE SSL to created users; enforce TLS for consumers server-side with require_secure_transport.", + "properties": { + "caFile": { + "type": "string", + "description": "PEM bundle (mounted into the controller Pod) to verify the server certificate with. System roots when omitted." + }, + "serverName": { + "type": "string", + "description": "Name to verify in the server certificate. Defaults to host." + } + } + } + } +} diff --git a/pkg/drivers/mysql/schema/v0.1/parameters.schema.json b/pkg/drivers/mysql/schema/v0.1/parameters.schema.json new file mode 100644 index 0000000..912503d --- /dev/null +++ b/pkg/drivers/mysql/schema/v0.1/parameters.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "mysql driver Buckety parameters", + "description": "Schema for Buckety.spec.parameters when the resolved driver is mysql. All values are strings per the CRD contract. Omitted parameters are unmanaged: the server's character_set_server/collation_server defaults apply at creation and the driver never compares them afterwards. Both parameters are set at creation and immutable; the driver never alters an existing database's defaults (that would not convert its tables), so a database whose defaults differ - changed out of band, or adopted - surfaces ParameterDrift.", + "type": "object", + "additionalProperties": false, + "properties": { + "characterSet": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{1,31}$", + "not": {"const": "utf8"}, + "description": "Default character set of the database, e.g. utf8mb4. \"utf8\" is refused as ambiguous (utf8mb3 today, utf8mb4 in future server versions); name utf8mb4 or utf8mb3." + }, + "collation": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{1,63}$", + "not": {"pattern": "^utf8_"}, + "description": "Default collation of the database, e.g. utf8mb4_unicode_ci. Use the full name as information_schema.SCHEMATA reports it, or the value will read as drift." + } + } +} diff --git a/pkg/drivers/mysql/statements.go b/pkg/drivers/mysql/statements.go new file mode 100644 index 0000000..525c3ad --- /dev/null +++ b/pkg/drivers/mysql/statements.go @@ -0,0 +1,124 @@ +package mysql + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// Database-level privileges per BucketyAccess role, spelled as +// information_schema.SCHEMA_PRIVILEGES reports them. +var ( + readerPrivileges = []string{"SELECT"} + readWritePrivileges = []string{ + "SELECT", "INSERT", "UPDATE", "DELETE", + "CREATE", "ALTER", "INDEX", "DROP", "REFERENCES", + "CREATE TEMPORARY TABLES", "LOCK TABLES", + } +) + +// rolePrivileges maps BucketyAccess.spec.role to a grant set. +// Writer is refused rather than guessed: see docs/mysql.md. +func rolePrivileges(role string) ([]string, error) { + switch role { + case "Reader": + return readerPrivileges, nil + case "", "ReadWrite": + return readWritePrivileges, nil + case "Writer": + return nil, fmt.Errorf("mysql: role Writer is not supported; use Reader (SELECT) or ReadWrite (data and schema changes)") + default: + return nil, fmt.Errorf("mysql: unknown role %q", role) + } +} + +// privilegeRE: privilege keywords only. Names read back from the +// server are spliced into REVOKE, so they are checked like input. +var privilegeRE = regexp.MustCompile(`^[A-Z]+( [A-Z]+)*$`) + +// Account statements. ? arguments are interpolated client-side as +// escaped string literals (Config.clientConfig); the values are +// validated before they get here. +const ( + createUserSQL = "CREATE USER ?@? IDENTIFIED BY ?" + alterUserSQL = "ALTER USER ?@? IDENTIFIED BY ?" + dropUserSQL = "DROP USER IF EXISTS ?@?" +) + +const ( + schemaSQL = "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?" + // contentSQL counts what makes a database non-empty for the + // adoption gate: tables, views, routines and events. + contentSQL = "SELECT" + + " (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?)" + + " + (SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?)" + + " + (SELECT COUNT(*) FROM information_schema.EVENTS WHERE EVENT_SCHEMA = ?)" + currentUserSQL = "SELECT CURRENT_USER()" + // privilegesSQL runs as the created user, who sees its own rows. + privilegesSQL = "SELECT PRIVILEGE_TYPE, IS_GRANTABLE FROM information_schema.SCHEMA_PRIVILEGES WHERE GRANTEE = ? AND TABLE_SCHEMA = ?" +) + +func createDatabaseSQL(name, charset, collation string) string { + var b strings.Builder + b.WriteString("CREATE DATABASE IF NOT EXISTS ") + b.WriteString(quoteIdent(name)) + if charset != "" { + b.WriteString(" CHARACTER SET ") + b.WriteString(quoteIdent(charset)) + } + if collation != "" { + b.WriteString(" COLLATE ") + b.WriteString(quoteIdent(collation)) + } + return b.String() +} + +func dropDatabaseSQL(name string) string { + return "DROP DATABASE IF EXISTS " + quoteIdent(name) +} + +// grantSQL and revokeSQL take the escaped grant pattern of one +// database; the account goes in as two ? arguments. +func grantSQL(privileges []string, pattern string) (string, error) { + list, err := privilegeList(privileges) + if err != nil { + return "", err + } + return "GRANT " + list + " ON " + quoteIdent(pattern) + ".* TO ?@?", nil +} + +func revokeSQL(privileges []string, pattern string) (string, error) { + list, err := privilegeList(privileges) + if err != nil { + return "", err + } + return "REVOKE " + list + " ON " + quoteIdent(pattern) + ".* FROM ?@?", nil +} + +func revokeGrantOptionSQL(pattern string) string { + return "REVOKE GRANT OPTION ON " + quoteIdent(pattern) + ".* FROM ?@?" +} + +func privilegeList(privileges []string) (string, error) { + if len(privileges) == 0 { + return "", fmt.Errorf("mysql: empty privilege list") + } + for _, p := range privileges { + if !privilegeRE.MatchString(p) { + return "", fmt.Errorf("mysql: unexpected privilege name %q", p) + } + } + return strings.Join(privileges, ", "), nil +} + +// without returns the elements of a not in b, in a's order. +func without(a, b []string) []string { + var out []string + for _, x := range a { + if !slices.Contains(b, x) { + out = append(out, x) + } + } + return out +} diff --git a/pkg/drivers/mysql/statements_test.go b/pkg/drivers/mysql/statements_test.go new file mode 100644 index 0000000..8c95a15 --- /dev/null +++ b/pkg/drivers/mysql/statements_test.go @@ -0,0 +1,84 @@ +package mysql + +import ( + "slices" + "strings" + "testing" +) + +func TestRolePrivileges(t *testing.T) { + r, err := rolePrivileges("Reader") + if err != nil || !slices.Equal(r, []string{"SELECT"}) { + t.Fatalf("Reader = %v, %v", r, err) + } + wantRW := []string{ + "SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "INDEX", + "DROP", "REFERENCES", "CREATE TEMPORARY TABLES", "LOCK TABLES", + } + for _, role := range []string{"ReadWrite", ""} { + rw, err := rolePrivileges(role) + if err != nil || !slices.Equal(rw, wantRW) { + t.Fatalf("%q = %v, %v", role, rw, err) + } + } + for _, role := range []string{"Writer", "Owner", "readwrite"} { + if _, err := rolePrivileges(role); err == nil { + t.Errorf("role %q accepted", role) + } + } +} + +func TestGrantAndRevokeSQL(t *testing.T) { + q, err := grantSQL(readWritePrivileges, grantPattern("b_t1_orders")) + if err != nil { + t.Fatal(err) + } + want := "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES ON `b\\_t1\\_orders`.* TO ?@?" + if q != want { + t.Errorf("grantSQL:\n got %s\nwant %s", q, want) + } + q, err = revokeSQL([]string{"INSERT", "EXECUTE"}, grantPattern("b_x")) + if err != nil || q != "REVOKE INSERT, EXECUTE ON `b\\_x`.* FROM ?@?" { + t.Errorf("revokeSQL = %s, %v", q, err) + } + if q := revokeGrantOptionSQL(grantPattern("b_x")); q != "REVOKE GRANT OPTION ON `b\\_x`.* FROM ?@?" { + t.Errorf("revokeGrantOptionSQL = %s", q) + } + for _, bad := range [][]string{nil, {""}, {"select"}, {"SELECT; DROP DATABASE mysql"}, {"ALL PRIVILEGES ON *.* TO x"}, {"SELECT,INSERT"}} { + if _, err := revokeSQL(bad, "b\\_x"); err == nil { + t.Errorf("revokeSQL(%q) accepted", bad) + } + } +} + +func TestDatabaseSQL(t *testing.T) { + cases := map[string][3]string{ + "CREATE DATABASE IF NOT EXISTS `b_x`": {"b_x", "", ""}, + "CREATE DATABASE IF NOT EXISTS `b_x` CHARACTER SET `utf8mb4`": {"b_x", "utf8mb4", ""}, + "CREATE DATABASE IF NOT EXISTS `b_x` CHARACTER SET `utf8mb4` COLLATE `utf8mb4_unicode_ci`": {"b_x", "utf8mb4", "utf8mb4_unicode_ci"}, + "CREATE DATABASE IF NOT EXISTS `b_x-y` COLLATE `latin1_swedish_ci`": {"b_x-y", "", "latin1_swedish_ci"}, + } + for want, in := range cases { + if got := createDatabaseSQL(in[0], in[1], in[2]); got != want { + t.Errorf("createDatabaseSQL%v = %s", in, got) + } + } + if got := dropDatabaseSQL("b_x"); got != "DROP DATABASE IF EXISTS `b_x`" { + t.Errorf("dropDatabaseSQL = %s", got) + } +} + +func TestAccountStatementsUsePlaceholders(t *testing.T) { + for _, q := range []string{createUserSQL, alterUserSQL, dropUserSQL} { + if !strings.Contains(q, "?@?") { + t.Errorf("%q does not pass the account as arguments", q) + } + } +} + +func TestWithout(t *testing.T) { + got := without([]string{"a", "b", "c"}, []string{"b"}) + if !slices.Equal(got, []string{"a", "c"}) { + t.Fatalf("without = %v", got) + } +} diff --git a/schema/README.md b/schema/README.md index 8e1ce6c..5e6ea46 100644 --- a/schema/README.md +++ b/schema/README.md @@ -23,6 +23,7 @@ nothing else: | `buckety-gcs.schema.json` | the full gcs driver set | | `buckety-s3.schema.json` | the full s3 driver set | | `buckety-kadm.schema.json` | the kadm driver set (`partitions`, `replicationFactor`, `config.*`) - kadm is not in a family | +| `buckety-mysql.schema.json` | the mysql driver set (`characterSet`, `collation`) - mysql is not in a family | | `bucketyaccess.schema.json` | (BucketyAccess; parameters unconstrained) | Editor validation is documentation and early feedback, not diff --git a/schema/buckety-mysql.schema.json b/schema/buckety-mysql.schema.json new file mode 100644 index 0000000..0440e56 --- /dev/null +++ b/schema/buckety-mysql.schema.json @@ -0,0 +1,211 @@ +{ + "$id": "https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Standalone editor schema for a Buckety whose backend resolves to the mysql driver (a MariaDB/MySQL database). mysql is not in a driver family; GENERALIZE to buckety for no parameter constraints. spec.name must resolve to a name starting with the backend's namePrefix, e.g. b_${namespace}_${name}; admission enforces it.", + "properties": { + "apiVersion": { + "enum": [ + "buckety.yolean.se/v1alpha1" + ], + "type": "string" + }, + "kind": { + "enum": [ + "Buckety" + ], + "type": "string" + }, + "metadata": { + "properties": { + "annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "spec": { + "properties": { + "adoption": { + "default": "AdoptEmpty", + "description": "What happens when the resolved backend resource\nalready exists at first reconcile. AdoptEmpty (the\ndefault) adopts only content-free resources; Adopt\nclaims a resource with pre-existing content.\nAdopted resources are never deleted from the\nbackend, whatever retentionPolicy says. See SPEC.md\nsection \"Adoption\".\n", + "enum": [ + "AdoptEmpty", + "Adopt" + ], + "type": "string" + }, + "backend": { + "description": "Name of a backend defined in the controller's config\nfile. Immutable after creation.\n", + "minLength": 1, + "type": "string" + }, + "defaultAccess": { + "description": "Single-consumer shortcut. Materialises an implicit\nBucketyAccess of the same name; reclaimed via\nowner-ref when an explicit BucketyAccess arrives or\nthis field is removed. See SPEC.md.\n", + "properties": { + "credentialsSecretName": { + "minLength": 1, + "type": "string" + }, + "role": { + "default": "ReadWrite", + "enum": [ + "Reader", + "Writer", + "ReadWrite" + ], + "type": "string" + } + }, + "required": [ + "credentialsSecretName" + ], + "type": "object" + }, + "name": { + "description": "Optional name template. Resolved once at first\nreconcile and frozen in status.backendResourceName.\nDefaults to metadata.name if omitted. See SPEC.md\nsection \"Naming templates\" for the grammar.\n", + "type": "string" + }, + "parameters": { + "additionalProperties": false, + "description": "Schema for Buckety.spec.parameters when the resolved driver is mysql. All values are strings per the CRD contract. Omitted parameters are unmanaged: the server's character_set_server/collation_server defaults apply at creation and the driver never compares them afterwards. Both parameters are set at creation and immutable; the driver never alters an existing database's defaults (that would not convert its tables), so a database whose defaults differ - changed out of band, or adopted - surfaces ParameterDrift.", + "properties": { + "characterSet": { + "description": "Default character set of the database, e.g. utf8mb4. \"utf8\" is refused as ambiguous (utf8mb3 today, utf8mb4 in future server versions); name utf8mb4 or utf8mb3.", + "not": { + "const": "utf8" + }, + "pattern": "^[a-z][a-z0-9]{1,31}$", + "type": "string" + }, + "collation": { + "description": "Default collation of the database, e.g. utf8mb4_unicode_ci. Use the full name as information_schema.SCHEMATA reports it, or the value will read as drift.", + "not": { + "pattern": "^utf8_" + }, + "pattern": "^[a-z][a-z0-9_]{1,63}$", + "type": "string" + } + }, + "title": "mysql driver Buckety parameters", + "type": "object" + }, + "retentionPolicy": { + "default": "Retain", + "description": "What happens to the backend resource when this\nBuckety is deleted.\n", + "enum": [ + "Retain", + "Delete" + ], + "type": "string" + } + }, + "required": [ + "backend" + ], + "type": "object" + }, + "status": { + "properties": { + "backend": { + "description": "Sticky. Stamped at first reconcile.", + "type": "string" + }, + "backendResourceName": { + "description": "Resolved name template. Sticky from first reconcile.\n", + "type": "string" + }, + "conditions": { + "items": { + "properties": { + "lastTransitionTime": { + "format": "date-time", + "type": "string" + }, + "message": { + "type": "string" + }, + "observedGeneration": { + "format": "int64", + "type": "integer" + }, + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "True", + "False", + "Unknown" + ], + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "status", + "lastTransitionTime", + "reason", + "message" + ], + "type": "object" + }, + "type": "array" + }, + "driver": { + "description": "Sticky. Stamped at first reconcile.", + "type": "string" + }, + "driverBuildVersion": { + "description": "Full SemVer of the binary that most recently\nreconciled this resource. Informational.\n", + "type": "string" + }, + "driverMajor": { + "description": "Sticky. Gates driver-version compatibility. See\nSPEC.md \"Driver versioning\".\n", + "type": "integer" + }, + "observedGeneration": { + "format": "int64", + "type": "integer" + }, + "provenance": { + "description": "Created when the controller made the backend\nresource, Adopted when it claimed a pre-existing\none. Sticky. Absent on resources stamped before\nadoption tracking existed. DeleteBuckety only runs\nfor Created; see SPEC.md section \"Adoption\".\n", + "enum": [ + "Created", + "Adopted" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "title": "Buckety (mysql)", + "type": "object" +} diff --git a/schema/buckety.schema.json b/schema/buckety.schema.json index 205cfa9..b5edff1 100644 --- a/schema/buckety.schema.json +++ b/schema/buckety.schema.json @@ -1,7 +1,7 @@ { "$id": "https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm.", + "description": "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm, buckety-mysql.", "properties": { "apiVersion": { "enum": [ diff --git a/scripts/gen-cr-schemas/main.go b/scripts/gen-cr-schemas/main.go index 8f1dff8..43bef6b 100644 --- a/scripts/gen-cr-schemas/main.go +++ b/scripts/gen-cr-schemas/main.go @@ -13,6 +13,7 @@ // buckety-gcs.schema.json full gcs driver parameters // buckety-s3.schema.json full s3 driver parameters // buckety-kadm.schema.json kadm driver parameters (no family) +// buckety-mysql.schema.json mysql driver parameters (no family) // // Run via `go run ./scripts/gen-cr-schemas` from the repo root; CI // asserts the output is committed (git diff --exit-code -- schema/). @@ -41,7 +42,7 @@ func main() { desc string }{ {"", "", - "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm."}, + "Standalone editor schema for a Buckety against any backend. spec.parameters is unconstrained at this level; SPECIALIZE by switching the $schema URL suffix: buckety-blobstore (portable across bucket backends), buckety-gcs, buckety-s3, buckety-kadm, buckety-mysql."}, // "blobstore" is the user-facing name of the object-store // driver family (pkg/drivers/objectstore) - it matches how // consumers talk about these resources (blobs namespaces @@ -54,6 +55,8 @@ func main() { "Standalone editor schema for a Buckety whose backend resolves to the s3 driver. GENERALIZE to buckety-blobstore to keep the resource portable across bucket backends, or to buckety for no parameter constraints."}, {"kadm", "pkg/drivers/kadm/schema/v0.1/parameters.schema.json", "Standalone editor schema for a Buckety whose backend resolves to the kadm driver. kadm is deliberately not in a driver family (families are per service kind); GENERALIZE to buckety for no parameter constraints."}, + {"mysql", "pkg/drivers/mysql/schema/v0.1/parameters.schema.json", + "Standalone editor schema for a Buckety whose backend resolves to the mysql driver (a MariaDB/MySQL database). mysql is not in a driver family; GENERALIZE to buckety for no parameter constraints. spec.name must resolve to a name starting with the backend's namePrefix, e.g. b_${namespace}_${name}; admission enforces it."}, } must(os.MkdirAll("schema", 0o755)) From d1ef37343b2ccc0a8447fefd34c54b11e658806d Mon Sep 17 00:00:00 2001 From: Yolean gle-qa Date: Thu, 1 Oct 2026 20:28:16 +0000 Subject: [PATCH 03/15] examples, e2e: mysql bootstrap, scenarios and a MariaDB backing service examples/mysql/bootstrap creates the controller's account where the instance is created: an initdb component adds buckety-account.sh to /docker-entrypoint-initdb.d and mounts the Secret it reads the password from, so no password exists in source. The image entrypoint runs it once, on the first boot of an empty data directory, with root on the local socket - the only way in when root may connect only from localhost, as in kubernetes-mysql-cluster. The component targets that project's StatefulSet mariadb, so any variant takes it; the example pins variants/scale-1. The script runs in a subshell so the entrypoint may source or execute it, validates the password with the driver's rule, passes it on stdin, and is idempotent for manual runs against existing instances. e2e: a single ephemeral MariaDB, shaped like kubernetes-mysql-cluster so the same component applies, with the password generated by mariadb-secret.sh into both namespaces that need it (a pod mounts Secrets only from its own namespace). The controller env is optional so clusters without it still start. Scenarios: happy-path (database with charset, ReadWrite user running DDL from a Job) and multi-consumer (Reader denied INSERT/CREATE by the server, Writer refused with GrantFailed and no Secret, deletion blocked by accesses). Both are unrun: no k3d/docker where they were written. secret_has_keys printed the whole Secret on a missing key; it now prints key names only, so a failing run does not put credentials in CI logs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e.yaml | 9 +- examples/mysql/README.md | 138 ++++++++++++++++++ .../mysql/bootstrap/initdb/buckety-account.sh | 56 +++++++ .../mysql/bootstrap/initdb/kustomization.yaml | 18 +++ .../bootstrap/initdb/statefulset-patch.yaml | 30 ++++ examples/mysql/bootstrap/kustomization.yaml | 14 ++ examples/mysql/buckety-controller.yaml | 31 ++++ examples/mysql/controller-env-patch.yaml | 17 +++ examples/mysql/happy-path/README.md | 29 ++++ examples/mysql/happy-path/assert.sh | 30 ++++ examples/mysql/happy-path/buckety.yaml | 16 ++ examples/mysql/happy-path/consumer-job.yaml | 33 +++++ examples/mysql/happy-path/kustomization.yaml | 5 + examples/mysql/multi-consumer/README.md | 29 ++++ examples/mysql/multi-consumer/access.yaml | 32 ++++ examples/mysql/multi-consumer/assert.sh | 36 +++++ examples/mysql/multi-consumer/buckety.yaml | 9 ++ .../mysql/multi-consumer/consumer-job.yaml | 40 +++++ .../mysql/multi-consumer/kustomization.yaml | 6 + test/e2e/backing-services/kustomization.yaml | 1 + test/e2e/backing-services/mariadb-secret.sh | 29 ++++ .../mariadb/kustomization.yaml | 12 ++ .../e2e/backing-services/mariadb/mariadb.yaml | 56 +++++++ test/e2e/backing-services/namespaces.yaml | 5 + test/e2e/configs/mariadb.yaml | 14 ++ test/e2e/lib.sh | 4 +- test/e2e/overlay/deployment.yaml | 8 + test/e2e/run.sh | 6 +- 28 files changed, 708 insertions(+), 5 deletions(-) create mode 100644 examples/mysql/README.md create mode 100644 examples/mysql/bootstrap/initdb/buckety-account.sh create mode 100644 examples/mysql/bootstrap/initdb/kustomization.yaml create mode 100644 examples/mysql/bootstrap/initdb/statefulset-patch.yaml create mode 100644 examples/mysql/bootstrap/kustomization.yaml create mode 100644 examples/mysql/buckety-controller.yaml create mode 100644 examples/mysql/controller-env-patch.yaml create mode 100644 examples/mysql/happy-path/README.md create mode 100755 examples/mysql/happy-path/assert.sh create mode 100644 examples/mysql/happy-path/buckety.yaml create mode 100644 examples/mysql/happy-path/consumer-job.yaml create mode 100644 examples/mysql/happy-path/kustomization.yaml create mode 100644 examples/mysql/multi-consumer/README.md create mode 100644 examples/mysql/multi-consumer/access.yaml create mode 100755 examples/mysql/multi-consumer/assert.sh create mode 100644 examples/mysql/multi-consumer/buckety.yaml create mode 100644 examples/mysql/multi-consumer/consumer-job.yaml create mode 100644 examples/mysql/multi-consumer/kustomization.yaml create mode 100755 test/e2e/backing-services/mariadb-secret.sh create mode 100644 test/e2e/backing-services/mariadb/kustomization.yaml create mode 100644 test/e2e/backing-services/mariadb/mariadb.yaml create mode 100644 test/e2e/configs/mariadb.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 90317bf..927e644 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -87,9 +87,11 @@ jobs: kubectl wait --for=condition=Ready node --all --timeout=120s kubectl get nodes - - name: Deploy backing services (Redpanda, VersityGW, MinIO, fake-gcs-server) + - name: Deploy backing services (Redpanda, VersityGW, MinIO, fake-gcs-server, MariaDB) run: | set -euxo pipefail + # Generates the MariaDB account password into Secrets; prints none. + test/e2e/backing-services/mariadb-secret.sh kubectl apply -k test/e2e/backing-services # rollout status (not wait --for=Ready) tolerates the pods not # existing yet at the moment we ask. @@ -97,6 +99,7 @@ jobs: kubectl -n blobs rollout status deploy/versitygw --timeout=180s kubectl -n blobs rollout status deploy/minio --timeout=180s kubectl -n blobs rollout status deploy/fake-gcs --timeout=180s + kubectl -n mysql rollout status statefulset/mariadb --timeout=300s - name: Set up contain uses: solsson/setup-contain@49cc4cce1498df8a59e88fff52c1a9b747f11f08 # v1 @@ -127,7 +130,7 @@ jobs: - name: Run e2e harness env: - IMPLEMENTATIONS: redpanda,versitygw,minio,fakegcs + IMPLEMENTATIONS: redpanda,versitygw,minio,fakegcs,mariadb OCI_DIR: ./oci # PUSH_AS reaches the k3d local registry via its host-published # port; CONTROLLER_IMAGE uses the same registry's in-cluster @@ -148,6 +151,8 @@ jobs: kubectl -n blobs describe pod -l app=versitygw || true kubectl -n blobs describe pod -l app=minio || true kubectl -n blobs describe pod -l app=fake-gcs || true + kubectl -n mysql describe pod -l app=mariadb || true + kubectl -n mysql logs statefulset/mariadb --tail=100 || true # The image push job runs only on main-branch builds in the canonical # Yolean org with a successful e2e. It rebuilds (proves reproducibility), diff --git a/examples/mysql/README.md b/examples/mysql/README.md new file mode 100644 index 0000000..2193f09 --- /dev/null +++ b/examples/mysql/README.md @@ -0,0 +1,138 @@ +# mysql driver examples + +MariaDB/MySQL databases as `Buckety`, users as `BucketyAccess`. +The design and the security model are in +[`docs/mysql.md`](../../docs/mysql.md); this page is the setup. + +| Path | What | +| --- | --- | +| `bootstrap/` | Yolean/kubernetes-mysql-cluster `variants/scale-1`, plus the `initdb/` component that creates the controller's account on first boot | +| `buckety-controller.yaml` | the backend config | +| `controller-env-patch.yaml` | the controller Deployment env the config reads | +| `happy-path/`, `multi-consumer/` | e2e scenarios: `Buckety`, `BucketyAccess` and consumer Jobs | + +## 1. Generate the account password once + +The MariaDB pod mounts the password and the controller reads it as +env; a pod can only use Secrets from its own namespace, so the same +Secret goes into both: + +```sh +umask 077 +head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n' > mysql-buckety-password +kubectl -n mysql create secret generic mysql-buckety --from-file=password=mysql-buckety-password +kubectl -n buckety create secret generic mysql-buckety --from-file=password=mysql-buckety-password +rm mysql-buckety-password +``` + +Use 16-128 characters of `A-Za-z0-9._~-`; the bootstrap script +refuses anything else. + +## 2. Create the controller's account + +kubernetes-mysql-cluster lets root connect only from localhost, so +the account is created inside the MariaDB pod. + +**New instance:** deploy with the component, e.g. +`kubectl apply -k examples/mysql/bootstrap`, or add it to your own +kustomization of any kubernetes-mysql-cluster variant: + +```yaml +resources: +- github.com/Yolean/kubernetes-mysql-cluster/variants/scale-1?ref= +components: +- /examples/mysql/bootstrap/initdb +``` + +When the image entrypoint initialises the empty data directory, it +runs `/docker-entrypoint-initdb.d/buckety-account.sh`, which reads +`/run/secrets/buckety/password` and runs: + +```sql +CREATE USER IF NOT EXISTS 'buckety'@'%' IDENTIFIED BY ''; +GRANT CREATE USER ON *.* TO 'buckety'@'%'; +GRANT ALL PRIVILEGES ON `b\_%`.* TO 'buckety'@'%' WITH GRANT OPTION; +``` + +`BUCKETY_ADMIN_USER` and `BUCKETY_NAME_PREFIX` override `buckety` +and `b_`. With Galera (scale-2, the 3-node base) the first node +creates the account and the others receive it with the data. + +**Existing instance:** the entrypoint skips init scripts when data +exists. Apply the component (the pod restarts with the script and +the Secret mounted) and run the script once; it is idempotent: + +```sh +kubectl -n mysql exec mariadb-0 -c mariadb -- /docker-entrypoint-initdb.d/buckety-account.sh +``` + +Or run the three statements above as root by hand. + +## 3. Configure the backend + +Add the backend from `buckety-controller.yaml` to the controller +config and the env from `controller-env-patch.yaml` to its +Deployment (see the top-level README, *Install*). The controller +connects lazily: a database that is down fails reconciles, not +startup. + +## 4. Use it + +```yaml +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: orders + namespace: shop +spec: + backend: cluster-mysql + # Must start with the backend's namePrefix: b_shop_orders + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Retain # Delete drops the database with its data + defaultAccess: + role: ReadWrite + credentialsSecretName: orders-db +--- +# A second consumer that only reads; it gets a user of its own. +# (An explicit access replaces the implicit one from defaultAccess; +# see SPEC.md "Implicit access".) +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: orders-reporting + namespace: shop +spec: + bucketyRef: + name: orders + credentialsSecretName: orders-reporting-db + role: Reader +``` + +The Secret has `host`, `port`, `database`, `username`, `password`, +`jdbcUrl` and `url`. For Keycloak, for example: + +```yaml +env: +- name: KC_DB + value: mariadb +- name: KC_DB_URL + valueFrom: { secretKeyRef: { name: keycloak-db, key: jdbcUrl } } +- name: KC_DB_USERNAME + valueFrom: { secretKeyRef: { name: keycloak-db, key: username } } +- name: KC_DB_PASSWORD + valueFrom: { secretKeyRef: { name: keycloak-db, key: password } } +``` + +Roles: `Reader` gets `SELECT`; `ReadWrite` gets data changes plus +the DDL migrations need; `Writer` is refused. To rotate a password, +delete the access's Secret (the next reconcile generates one), or +write a new password into it (it is applied to the server). Running +consumers keep their open connections; restart them to pick up a +new password. + +Databases that predate the prefix (say, a `keycloak` database +created by hand) cannot be adopted; see +[`docs/mysql.md`](../../docs/mysql.md#adoption-and-retention). diff --git a/examples/mysql/bootstrap/initdb/buckety-account.sh b/examples/mysql/bootstrap/initdb/buckety-account.sh new file mode 100644 index 0000000..0fd78eb --- /dev/null +++ b/examples/mysql/bootstrap/initdb/buckety-account.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Creates buckety-controller's account in a fresh MariaDB/MySQL. +# +# The image entrypoint runs this from /docker-entrypoint-initdb.d +# once, when it initialises an empty data directory. The password +# is read from a mounted Secret file, so no password is in source; +# the controller gets the same Secret through ${...} substitution. +# +# Environment (all optional): +# BUCKETY_PASSWORD_FILE default /run/secrets/buckety/password +# BUCKETY_ADMIN_USER default buckety +# BUCKETY_NAME_PREFIX default b_ (the backend's namePrefix) +# +# The body runs in a subshell, so the entrypoint may execute or +# source this file without inheriting its options or variables. + +buckety_account() ( + set -euo pipefail + password_file="${BUCKETY_PASSWORD_FILE:-/run/secrets/buckety/password}" + user="${BUCKETY_ADMIN_USER:-buckety}" + prefix="${BUCKETY_NAME_PREFIX:-b_}" + + if [ ! -s "$password_file" ]; then + echo "buckety-account: $password_file is missing or empty" >&2 + exit 1 + fi + password="$(cat "$password_file")" + # The driver's rule for passwords: characters that need no + # escaping inside a SQL string literal. + if [ ${#password} -lt 16 ] || [ ${#password} -gt 128 ] || [[ "$password" =~ [^A-Za-z0-9._~-] ]]; then + echo "buckety-account: the password must be 16-128 characters of A-Z a-z 0-9 . _ ~ -" >&2 + exit 1 + fi + if [[ ! "$user" =~ ^[A-Za-z0-9]{1,32}$ ]]; then + echo "buckety-account: BUCKETY_ADMIN_USER must be 1-32 letters or digits" >&2 + exit 1 + fi + if [[ ! "$prefix" =~ ^[a-z][a-z0-9]{0,8}_$ ]]; then + echo "buckety-account: BUCKETY_NAME_PREFIX must be a lowercase letter, letters or digits, and a trailing _" >&2 + exit 1 + fi + # GRANT ... ON db.* reads _ and % in the name as wildcards. + pattern="${prefix//_/\\_}%" + + client=mariadb + command -v mariadb >/dev/null 2>&1 || client=mysql + # Root's password when the image was given one; empty otherwise. + MYSQL_PWD="${MARIADB_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}" "$client" --protocol=socket -uroot < parameter defaults). + # parameters: + # characterSet: utf8mb4 + # collation: utf8mb4_unicode_ci diff --git a/examples/mysql/controller-env-patch.yaml b/examples/mysql/controller-env-patch.yaml new file mode 100644 index 0000000..60ad50e --- /dev/null +++ b/examples/mysql/controller-env-patch.yaml @@ -0,0 +1,17 @@ +# Patch for the controller Deployment: the account password for +# ${MYSQL_BUCKETY_PASSWORD} in buckety-controller.yaml. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: buckety-controller +spec: + template: + spec: + containers: + - name: controller + env: + - name: MYSQL_BUCKETY_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-buckety + key: password diff --git a/examples/mysql/happy-path/README.md b/examples/mysql/happy-path/README.md new file mode 100644 index 0000000..11c6359 --- /dev/null +++ b/examples/mysql/happy-path/README.md @@ -0,0 +1,29 @@ +# mysql / happy-path + +**Scenario:** SPEC.md §End-to-end coverage #1 - Single-consumer +happy path, for the `mysql` driver. + +A `Buckety` with `defaultAccess` creates a MariaDB database and a +user with ReadWrite grants on it, and mints the Secret a single +workload consumes. + +**Demonstrates:** + +- `spec.name: "b_${namespace}_${name}"`: database names must start + with the backend's `namePrefix` (`b_`). +- `characterSet` / `collation` parameters, applied at creation. +- The minted Secret carries the documented mysql keys: `host`, + `port`, `database` (resource-type key), `username`, `password`, + `jdbcUrl`, `url`. +- A consumer `Job` connects with the Secret's values, creates and + alters a table (schema migrations need DDL), and reads back a row. + +**Assertions** (`assert.sh`): + +1. `Buckety/orders` and its implicit `BucketyAccess/orders` reach + `Ready=True`. +2. `Secret/orders-db` has all keys and the owned label. +3. `database` and `status.backendResourceName` are + `b__orders`; `status.principal` is `@%`. +4. No `ScopingNotImplemented`: the mysql driver scopes per role. +5. The consumer `Job` completes. diff --git a/examples/mysql/happy-path/assert.sh b/examples/mysql/happy-path/assert.sh new file mode 100755 index 0000000..c430428 --- /dev/null +++ b/examples/mysql/happy-path/assert.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/orders 120s +wait_ready bucketyaccess/orders 120s +secret_has_keys orders-db host port database username password jdbcUrl url +secret_owned_label orders-db + +want="b_${E2E_NAMESPACE}_orders" +db="$(secret_value orders-db database)" +[[ "$db" == "$want" ]] || fail "Secret database is '$db', expected '$want'" +resolved="$(kc get buckety/orders -o jsonpath='{.status.backendResourceName}')" +[[ "$resolved" == "$want" ]] || fail "status.backendResourceName is '$resolved', expected '$want'" + +user="$(secret_value orders-db username)" +[[ "$user" == b_* && ${#user} -le 32 ]] || fail "username '$user' is not a b_ name of at most 32 characters" +principal="$(kc get bucketyaccess/orders -o jsonpath='{.status.principal}')" +[[ "$principal" == "$user@%" ]] || fail "status.principal is '$principal', expected '$user@%'" +jdbc="$(secret_value orders-db jdbcUrl)" +[[ "$jdbc" == "jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/$want" ]] || fail "jdbcUrl is '$jdbc'" + +# The mysql driver scopes per role: no ScopingNotImplemented. +sni="$(condition_status bucketyaccess/orders ScopingNotImplemented)" +[[ -z "$sni" || "$sni" == "False" ]] || fail "ScopingNotImplemented is '$sni'" + +kc wait --for=condition=Complete --timeout=120s job/orders-roundtrip \ + || { kc logs job/orders-roundtrip >&2 || true; fail "roundtrip Job did not complete"; } + +log "happy-path PASS" diff --git a/examples/mysql/happy-path/buckety.yaml b/examples/mysql/happy-path/buckety.yaml new file mode 100644 index 0000000..ca95c70 --- /dev/null +++ b/examples/mysql/happy-path/buckety.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: orders +spec: + backend: mysql + # Database names must start with the backend's namePrefix (b_). + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: orders-db diff --git a/examples/mysql/happy-path/consumer-job.yaml b/examples/mysql/happy-path/consumer-job.yaml new file mode 100644 index 0000000..e38f6ce --- /dev/null +++ b/examples/mysql/happy-path/consumer-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: orders-roundtrip +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roundtrip + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: orders-db, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: orders-db, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: orders-db, key: database } } + - name: DB_USER + valueFrom: { secretKeyRef: { name: orders-db, key: username } } + # The client reads MYSQL_PWD, keeping the password off the + # command line. + - name: MYSQL_PWD + valueFrom: { secretKeyRef: { name: orders-db, key: password } } + command: [bash, -ec] + args: + - | + sql() { mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --batch --skip-column-names "$DB_NAME" -e "$1"; } + sql "CREATE TABLE IF NOT EXISTS probe (id INT PRIMARY KEY, msg VARCHAR(40))" + sql "ALTER TABLE probe ADD COLUMN IF NOT EXISTS at TIMESTAMP NULL" + sql "REPLACE INTO probe (id, msg) VALUES (1, 'hello-buckety')" + [ "$(sql 'SELECT msg FROM probe WHERE id = 1')" = hello-buckety ] diff --git a/examples/mysql/happy-path/kustomization.yaml b/examples/mysql/happy-path/kustomization.yaml new file mode 100644 index 0000000..b49066b --- /dev/null +++ b/examples/mysql/happy-path/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml +- consumer-job.yaml diff --git a/examples/mysql/multi-consumer/README.md b/examples/mysql/multi-consumer/README.md new file mode 100644 index 0000000..575c1de --- /dev/null +++ b/examples/mysql/multi-consumer/README.md @@ -0,0 +1,29 @@ +# mysql / multi-consumer + +**Scenario:** SPEC.md §End-to-end coverage #2 - Multiple consumers, +different roles, for the `mysql` driver. + +Unlike the v1alpha1 kadm/s3 drivers, the mysql driver gives every +`BucketyAccess` its own user with grants for its role, so the roles +are enforced by the server. + +**Demonstrates:** + +- One database, three `BucketyAccess`: `ReadWrite` (data and DDL), + `Reader` (`SELECT` only) and `Writer`, which the mysql driver + refuses (`Ready=False`, reason `GrantFailed`) rather than guess a + grant set for. +- A `Job` writes as the ReadWrite user and verifies the Reader can + read but not insert or create tables. +- Deletion blocks on explicit accesses; removing them drops their + users and then, with `retentionPolicy: Delete`, the database. + +**Assertions** (`assert.sh`): + +1. Buckety and the ReadWrite/Reader accesses reach Ready, with full + Secrets and no `ScopingNotImplemented`. +2. Both Secrets name the same database and different users. +3. The Writer access reports `GrantFailed` and has no Secret. +4. The roles Job completes. +5. `BlockedByAccesses` while accesses exist; deletion completes + after they are removed. diff --git a/examples/mysql/multi-consumer/access.yaml b/examples/mysql/multi-consumer/access.yaml new file mode 100644 index 0000000..080eef7 --- /dev/null +++ b/examples/mysql/multi-consumer/access.yaml @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-rw +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-rw + role: ReadWrite +--- +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-reader +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-reader + role: Reader +--- +# The mysql driver refuses Writer instead of guessing a grant set +# (docs/mysql.md); this access stays Ready=False/GrantFailed. +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shared-writer +spec: + bucketyRef: + name: shared + credentialsSecretName: shared-writer + role: Writer diff --git a/examples/mysql/multi-consumer/assert.sh b/examples/mysql/multi-consumer/assert.sh new file mode 100755 index 0000000..dec7905 --- /dev/null +++ b/examples/mysql/multi-consumer/assert.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/shared 120s +for s in shared-rw shared-reader; do + wait_ready "bucketyaccess/$s" 120s + secret_has_keys "$s" host port database username password jdbcUrl url + sni="$(condition_status "bucketyaccess/$s" ScopingNotImplemented)" + [[ -z "$sni" || "$sni" == "False" ]] || fail "$s: ScopingNotImplemented is '$sni'; the mysql driver scopes per role" +done + +[[ "$(secret_value shared-rw database)" == "$(secret_value shared-reader database)" ]] \ + || fail "the accesses name different databases" +[[ "$(secret_value shared-rw username)" != "$(secret_value shared-reader username)" ]] \ + || fail "the accesses share a user; each access must get its own" + +# Writer is refused, and nothing is minted for it. +wait_ready_reason bucketyaccess/shared-writer GrantFailed 60 +if kc get secret/shared-writer >/dev/null 2>&1; then + fail "a Secret was minted for the refused Writer access" +fi + +kc wait --for=condition=Complete --timeout=120s job/shared-roles \ + || { kc logs job/shared-roles >&2 || true; fail "roles Job did not complete"; } + +# Deletion blocks on explicit accesses; removing them drops their +# users, then the database (retentionPolicy=Delete). +log "deleting buckety/shared; expecting BlockedByAccesses" +kc delete buckety/shared --wait=false +wait_condition buckety/shared BlockedByAccesses True 60s +kc delete bucketyaccess --all --wait=true --timeout=90s +kc wait --for=delete buckety/shared --timeout=90s \ + || fail "shared deletion did not complete after accesses were removed" + +log "multi-consumer PASS" diff --git a/examples/mysql/multi-consumer/buckety.yaml b/examples/mysql/multi-consumer/buckety.yaml new file mode 100644 index 0000000..30e010d --- /dev/null +++ b/examples/mysql/multi-consumer/buckety.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shared +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete diff --git a/examples/mysql/multi-consumer/consumer-job.yaml b/examples/mysql/multi-consumer/consumer-job.yaml new file mode 100644 index 0000000..36c8253 --- /dev/null +++ b/examples/mysql/multi-consumer/consumer-job.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: shared-roles +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roles + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: shared-rw, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: shared-rw, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: shared-rw, key: database } } + - name: RW_USER + valueFrom: { secretKeyRef: { name: shared-rw, key: username } } + - name: RW_PASSWORD + valueFrom: { secretKeyRef: { name: shared-rw, key: password } } + - name: RO_USER + valueFrom: { secretKeyRef: { name: shared-reader, key: username } } + - name: RO_PASSWORD + valueFrom: { secretKeyRef: { name: shared-reader, key: password } } + command: [bash, -ec] + args: + - | + rw() { MYSQL_PWD="$RW_PASSWORD" mariadb -h"$DB_HOST" -P"$DB_PORT" -u"$RW_USER" -N -B "$DB_NAME" -e "$1"; } + ro() { MYSQL_PWD="$RO_PASSWORD" mariadb -h"$DB_HOST" -P"$DB_PORT" -u"$RO_USER" -N -B "$DB_NAME" -e "$1"; } + rw "CREATE TABLE IF NOT EXISTS t (id INT PRIMARY KEY)" + rw "REPLACE INTO t VALUES (1)" + [ "$(ro 'SELECT COUNT(*) FROM t')" = 1 ] + if ro "INSERT INTO t VALUES (2)" 2>/tmp/err; then echo "Reader could INSERT" >&2; exit 1; fi + grep -q "command denied" /tmp/err + if ro "CREATE TABLE t2 (id INT)" 2>/tmp/err; then echo "Reader could CREATE" >&2; exit 1; fi + grep -q "command denied" /tmp/err + echo "roles enforced" diff --git a/examples/mysql/multi-consumer/kustomization.yaml b/examples/mysql/multi-consumer/kustomization.yaml new file mode 100644 index 0000000..f5cb454 --- /dev/null +++ b/examples/mysql/multi-consumer/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml +- access.yaml +- consumer-job.yaml diff --git a/test/e2e/backing-services/kustomization.yaml b/test/e2e/backing-services/kustomization.yaml index d9347d9..6aeabc9 100644 --- a/test/e2e/backing-services/kustomization.yaml +++ b/test/e2e/backing-services/kustomization.yaml @@ -6,3 +6,4 @@ resources: - versitygw.yaml - minio.yaml - fakegcs.yaml +- mariadb diff --git a/test/e2e/backing-services/mariadb-secret.sh b/test/e2e/backing-services/mariadb-secret.sh new file mode 100755 index 0000000..43e506d --- /dev/null +++ b/test/e2e/backing-services/mariadb-secret.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Creates the controller account's password for the MariaDB backing +# service: generated once into Secret mysql-buckety in namespace +# mysql (mounted by MariaDB's first-boot script) and copied to the +# controller namespace (env for ${MYSQL_BUCKETY_PASSWORD}). A pod can +# only mount Secrets from its own namespace, hence two copies. +# Idempotent; never prints the password. Run before +# `kubectl apply -k test/e2e/backing-services`. + +set -euo pipefail + +CONTROLLER_NS="${CONTROLLER_NS:-buckety}" + +for ns in mysql "$CONTROLLER_NS"; do + kubectl create namespace "$ns" --dry-run=client -o yaml | kubectl apply -f - >/dev/null +done + +if ! kubectl -n mysql get secret mysql-buckety >/dev/null 2>&1; then + # 32 characters of [A-Za-z0-9_-], within the bootstrap script's rule. + head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n' \ + | kubectl -n mysql create secret generic mysql-buckety --from-file=password=/dev/stdin >/dev/null + echo "created secret mysql/mysql-buckety" +fi + +kubectl -n mysql get secret mysql-buckety -o json \ + | jq --arg ns "$CONTROLLER_NS" '{apiVersion, kind, type, data, metadata: {name: .metadata.name, namespace: $ns}}' \ + | kubectl apply -f - >/dev/null +echo "secret mysql-buckety present in namespaces mysql and $CONTROLLER_NS" diff --git a/test/e2e/backing-services/mariadb/kustomization.yaml b/test/e2e/backing-services/mariadb/kustomization.yaml new file mode 100644 index 0000000..c388db4 --- /dev/null +++ b/test/e2e/backing-services/mariadb/kustomization.yaml @@ -0,0 +1,12 @@ +# MariaDB for the mysql driver's e2e: one ephemeral node, with the +# controller account created on first boot by the same component +# examples/mysql/bootstrap uses. The account password is the Secret +# mysql-buckety, generated by ../mariadb-secret.sh (no password in +# source), which also copies it to the controller namespace. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: mysql +resources: +- mariadb.yaml +components: +- ../../../../examples/mysql/bootstrap/initdb diff --git a/test/e2e/backing-services/mariadb/mariadb.yaml b/test/e2e/backing-services/mariadb/mariadb.yaml new file mode 100644 index 0000000..fcf056d --- /dev/null +++ b/test/e2e/backing-services/mariadb/mariadb.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: Service +metadata: + name: mysql +spec: + selector: + app: mariadb + ports: + - port: 3306 + targetPort: 3306 + name: mysql +--- +# Named and shaped like kubernetes-mysql-cluster's StatefulSet +# (mariadb/mariadb, root only on localhost) so the initdb component +# patches both, but with an emptyDir data directory and no Galera. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mariadb +spec: + serviceName: mysql + replicas: 1 + selector: + matchLabels: + app: mariadb + template: + metadata: + labels: + app: mariadb + spec: + containers: + - name: mariadb + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: MYSQL_ROOT_HOST + value: localhost + - name: MYSQL_ALLOW_EMPTY_PASSWORD + value: "yes" + - name: MYSQL_INITDB_SKIP_TZINFO + value: "yes" + ports: + - containerPort: 3306 + name: mysql + # TCP, not mysqladmin over the socket: the entrypoint's + # first-boot server is socket-only, and Ready must mean the + # account exists and the real server listens. + readinessProbe: + tcpSocket: + port: 3306 + periodSeconds: 3 + volumeMounts: + - name: data + mountPath: /var/lib/mysql + volumes: + - name: data + emptyDir: {} diff --git a/test/e2e/backing-services/namespaces.yaml b/test/e2e/backing-services/namespaces.yaml index 740eb32..5c182f5 100644 --- a/test/e2e/backing-services/namespaces.yaml +++ b/test/e2e/backing-services/namespaces.yaml @@ -12,3 +12,8 @@ apiVersion: v1 kind: Namespace metadata: name: blobs +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mysql diff --git a/test/e2e/configs/mariadb.yaml b/test/e2e/configs/mariadb.yaml new file mode 100644 index 0000000..9aa698f --- /dev/null +++ b/test/e2e/configs/mariadb.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/pkg/config/schema/buckety-controller.schema.json +# Controller config for e2e against MariaDB (backing-services/mariadb). +# The account and its password are set up by +# backing-services/mariadb-secret.sh and the first-boot script. +backends: + +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${MYSQL_BUCKETY_PASSWORD} + defaults: + zone: e2e diff --git a/test/e2e/lib.sh b/test/e2e/lib.sh index 1b40213..2334fcc 100755 --- a/test/e2e/lib.sh +++ b/test/e2e/lib.sh @@ -64,7 +64,9 @@ secret_has_keys() { fi done if [[ ${#missing[@]} -gt 0 ]]; then - kc get "secret/$secret" -o yaml >&2 || true + # Key names only: the values are credentials. + # shellcheck disable=SC2016 # a go-template, not shell + log "Secret/$secret has keys: $(kc get "secret/$secret" -o go-template='{{range $k, $v := .data}}{{$k}} {{end}}' 2>/dev/null || true)" fail "Secret/$secret missing keys: ${missing[*]}" fi } diff --git a/test/e2e/overlay/deployment.yaml b/test/e2e/overlay/deployment.yaml index 4ad1f5e..ed7b9c8 100644 --- a/test/e2e/overlay/deployment.yaml +++ b/test/e2e/overlay/deployment.yaml @@ -43,3 +43,11 @@ spec: key: root-secretkey - name: STORAGE_EMULATOR_HOST value: fake-gcs.blobs.svc.cluster.local:8000 + # Optional so clusters without the MariaDB backing service + # (backing-services/mariadb-secret.sh not run) still start. + - name: MYSQL_BUCKETY_PASSWORD + valueFrom: + secretKeyRef: + name: mysql-buckety + key: password + optional: true diff --git a/test/e2e/run.sh b/test/e2e/run.sh index df68825..4b5cb57 100755 --- a/test/e2e/run.sh +++ b/test/e2e/run.sh @@ -8,7 +8,7 @@ # cluster with backing services available. # # Inputs (env): -# IMPLEMENTATIONS Comma-separated. Default: redpanda,versitygw,minio,fakegcs. +# IMPLEMENTATIONS Comma-separated. Default: redpanda,versitygw,minio,fakegcs,mariadb. # Each maps via $IMPL_DRIVER below to a driver. # SCENARIOS Optional comma-separated scenario directory # names (e.g. adoption,backend-stickiness) to run @@ -58,7 +58,7 @@ HERE="$(here)" REPO="$(cd "$HERE/../.." && pwd)" CONTROLLER_NS="${CONTROLLER_NS:-buckety}" KEEP_FAILED="${KEEP_FAILED:-false}" -IMPLEMENTATIONS="${IMPLEMENTATIONS:-redpanda,versitygw,minio,fakegcs}" +IMPLEMENTATIONS="${IMPLEMENTATIONS:-redpanda,versitygw,minio,fakegcs,mariadb}" # Map implementation -> driver. Scenario discovery uses this to # pick which examples//* to run for each implementation. @@ -67,6 +67,7 @@ declare -A IMPL_DRIVER=( [versitygw]=s3 [minio]=s3 [fakegcs]=gcs + [mariadb]=mysql ) # Map implementation -> the backend name its config declares for # the driver-agnostic scenarios. backend-stickiness renames it to @@ -77,6 +78,7 @@ declare -A IMPL_BACKEND=( [versitygw]=s3 [minio]=s3 [fakegcs]=gcs + [mariadb]=mysql ) log() { printf '[run.sh] %s\n' "$*" >&2; } From 484430ab14872a4088337a4ba8df03d15a624fdf Mon Sep 17 00:00:00 2001 From: Yolean gle-qa Date: Thu, 1 Oct 2026 20:28:16 +0000 Subject: [PATCH 04/15] docs: the mysql driver's contract, account and decisions SPEC gains the driver in the driver table, the Secret output section with the prefix boundary as a contract-level property, the database notion of empty for adoption, the CI matrix row, and the scoping exception in the non-goals. README covers the backend config and the CREATE USER caveat for cluster maintainers. docs/mysql.md carries the detail: the account's grants, why CREATE USER makes the prefix the security boundary and that sensitive accounts belong on an instance without a backend, naming rules, roles, drift detection by login, SQL construction, MySQL 8 notes, and the design decisions, listed for review. Co-Authored-By: Claude Opus 5.5 --- README.md | 50 +++++++--- SPEC.md | 88 ++++++++++++++---- docs/mysql.md | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 34 deletions(-) create mode 100644 docs/mysql.md diff --git a/README.md b/README.md index bb1fe5c..17bfe90 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # buckety-controller A small in-cluster operator that provisions named resources on -backing services (Kafka topics, S3 buckets) and mints -`secretKeyRef`-friendly credentials Secrets for consumers. +backing services (Kafka topics, S3 buckets, MySQL databases) and +mints `secretKeyRef`-friendly credentials Secrets for consumers. -> **Status: v1alpha1 shipped.** Three drivers (`kadm`, `s3`, -> `gcs`) are implemented and covered by the e2e suite in CI. Every commit on +> **Status: v1alpha1 shipped.** Four drivers (`kadm`, `s3`, +> `gcs`, `mysql`) are implemented and covered by the e2e suite in CI. Every commit on > `main` pins a reproducible image build in > [`deploy/kustomize/release/`](./deploy/kustomize/release), and > the same digest is pushed to `ghcr.io/yolean/buckety-controller` @@ -56,16 +56,17 @@ here. The deviations are operational, not architectural: ## Status -v1alpha1. Three drivers shipped: +v1alpha1. Four drivers shipped: | Driver | Backing services | Notes | | --- | --- | --- | | `kadm` | Kafka-protocol brokers (Redpanda, Apache Kafka, Confluent) | Topic create/alter/delete. v1alpha1: no per-consumer SASL/SCRAM scoping. | | `s3` | S3-compatible (VersityGW, MinIO, AWS S3, Cloudflare R2, Hetzner, GCS interop) | Bucket create/delete. v1alpha1: all consumers receive the backend's root keys. | | `gcs` | Google Cloud Storage via the native JSON API | Bucket create/update/delete with location, uniform bucket-level access, versioning and lifecycle parameters. Access Secrets carry a static HMAC pair (S3-protocol data path); all consumers receive the same pair. Driver 0.2 adds opt-in per-bucket service accounts (`parameters.serviceAccount`): a bucket-scoped GCP SA whose key JSON lands in each access Secret for OAuth2 bearer-token auth (`examples/gcs/service-account/`). | +| `mysql` | MariaDB 10.11+ and MySQL 8 | Database create/drop with `characterSet` and `collation` parameters. Each access gets its own user, with grants for its role on that one database (`Reader`: `SELECT`; `ReadWrite`: data and schema changes). Every name is confined to the backend's `namePrefix`; see [`docs/mysql.md`](./docs/mysql.md) for the account it needs and why. | e2e coverage in CI runs against Redpanda (`kadm`), VersityGW + -MinIO (`s3`) and fake-gcs-server (`gcs`). The other listed S3 +MinIO (`s3`), fake-gcs-server (`gcs`) and MariaDB (`mysql`). The other listed S3 backends share the same client library and the same e2e shape; if you hit a compatibility issue with one of them, please file an issue. For `gcs`, behaviours the emulator cannot exercise (HMAC @@ -202,8 +203,24 @@ backends: # examples/gcs/service-account/ for required grants. # serviceAccounts: # project: my-buckety-identities + +- name: cluster-mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${MYSQL_BUCKETY_PASSWORD} + # Every database and user the driver touches starts with this; + # the account's grants are scoped to it. See docs/mysql.md. + namePrefix: b_ ``` +The mysql backend's account and its first-boot creation from a +Secret are in [`examples/mysql/`](./examples/mysql). `CREATE USER` +cannot be scoped by name on MariaDB or MySQL, so the driver refuses +every database and user outside `namePrefix`; keep accounts that +matter on an instance the controller has no backend for. + Access Secrets carry the S3-interop `endpoint` (a bare host - the scheme is the consumer's choice) and `region`, derived per bucket from its location: `storage..rep.googleapis.com` for @@ -356,16 +373,19 @@ explicit `BucketyAccess` in a second apply, if zero-gap matters. See [`SPEC.md`](./SPEC.md#implicit-access-defaultaccess) for the full lifecycle. -> **`role` is advisory in v1alpha1.** `BucketyAccess.spec.role` -> accepts `Reader`, `Writer`, or `ReadWrite`, but the v1alpha1 -> kadm and s3 drivers do not yet scope credentials per role. +> **`role` is advisory in v1alpha1, except for `mysql`.** +> `BucketyAccess.spec.role` accepts `Reader`, `Writer`, or +> `ReadWrite`, but the v1alpha1 kadm, s3 and gcs drivers do not yet +> scope credentials per role. > Every Secret minted for the same `Buckety` carries identical > root credentials regardless of `role`. The controller surfaces > a `ScopingNotImplemented=True` condition on each affected > `BucketyAccess` (visible via `kubectl describe bucketyaccess`) > so the gap is honest, not silent. Scoped credentials > (SASL/SCRAM, IAM users) are v1alpha2 work. Until then, treat -> `role` as documentation of intent, not enforcement. +> `role` as documentation of intent, not enforcement. The `mysql` +> driver does enforce it: each access is its own database user with +> grants for its role, and `Writer` is refused. S3 is the same shape; see [`examples/s3/`](./examples/s3). @@ -386,13 +406,14 @@ are in [`SPEC.md`](./SPEC.md#naming-templates). ## Resources -- `Buckety` — a topic / bucket / future-MySQL-database. Selects +- `Buckety` — a topic / bucket / MySQL database. Selects a backend by name. Carries mutable parameters; the controller reconciles drift to the backing service. - `BucketyAccess` — a Secret request. Each one mints exactly one Secret in the same namespace as the `BucketyAccess`. Multiple accesses can target the same Buckety (in v1alpha1 they all - receive identical credentials). + receive identical credentials, except with `mysql`, where each + gets its own user). Both kinds are namespaced. Cross-namespace `bucketyRef` is not supported in v1alpha1. @@ -491,9 +512,8 @@ Auto-migration is not in v1alpha1. The full list is in [`SPEC.md`](./SPEC.md). Highlights: -- No per-consumer credential scoping. SASL/SCRAM and IAM-user - minting are v1alpha2 work. -- No MySQL driver. +- No per-consumer credential scoping, except in the `mysql` + driver. SASL/SCRAM and IAM-user minting are v1alpha2 work. - No cross-namespace `bucketyRef`. - No hot-reload of `buckety-controller.yaml`. diff --git a/SPEC.md b/SPEC.md index a2f40d5..7706278 100644 --- a/SPEC.md +++ b/SPEC.md @@ -14,8 +14,8 @@ the two disagree, this file wins until both are revised together. ## Context Buckety provisions named, individually-configurable resources on -backing services (Kafka topics, S3 buckets, future: MySQL -databases) and mints per-consumer credentials. +backing services (Kafka topics, S3 buckets, MySQL databases) and +mints per-consumer credentials. The design is informed by a Yolean exploration of the Container Object Storage Interface (COSI). COSI's two-resource split @@ -73,13 +73,14 @@ classes — they pick the named thing their cluster offers. | `kadm` | Kafka-protocol topics (Redpanda, Apache Kafka, Confluent) | `github.com/twmb/franz-go/pkg/kadm` | Redpanda | | `s3` | S3 API (VersityGW, MinIO, AWS S3, R2, Hetzner, GCS interop) | `github.com/aws/aws-sdk-go-v2/service/s3` | VersityGW + MinIO | | `gcs` | Google Cloud Storage, provisioned via the native JSON API | `cloud.google.com/go/storage` | fake-gcs-server | +| `mysql` | MariaDB and MySQL databases, one user per access | `github.com/go-sql-driver/mysql` | MariaDB | Driver names are short, semantic, and stable; they MUST NOT -change after v1alpha1 ships. New drivers — `mysql` via -`go-sql-driver/mysql`, `minio-admin` for scoped MinIO IAM, -`versitygw-iam` for scoped VersityGW IAM — are follow-ups, not -v1alpha1. The `gcs` driver shipped as the first such follow-up -(same v1alpha1 CRDs, additive driver registration). +change after v1alpha1 ships. New drivers — `minio-admin` for +scoped MinIO IAM, `versitygw-iam` for scoped VersityGW IAM — are +follow-ups, not v1alpha1. The `gcs` driver shipped as the first +such follow-up and `mysql` as the second (same v1alpha1 CRDs, +additive driver registration). The `gcs` / `s3` boundary: the s3 driver's client-library bet covers GCS on the DATA path (HMAC keys work with SigV4 against @@ -278,7 +279,9 @@ credentials drawn from the backend's root config. The CRD shape already supports per-access scoping for v1alpha2 — drivers are expected to ignore role/parameter values they don't yet implement and surface a `ScopingNotImplemented` condition rather -than silently treating Reader as ReadWrite. +than silently treating Reader as ReadWrite. The mysql driver +scopes per access and role already, and refuses a role it has no +grant set for (`Writer`) instead of ignoring it. ## Implicit access (`defaultAccess`) @@ -568,7 +571,8 @@ applied against minio, versitygw and the GCS emulator. Subfamily hierarchies (definitions shared by a subset of a family) are deferred until a third bucket driver forces the -question; today the family is flat and small. +question; today the family is flat and small. `mysql` is not in +a family; a PostgreSQL driver would make a database family. ## Schemas @@ -597,7 +601,7 @@ standalone whole-document schemas for editors (the kubernetes-json-schema pattern): `buckety.schema.json` plus one per family and per driver (`buckety-blobstore` for the object-store family, `buckety-gcs`, `buckety-s3`, -`buckety-kadm`) and `bucketyaccess.schema.json`. +`buckety-kadm`, `buckety-mysql`) and `bucketyaccess.schema.json`. The suffix forms a specialize/generalize ladder walked by switching the URL: the family rung accepts only family-common parameters, so a resource annotated with it provably stays @@ -639,11 +643,13 @@ call, guided by the chosen operator SDK. (see *`Delete` is recursive*), idempotent on NotFound; called only when `retentionPolicy == Delete`. - `GrantAccess` — mints credentials, applies backend-side - permissions (no-op in v1alpha1 for both shipped drivers), - returns the Secret payload as a flat `map[string][]byte`. - Idempotent for the same `(Buckety, BucketyAccess)` pair. + permissions (no-op in v1alpha1 for kadm and s3; mysql creates + a user per access with the role's grants), returns the Secret + payload as a flat `map[string][]byte`. Idempotent for the same + `(Buckety, BucketyAccess)` pair, which the request identifies + by the access's namespace and name. - `RevokeAccess` — removes the backend-side principal (no-op in - v1alpha1), idempotent on NotFound. + v1alpha1 for kadm and s3), idempotent on NotFound. - `ValidateParameters` — used by the admission webhook and, with the webhook disabled, by the reconciler; returns an error naming the offending key. @@ -720,8 +726,8 @@ identity: on the broker. - `s3` driver → key `bucket` carrying the resolved bucket name on the backend. -- Future drivers follow the same pattern: `database`, - `namespace`, etc. +- `mysql` driver → key `database` carrying the database name. +- Future drivers follow the same pattern: `namespace`, etc. The resource-type key is the unambiguous handle a consumer uses to operate on the resource. The other keys (`bootstrap`, @@ -879,6 +885,48 @@ ever wedges an SA at the key cap, surplus `USER_MANAGED` keys on a marker-verified SA are safe to delete server-side and the fleet re-mints within one reconcile. +### `mysql` driver + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: orders-db + namespace: shop +type: Opaque +data: + host: # mysql.mysql.svc.cluster.local + port: # 3306 + database: # b_shop_orders (resource-type key) + username: # b_shop_orders (this access's own user) + password: + jdbcUrl: # jdbc:mariadb://mysql.mysql.svc.cluster.local:3306/b_shop_orders + url: # mysql://:@mysql.mysql.svc.cluster.local:3306/b_shop_orders +``` + +Unlike the v1alpha1 kadm and s3 drivers, credentials are scoped: +each `BucketyAccess` gets a user of its own, named from the +access's namespace and name, with privileges on its one database +by role (`Reader`: `SELECT`; `ReadWrite`: data changes plus the DDL +schema migrations need; `Writer` is refused). `GrantResult.Scoped` +is true, `status.principal` is `user@host`, and `RevokeAccess` +drops the user under every retention policy. The password is +generated at the first grant and read back from the Secret on every +later one, so a password written into the Secret is applied to the +server and deleting the Secret rotates it. + +**The prefix boundary.** The controller's account needs +`CREATE USER` globally, which MariaDB and MySQL cannot scope by +name and which also allows altering and dropping every account on +the server. The backend's `namePrefix` is therefore the driver's +security boundary: every database and user name it creates or +touches must start with it, `spec.name` must resolve to it +(`b_${namespace}_${name}`), and anything outside it is refused, +including principals read back from status. Accounts that matter +belong on instances the controller has no backend for. +[`docs/mysql.md`](docs/mysql.md) has the account grants, naming +rules, drift detection and the decisions taken. + ## Adoption A resolved backend resource name can collide with a resource that @@ -910,7 +958,8 @@ when the resource already exists: - `AdoptEmpty` (default): adopt only when the resource holds no live content (no current objects for buckets; noncurrent versions and delete markers are not consulted. No retained - records for topics; fully expired records count as none). + records for topics; fully expired records count as none. No + tables, views, routines or events for databases). A non-empty resource surfaces `Ready=False` with reason `BackendResourceExists`, mints no Secret, stamps nothing, and re-checks on the periodic cadence. The message names the @@ -1149,6 +1198,7 @@ Required CI matrix for v1alpha1: | `kadm` | redpanda | | `s3` | versitygw, minio | | `gcs` | fakegcs (fake-gcs-server; covers the JSON-API control plane — real-GCS-only behaviours like HMAC auth enforcement, the 90-day UBLA disable window and the serviceAccounts IAM surface (no iam.googleapis.com or bucket-IAM emulation; unit-tested against an httptest fake instead) are documented, not e2e-gated) | +| `mysql` | mariadb (MariaDB 10.11; MySQL 8.0 is covered by the driver's integration test, run outside CI) | Adding an implementation later (e.g. AWS S3 once the project has credentials and a budget) requires no example or harness changes, @@ -1182,14 +1232,14 @@ and the corresponding GHA secret. ## Non-goals in v1alpha1 -- MySQL driver. - Per-consumer credential scoping (SASL/SCRAM for kafka, IAM users for S3). All `BucketyAccess` instances for the same `Buckety` receive identical credentials. Partial exception since gcs driver 0.2: opt-in per-BUCKET service accounts give each access its own key for a bucket-scoped identity (see Secret output > gcs driver), but role scoping remains - unimplemented. + unimplemented. The mysql driver is the exception: a user per + access, scoped by role (see Secret output > mysql driver). - Cross-namespace `bucketyRef`. - Quota enforcement. - Hot-reload of `buckety-controller.yaml` (envsubst is diff --git a/docs/mysql.md b/docs/mysql.md new file mode 100644 index 0000000..ccbeb83 --- /dev/null +++ b/docs/mysql.md @@ -0,0 +1,247 @@ +# The `mysql` driver + +Companion to `SPEC.md` for the MariaDB/MySQL driver (v0.1). The +SPEC has the contract; this page has the security model, the +naming rules and the choices behind them. Setup steps are in +[`examples/mysql/README.md`](../examples/mysql/README.md). + +| Resource | On the server | +| --- | --- | +| `Buckety` | a database, `CREATE DATABASE IF NOT EXISTS` with optional character set and collation | +| `BucketyAccess` | a user of its own, with a generated password and the role's privileges on that one database | +| `retentionPolicy: Delete` | `DROP DATABASE` (adopted databases are retained, as for every driver) | +| `BucketyAccess` deletion | `DROP USER`, under every retention policy | + +Tested against MariaDB 10.11 and MySQL 8.0 (the integration test +in `pkg/drivers/mysql/integration_test.go`). + +## The controller's account + +```sql +CREATE USER 'buckety'@'%' IDENTIFIED BY '...'; +GRANT CREATE USER ON *.* TO 'buckety'@'%'; +GRANT ALL PRIVILEGES ON `b\_%`.* TO 'buckety'@'%' WITH GRANT OPTION; +``` + +The account can create databases whose names start with `b_`, +create users, and grant on those databases. It cannot read or +change any other database, and it cannot read the `mysql` schema +(so it cannot see other accounts' password hashes or grants). +`\_` matters: in `GRANT ... ON db.*` an unescaped `_` is a +wildcard, and `b_%` would also cover `bad_db`. + +`examples/mysql/bootstrap/` creates the account on the server's +first boot from a mounted Secret, so no password is in source. + +### The CREATE USER caveat + +Neither MariaDB nor MySQL can scope `CREATE USER` by name, and the +privilege also allows `ALTER USER`, `RENAME USER` and `DROP USER` +on **every** account on the server. The server does not confine +the controller's account to its own users; the driver does: + +- every database and user name passes one check (`checkManaged`) + before it reaches SQL: it must start with `namePrefix`, be + lowercase letters, digits, `_` and `-` within the length limits, + and not be a system schema or account (`mysql`, `sys`, `root`, + `mariadb.sys`, ...); +- `namePrefix` must end with `_`, may not be a prefix of a system + name (`performance_` is refused), and the admin account itself + must be outside it; +- `RevokeAccess` parses `status.principal` and refuses to drop + anything outside the prefix, so a tampered status cannot drop + `root`. + +A controller compromise (its credentials, or a code path that +bypasses these checks) can still alter or drop any account on the +server. **Sensitive accounts belong on an instance the controller +has no backend for.** + +## Naming + +**Databases** are the resolved `spec.name`, which must start with +`namePrefix`: `spec.name: "b_${namespace}_${name}"`. The driver +does not prepend the prefix itself, so `status.backendResourceName` +and the Secret's `database` key are the real database name. A +`Buckety` without a prefixed `spec.name` is refused at admission +with that suggestion. Limit: 64 characters. + +The prefix is literal in the CR. A cluster that wants CRs portable +across prefixes can put it in the backend's `defaults` and write +`spec.name: "${backend.namePrefix}${namespace}_${name}"`. + +`spec.name` is tenant-controlled, as for every driver: the prefix +keeps tenants off databases outside it, not off each other's. +`spec.adoption: AdoptEmpty` stops a CR from claiming a database +with tables; `Adopt` claims it regardless. Where tenants are +mutually untrusted, require `${namespace}` in `spec.name` (and +restrict `adoption: Adopt`) with an admission policy. + +**Users** are derived from the `BucketyAccess` namespace and name +(the driver gets them in `GrantRequest`): + +- `b__` when it fits and neither part has a dot, + e.g. `b_shop_orders`; +- otherwise `b___<16 base32 characters of + sha256(namespace/name)>`: access `orders` in namespace + `e2e-mysql-mariadb-mysql-happy-path` is + `b_e2e-mysql-ma__eug6f3psticceco5`. + +Kubernetes names contain no `_`, so the first form has one +underscore after the prefix and the second two: the forms cannot +collide, and two accesses share a user only through an 80-bit hash +collision. The limit is 32 characters: MariaDB allows 80, MySQL 8 +only 32, and one scheme for both keeps a backend portable between +them. + +`status.principal` is `user@host`, with the `userHost` the user +was created with. + +## Roles + +Privileges are granted on the one database, by its escaped name +(`b\_t1\_orders`), never on a wildcard pattern. + +| `spec.role` | Privileges | +| --- | --- | +| `Reader` | `SELECT` | +| `ReadWrite` (default) | `SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES` | +| `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret | + +ReadWrite includes the DDL that schema migrations need (an application +that migrates its own schema on startup, as Keycloak does). On every reconcile the driver +grants what is missing and revokes everything else the user holds +on the database, including privileges granted out of band +(`EXECUTE`, `CREATE VIEW`, ...) and `GRANT OPTION`. `Scoped` is +true, so `ScopingNotImplemented` never surfaces for this driver. + +## Credentials and drift + +The Secret holds `host`, `port`, `database` (the resource-type +key), `username`, `password`, `jdbcUrl` +(`jdbc:mariadb://host:port/database`) and `url` +(`mysql://username:password@host:port/database`). + +- The first grant generates a 32-character password from + `crypto/rand` (letters, digits and `-._~`, all four classes, so + `simple_password_check` accepts it). +- Later grants reuse the Secret's password. A password changed in + the Secret is applied to the server; it must be 16-128 + characters of `A-Za-z0-9._~-`, the characters that need no + escaping anywhere the driver puts them. Anything else is refused + without echoing it. +- Deleting the Secret rotates the password: the next reconcile + generates one and sets it with `ALTER USER`. + +The admin account cannot see other users or their grants without +SELECT on the `mysql` schema, so the driver checks each user by +logging in as it with the Secret's password: + +- login succeeds: read the user's own rows in + `information_schema.SCHEMA_PRIVILEGES` and align the grants. When + nothing differs, nothing is written: steady state costs one login + per access per re-check and no statements that replicate. +- login is refused: the user is missing or its password differs. + `CREATE USER`, or `ALTER USER` when it exists, then grant, then + log in again (retrying for a few seconds, for Galera) to verify. + +So **`userHost` must admit the controller's own address**; with +the default `%` it does. Changing `userHost` moves each access to +a new account on its next reconcile, and the controller drops the +old one. + +Passwords never appear in logs or errors: the driver builds no DSN +strings, and errors from statements that carry a password are +redacted before they reach a condition or event. + +## SQL construction + +- Identifiers (database names, character sets, collations) are + validated against the rules above and backtick-quoted. +- Account names and passwords go in as `?` arguments. The account + statements (`CREATE USER`, `GRANT`) are not parameterisable in + the server-side prepared statement protocol, so the client + interpolates the arguments as escaped string literals + (go-sql-driver's `interpolateParams`); the values are validated + first regardless. +- Privilege names read back from the server are checked against + `^[A-Z]+( [A-Z]+)*$` before they are spliced into `REVOKE`. +- Multi-statements and `LOAD DATA LOCAL` stay disabled. + +## Parameters + +`characterSet` and `collation` are applied at creation and +immutable. The driver never runs `ALTER DATABASE`: it changes the +default for new tables only, and converging silently would hide +that existing tables still differ. A database whose defaults differ +from the parameters (changed out of band, or adopted) surfaces +`ParameterDrift`; fix it out of band or recreate the `Buckety`. +`utf8` is refused as ambiguous (utf8mb3 today, utf8mb4 later). + +## Adoption and retention + +Adoption works as for every driver, within the prefix: a database +is empty when it has no tables, views, routines or events. Existing +unprefixed databases (say, a `keycloak` database created before +buckety) cannot be adopted, because the driver refuses every name +outside the prefix; migrate them into prefixed databases (dump and +restore), or keep them outside buckety. + +`retentionPolicy: Delete` runs `DROP DATABASE` for databases the +controller created. Users are dropped with their `BucketyAccess` +under every policy; a retained database keeps no credentials. + +## TLS + +`tls: {}` in the backend config requires verified TLS on the +controller's connections (`caFile` and `serverName` optional). +Created users get no `REQUIRE SSL`; to require TLS from consumers, +set `require_secure_transport` on the server. On MySQL 8 without +`tls`, `caching_sha2_password` makes the client fetch the server's +RSA key unauthenticated before sending the password; configure +`tls` where the network is not trusted. + +## MySQL 8 notes + +- `partial_revokes` must stay `OFF` (the default): with it on, the + `%` in the account's `b\_%` grant is literal. +- User names are capped at 32 characters, which the naming above + already respects. +- The `jdbcUrl` key is in the MariaDB Connector/J form; MySQL + Connector/J wants `jdbc:mysql://`. + +## Design decisions + +1. **Roles.** ReadWrite includes DDL; no separate Owner + role. The CRD's `Writer` is refused rather than given a guessed + meaning; a DML-only Writer (`SELECT, INSERT, UPDATE, DELETE`) is + the obvious candidate if one is wanted. +2. **Naming.** Literal prefix in `spec.name`, validated, 64 + characters for databases; derived user names capped at 32 for + MySQL 8, with a hashed form for long names. +3. **TLS.** Optional `tls` for the controller's connections; no + `REQUIRE SSL` on created users (server-side + `require_secure_transport` instead). +4. **Password rotation.** On demand: delete the Secret, or write a + new password into it. No scheduled rotation; `ALTER USER` does + not end sessions that are already open. +5. **Galera.** `CREATE USER`, `GRANT` and DDL replicate as Total + Order Isolation, which briefly blocks the whole cluster, so the + driver writes only on change. A login check right after a change + may reach a node that has not applied it yet; verification + retries for about five seconds. + +## Known limitations + +- `DROP USER` and `ALTER USER` do not end open sessions; revoking + an access stops new logins, not running ones. Ending sessions + would need `CONNECTION ADMIN`/`SUPER`. +- A user created in a pass whose Secret write then fails is + dropped again; a controller crash between `CREATE USER` and the + status write can still leave a user that no `BucketyAccess` + records. Such users are found by the prefix. +- Table- and column-level grants are not managed. +- An account with the same user name and a more specific host + (created by hand) shadows the managed one: the driver cannot + inspect the managed account through it and re-asserts it on every + reconcile. From 4f3b78370cb343694aaecb171f63c29c262f9216 Mon Sep 17 00:00:00 2001 From: Yolean gle-qa Date: Thu, 1 Oct 2026 20:42:13 +0000 Subject: [PATCH 05/15] mysql: fixes from a security review pass - userHost must be lowercase. Servers store hosts lowercased, so an uppercase userHost made every login check read as shadowed (an ALTER USER and grants on every pass) and a case-only change would have revoked the live account as the "old" principal. - An error from CREATE/ALTER USER keeps only the server's error number, with a hint for the policy and privilege cases. A parse error's message quotes the statement and can cut it mid-password, which whole-string redaction cannot catch. - The cleanup DROP USER after a failed pass runs detached from the request context, which may be what failed. - A line break in adminPassword is a startup error, and the bootstrap script refuses a password file with a trailing newline: the controller gets the Secret verbatim through env, while $(cat) stripped the newline, so the two would never match. - The e2e secret copy uses create/replace instead of apply, which would also store the data in last-applied-configuration. - docs/mysql.md: cleartext passwords without tls, grants the login check cannot see, the fixed namePrefix and how to retire one, trust in status within the prefix, ReadWrite's DROP on its own database, Secret-edit churn on Galera, SYSTEM_USER on MySQL 8. Co-Authored-By: Claude Opus 5.5 --- docs/mysql.md | 51 ++++++++++++++----- examples/mysql/README.md | 5 +- .../mysql/bootstrap/initdb/buckety-account.sh | 7 ++- pkg/drivers/mysql/config.go | 4 ++ pkg/drivers/mysql/config_test.go | 12 +++++ pkg/drivers/mysql/driver.go | 11 ++-- pkg/drivers/mysql/driver_test.go | 28 +++++++--- pkg/drivers/mysql/names.go | 8 +-- pkg/drivers/mysql/names_test.go | 2 +- pkg/drivers/mysql/password.go | 22 ++++++++ .../mysql/schema/v0.1/config.schema.json | 6 +-- test/e2e/backing-services/mariadb-secret.sh | 6 ++- 12 files changed, 127 insertions(+), 35 deletions(-) diff --git a/docs/mysql.md b/docs/mysql.md index ccbeb83..65f9e8f 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -45,17 +45,29 @@ the controller's account to its own users; the driver does: lowercase letters, digits, `_` and `-` within the length limits, and not be a system schema or account (`mysql`, `sys`, `root`, `mariadb.sys`, ...); -- `namePrefix` must end with `_`, may not be a prefix of a system - name (`performance_` is refused), and the admin account itself - must be outside it; +- `namePrefix` is at most 10 characters ending in `_`, which keeps + it off every system schema and account name, and the admin + account itself must be outside it; - `RevokeAccess` parses `status.principal` and refuses to drop anything outside the prefix, so a tampered status cannot drop - `root`. + `root`. Within the prefix the driver trusts status: whoever can + write `BucketyAccess` status (by RBAC, only the controller) could + make it drop another access's user. A controller compromise (its credentials, or a code path that bypasses these checks) can still alter or drop any account on the server. **Sensitive accounts belong on an instance the controller -has no backend for.** +has no backend for.** On MySQL 8.0.16+ an account holding +`SYSTEM_USER` (root has it) cannot be altered or dropped by an +account without it, which narrows the exposure there; MariaDB has +no equivalent. + +**`namePrefix` is fixed for the backend's lifetime.** Databases +and principals stamped under one prefix are refused under another: +reconciles fail and access deletion blocks. To change it, add a +second backend with the new prefix and migrate; to retire one +anyway, drop its users and databases by hand and remove the +finalizers. ## Naming @@ -109,10 +121,13 @@ Privileges are granted on the one database, by its escaped name | `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret | ReadWrite includes the DDL that schema migrations need (an application -that migrates its own schema on startup, as Keycloak does). On every reconcile the driver +that migrates its own schema on startup, as Keycloak does). Database-level `DROP` and +`CREATE` also let a ReadWrite user drop its own database, or +recreate it; nothing beyond it. On every reconcile the driver grants what is missing and revokes everything else the user holds -on the database, including privileges granted out of band -(`EXECUTE`, `CREATE VIEW`, ...) and `GRANT OPTION`. `Scoped` is +on the database by its exact name, including privileges granted out +of band (`EXECUTE`, `CREATE VIEW`, ...) and `GRANT OPTION`. Grants +it cannot see are listed under *Known limitations*. `Scoped` is true, so `ScopingNotImplemented` never surfaces for this driver. ## Credentials and drift @@ -131,7 +146,10 @@ key), `username`, `password`, `jdbcUrl` escaping anywhere the driver puts them. Anything else is refused without echoing it. - Deleting the Secret rotates the password: the next reconcile - generates one and sets it with `ALTER USER`. + generates one and sets it with `ALTER USER`. Each change is an + account statement, which on Galera replicates as a cluster-wide + blocking (TOI) operation; there is no rate limit, so a tenant + editing its Secret in a loop can slow the cluster. The admin account cannot see other users or their grants without SELECT on the `mysql` schema, so the driver checks each user by @@ -151,8 +169,9 @@ a new account on its next reconcile, and the controller drops the old one. Passwords never appear in logs or errors: the driver builds no DSN -strings, and errors from statements that carry a password are -redacted before they reach a condition or event. +strings, and an error from a statement that carries a password +keeps only the server's error number (a parse error's message +quotes the statement) before it reaches a condition or event. ## SQL construction @@ -195,6 +214,10 @@ under every policy; a retained database keeps no credentials. `tls: {}` in the backend config requires verified TLS on the controller's connections (`caFile` and `serverName` optional). +Without it, every access's password crosses the network in +cleartext, inside `CREATE USER`/`ALTER USER` statements and the +login checks; leave `tls` out only where that network is trusted +(an in-cluster Service with NetworkPolicies, say). Created users get no `REQUIRE SSL`; to require TLS from consumers, set `require_secure_transport` on the server. On MySQL 8 without `tls`, `caching_sha2_password` makes the client fetch the server's @@ -240,7 +263,11 @@ RSA key unauthenticated before sending the password; configure dropped again; a controller crash between `CREATE USER` and the status write can still leave a user that no `BucketyAccess` records. Such users are found by the prefix. -- Table- and column-level grants are not managed. +- Grants the driver cannot see are not managed: table- and + column-level grants, grants that reach the database through a + wildcard pattern (`b\_%`, `b_t1%`), global privileges and roles. + The login check reads only the user's database-level row for the + exact database name. - An account with the same user name and a more specific host (created by hand) shadows the managed one: the driver cannot inspect the managed account through it and re-asserts it on every diff --git a/examples/mysql/README.md b/examples/mysql/README.md index 2193f09..04d2dee 100644 --- a/examples/mysql/README.md +++ b/examples/mysql/README.md @@ -25,8 +25,9 @@ kubectl -n buckety create secret generic mysql-buckety --from-file=password=mysq rm mysql-buckety-password ``` -Use 16-128 characters of `A-Za-z0-9._~-`; the bootstrap script -refuses anything else. +Use 16-128 characters of `A-Za-z0-9._~-` and no trailing newline; +the bootstrap script refuses anything else, and the controller +refuses a line break. ## 2. Create the controller's account diff --git a/examples/mysql/bootstrap/initdb/buckety-account.sh b/examples/mysql/bootstrap/initdb/buckety-account.sh index 0fd78eb..a17fd01 100644 --- a/examples/mysql/bootstrap/initdb/buckety-account.sh +++ b/examples/mysql/bootstrap/initdb/buckety-account.sh @@ -24,11 +24,14 @@ buckety_account() ( echo "buckety-account: $password_file is missing or empty" >&2 exit 1 fi - password="$(cat "$password_file")" + # Keep a trailing newline, so the check below refuses it: the + # controller's env gets the Secret verbatim and would not match. + password="$(cat "$password_file"; printf x)" + password="${password%x}" # The driver's rule for passwords: characters that need no # escaping inside a SQL string literal. if [ ${#password} -lt 16 ] || [ ${#password} -gt 128 ] || [[ "$password" =~ [^A-Za-z0-9._~-] ]]; then - echo "buckety-account: the password must be 16-128 characters of A-Z a-z 0-9 . _ ~ -" >&2 + echo "buckety-account: the password must be 16-128 characters of A-Z a-z 0-9 . _ ~ - and no trailing newline" >&2 exit 1 fi if [[ ! "$user" =~ ^[A-Za-z0-9]{1,32}$ ]]; then diff --git a/pkg/drivers/mysql/config.go b/pkg/drivers/mysql/config.go index 85597fd..31868c7 100644 --- a/pkg/drivers/mysql/config.go +++ b/pkg/drivers/mysql/config.go @@ -92,6 +92,10 @@ func (c *Config) validate() error { return fmt.Errorf("missing required field %q", "adminUser") case c.AdminPassword == "": return fmt.Errorf("missing required field %q", "adminPassword") + case strings.ContainsAny(c.AdminPassword, "\r\n"): + // The usual cause is a Secret created from a file that ends + // in a newline; the bootstrap script refuses those too. + return fmt.Errorf("adminPassword contains a line break; recreate its Secret without a trailing newline") } if err := checkPrefix(c.NamePrefix); err != nil { return err diff --git a/pkg/drivers/mysql/config_test.go b/pkg/drivers/mysql/config_test.go index cd62220..e69dc89 100644 --- a/pkg/drivers/mysql/config_test.go +++ b/pkg/drivers/mysql/config_test.go @@ -88,6 +88,7 @@ func TestDecodeConfigErrors(t *testing.T) { "system prefix": base + "namePrefix: performance_\n", "admin inside prefix": "host: db\nadminUser: b_admin\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n", "bad userHost": base + "userHost: \"%' OR 1\"\n", + "uppercase userHost": base + "userHost: DB.example\n", "tls unknown field": base + "tls:\n insecure: true\n", } for name, y := range cases { @@ -102,6 +103,17 @@ func TestDecodeConfigErrors(t *testing.T) { } } +func TestAdminPasswordLineBreak(t *testing.T) { + t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword+"\n") + _, err := decodeConfig(rawConfig(t, "host: db\nadminUser: buckety\nadminPassword: ${MYSQL_BUCKETY_PASSWORD}\n")) + if err == nil || !strings.Contains(err.Error(), "line break") { + t.Fatalf("trailing newline accepted: %v", err) + } + if strings.Contains(err.Error(), testAdminPassword) { + t.Fatal("error contains the admin password") + } +} + func TestFactoryRegisteredAndLazy(t *testing.T) { t.Setenv("MYSQL_BUCKETY_PASSWORD", testAdminPassword) f, ok := registry.Lookup(DriverName) diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go index 230cd6a..4b24a9f 100644 --- a/pkg/drivers/mysql/driver.go +++ b/pkg/drivers/mysql/driver.go @@ -193,8 +193,11 @@ func (d *Driver) GrantAccess(ctx context.Context, req registry.GrantRequest) (re if err != nil { if created { // Nothing records a user created in a failed pass: drop - // it, the next pass creates it again. - _ = d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host) + // it, the next pass creates it again. Detached from ctx, + // which may be what failed. + cctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), dialTimeout) + _ = d.conn.exec(cctx, dropUserSQL, acct.User, acct.Host) + cancel() } return registry.GrantResult{}, err } @@ -270,10 +273,10 @@ func (d *Driver) setPassword(ctx context.Context, acct account, password string) return true, nil } if !isMySQLError(err, erCannotUser) { - return false, fmt.Errorf("mysql: create user %s: %w", acct, redact(err, password)) + return false, fmt.Errorf("mysql: create user %s: %w", acct, statementError(err, password)) } if err := d.conn.exec(ctx, alterUserSQL, acct.User, acct.Host, password); err != nil { - return false, fmt.Errorf("mysql: set password of %s: %w", acct, redact(err, password)) + return false, fmt.Errorf("mysql: set password of %s: %w", acct, statementError(err, password)) } return false, nil } diff --git a/pkg/drivers/mysql/driver_test.go b/pkg/drivers/mysql/driver_test.go index fa45ddb..7e1e573 100644 --- a/pkg/drivers/mysql/driver_test.go +++ b/pkg/drivers/mysql/driver_test.go @@ -461,17 +461,31 @@ func TestGrantLoginErrorIsNotRepair(t *testing.T) { } } -func TestGrantRedactsPassword(t *testing.T) { +// Server messages of statements that carry a password are dropped: +// a parse error quotes the statement, possibly cut mid-password. +func TestGrantKeepsPasswordOutOfErrors(t *testing.T) { + for _, number := range []uint16{1064, 1819} { + fc := &fakeConn{} + fc.onExec = func(q string, args []any) error { + pw := args[len(args)-1].(string) + return &gomysql.MySQLError{Number: number, Message: fmt.Sprintf("near '%s' at line 1", pw[:10])} + } + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("server error %d", number)) { + t.Fatalf("want the error number, got %v", err) + } + if strings.Contains(err.Error(), fc.execs[0].args[2].(string)[:10]) || strings.Contains(err.Error(), "near") { + t.Fatal("error carries the server message, and with it part of the password") + } + } + // Client-side errors keep their text, minus the password. fc := &fakeConn{} fc.onExec = func(q string, args []any) error { - return &gomysql.MySQLError{Number: 1064, Message: fmt.Sprintf("syntax error near '%v'", args[len(args)-1])} + return fmt.Errorf("write tcp: broken pipe after %v", args[len(args)-1]) } _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("ReadWrite", nil)) - if err == nil || !strings.Contains(err.Error(), "") { - t.Fatalf("want a redacted error, got %v", err) - } - if strings.Contains(err.Error(), string(fc.execs[0].args[2].(string))) { - t.Fatal("error contains the generated password") + if err == nil || !strings.Contains(err.Error(), "broken pipe") || !strings.Contains(err.Error(), "") { + t.Fatalf("client error: %v", err) } } diff --git a/pkg/drivers/mysql/names.go b/pkg/drivers/mysql/names.go index bf2923a..218a394 100644 --- a/pkg/drivers/mysql/names.go +++ b/pkg/drivers/mysql/names.go @@ -29,8 +29,10 @@ var ( // only, so lower_case_table_names cannot alias two names. nameRE = regexp.MustCompile(`^[a-z0-9_-]+$`) // hostRE covers host names, IPs, netmasks and the % and _ - // wildcards an operator may use in userHost. - hostRE = regexp.MustCompile(`^[A-Za-z0-9.%_:/-]+$`) + // wildcards an operator may use in userHost. Lowercase only: + // servers store hosts lowercased, and the login check compares + // CURRENT_USER() exactly. + hostRE = regexp.MustCompile(`^[a-z0-9.%_:/-]+$`) // k8sPartRE is a namespace or name usable verbatim in a user // name: Kubernetes characters without dots. k8sPartRE = regexp.MustCompile(`^[a-z0-9-]+$`) @@ -58,7 +60,7 @@ func checkPrefix(p string) error { func checkHost(h string) error { if h == "" || len(h) > maxHostLen || !hostRE.MatchString(h) { - return fmt.Errorf("host %q must be 1-%d characters of letters, digits and . %% _ : / -", h, maxHostLen) + return fmt.Errorf("host %q must be 1-%d characters of lowercase letters, digits and . %% _ : / -", h, maxHostLen) } return nil } diff --git a/pkg/drivers/mysql/names_test.go b/pkg/drivers/mysql/names_test.go index ed6fadc..6d357e8 100644 --- a/pkg/drivers/mysql/names_test.go +++ b/pkg/drivers/mysql/names_test.go @@ -180,7 +180,7 @@ func TestParsePrincipal(t *testing.T) { } for _, p := range []string{ "root@localhost", "buckety@%", "keycloak@%", "b_x", "@%", "b_x@", - "b_x@'%'", "b_x@%' OR 1=1", "b_x@% ", "B_x@%", "b_@%", "mysql.sys@localhost", + "b_x@'%'", "b_x@%' OR 1=1", "b_x@% ", "B_x@%", "b_@%", "mysql.sys@localhost", "b_x@LOCALHOST", } { if _, err := d.parsePrincipal(p); err == nil { t.Errorf("parsePrincipal(%q) accepted", p) diff --git a/pkg/drivers/mysql/password.go b/pkg/drivers/mysql/password.go index 2e6e772..88434dc 100644 --- a/pkg/drivers/mysql/password.go +++ b/pkg/drivers/mysql/password.go @@ -2,10 +2,13 @@ package mysql import ( "crypto/rand" + "errors" "fmt" "math/big" "regexp" "strings" + + gomysql "github.com/go-sql-driver/mysql" ) // Passwords are drawn from the URL-unreserved characters: they need @@ -65,6 +68,25 @@ func redact(err error, secret string) error { return redactedError(strings.ReplaceAll(err.Error(), secret, "")) } +// statementError describes an error from a statement that carried a +// password. A server error keeps only its number: messages such as +// a parse error's "near '...'" quote the statement and may cut it +// mid-password, which redact cannot catch. +func statementError(err error, password string) error { + var me *gomysql.MySQLError + if errors.As(err, &me) { + hint := "" + switch me.Number { + case 1819: + hint = ": the server's password policy rejected the password" + case 1227: + hint = ": the controller's account lacks the CREATE USER privilege" + } + return redactedError(fmt.Sprintf("server error %d%s", me.Number, hint)) + } + return redact(err, password) +} + type redactedError string func (e redactedError) Error() string { return string(e) } diff --git a/pkg/drivers/mysql/schema/v0.1/config.schema.json b/pkg/drivers/mysql/schema/v0.1/config.schema.json index 06807f1..707629f 100644 --- a/pkg/drivers/mysql/schema/v0.1/config.schema.json +++ b/pkg/drivers/mysql/schema/v0.1/config.schema.json @@ -26,7 +26,7 @@ "adminPassword": { "type": "string", "minLength": 1, - "description": "The controller's password; use ${VAR} interpolation from a Secret-sourced env var rather than a literal. envsubst:\"true\"." + "description": "The controller's password; use ${VAR} interpolation from a Secret-sourced env var rather than a literal. A line break is a startup error (the usual cause is a Secret created from a file with a trailing newline). envsubst:\"true\"." }, "namePrefix": { "type": "string", @@ -36,9 +36,9 @@ }, "userHost": { "type": "string", - "pattern": "^[A-Za-z0-9.%_:/-]{1,255}$", + "pattern": "^[a-z0-9.%_:/-]{1,255}$", "default": "%", - "description": "Host part of created accounts ('user'@''). Must also admit the controller's own address: the driver verifies passwords and grants by logging in as each user. Changing it moves accesses to new accounts on their next reconcile and drops the old ones." + "description": "Host part of created accounts ('user'@''), lowercase as the server stores it. Must also admit the controller's own address: the driver verifies passwords and grants by logging in as each user. Changing it moves accesses to new accounts on their next reconcile and drops the old ones." }, "tls": { "type": "object", diff --git a/test/e2e/backing-services/mariadb-secret.sh b/test/e2e/backing-services/mariadb-secret.sh index 43e506d..8988ff3 100755 --- a/test/e2e/backing-services/mariadb-secret.sh +++ b/test/e2e/backing-services/mariadb-secret.sh @@ -23,7 +23,11 @@ if ! kubectl -n mysql get secret mysql-buckety >/dev/null 2>&1; then echo "created secret mysql/mysql-buckety" fi +# create/replace, not apply: apply would also store the data in the +# last-applied-configuration annotation. +verb=create +kubectl -n "$CONTROLLER_NS" get secret mysql-buckety >/dev/null 2>&1 && verb=replace kubectl -n mysql get secret mysql-buckety -o json \ | jq --arg ns "$CONTROLLER_NS" '{apiVersion, kind, type, data, metadata: {name: .metadata.name, namespace: $ns}}' \ - | kubectl apply -f - >/dev/null + | kubectl "$verb" -f - >/dev/null echo "secret mysql-buckety present in namespaces mysql and $CONTROLLER_NS" From 47c4a0b9685ff087cdf4c41dd21f2169776db054 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 06:40:13 +0200 Subject: [PATCH 06/15] mysql: no REVOKE of privileges a user does not hold MySQL 8.4 refuses REVOKE of a privilege the account does not hold (error 1141), and a REVOKE naming several privileges is atomic. The first grant of a Reader revoked the rest of the ReadWrite set in one statement, so on 8.4 a Reader access could never be granted; MariaDB and MySQL 8.0 accept such a REVOKE silently. A user created in the same pass holds nothing, so the first grant now revokes nothing. A re-keyed user (password from a deleted or edited Secret) still has unknown grants: the rest of the managed set is revoked one privilege at a time, with "no such grant" as the expected answer for each one it does not hold. Co-Authored-By: Claude Opus 5.5 --- pkg/drivers/mysql/conn.go | 1 + pkg/drivers/mysql/driver.go | 70 ++++++++++++++++++++++---------- pkg/drivers/mysql/driver_test.go | 57 ++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/pkg/drivers/mysql/conn.go b/pkg/drivers/mysql/conn.go index c4717c8..637aebf 100644 --- a/pkg/drivers/mysql/conn.go +++ b/pkg/drivers/mysql/conn.go @@ -14,6 +14,7 @@ import ( const ( erAccessDenied = 1045 // login refused: unknown user or wrong password erCannotUser = 1396 // CREATE USER of an existing account, and similar + erNoSuchGrant = 1141 // REVOKE of a privilege the account does not hold (MySQL 8.4) ) var ( diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go index 4b24a9f..36ade80 100644 --- a/pkg/drivers/mysql/driver.go +++ b/pkg/drivers/mysql/driver.go @@ -236,7 +236,13 @@ func (d *Driver) ensureAccount(ctx context.Context, acct account, password strin if created, err = d.setPassword(ctx, acct, password); err != nil { return false, err } - if err := d.alignGrants(ctx, acct, pattern, want, nil); err != nil { + // A user this pass created holds nothing yet; a re-keyed one + // holds what it held, which no login has read. + var have *grants + if created { + have = &grants{} + } + if err := d.alignGrants(ctx, acct, pattern, want, have); err != nil { return created, err } if !fromSecret { @@ -283,30 +289,21 @@ func (d *Driver) setPassword(ctx context.Context, acct account, password string) // alignGrants grants what is missing from want and revokes what is // beyond it, including GRANT OPTION. have is nil when the current -// grants are unknown (the user was just created or re-keyed): then -// the whole set is granted and the rest of the managed set revoked, -// and a later pass with a working login revokes anything else. +// grants are unknown (the user was re-keyed): then the whole set is +// granted and the rest of the managed set revoked one privilege at +// a time, and a later pass with a working login revokes anything +// else. func (d *Driver) alignGrants(ctx context.Context, acct account, pattern string, want []string, have *grants) error { - var missing, extra []string - grantOption := false if have == nil { - missing = want - extra = without(readWritePrivileges, want) - } else { - missing = without(want, have.privileges) - extra = without(have.privileges, want) - grantOption = have.grantOption - } - if len(missing) > 0 { - q, err := grantSQL(missing, pattern) - if err != nil { + if err := d.grant(ctx, acct, pattern, want); err != nil { return err } - if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { - return fmt.Errorf("mysql: grant %s to %s: %w", strings.Join(missing, ", "), acct, err) - } + return d.revokeIfHeld(ctx, acct, pattern, without(readWritePrivileges, want)) + } + if err := d.grant(ctx, acct, pattern, without(want, have.privileges)); err != nil { + return err } - if len(extra) > 0 { + if extra := without(have.privileges, want); len(extra) > 0 { q, err := revokeSQL(extra, pattern) if err != nil { return err @@ -315,7 +312,7 @@ func (d *Driver) alignGrants(ctx context.Context, acct account, pattern string, return fmt.Errorf("mysql: revoke %s from %s: %w", strings.Join(extra, ", "), acct, err) } } - if grantOption { + if have.grantOption { if err := d.conn.exec(ctx, revokeGrantOptionSQL(pattern), acct.User, acct.Host); err != nil { return fmt.Errorf("mysql: revoke GRANT OPTION from %s: %w", acct, err) } @@ -323,6 +320,37 @@ func (d *Driver) alignGrants(ctx context.Context, acct account, pattern string, return nil } +func (d *Driver) grant(ctx context.Context, acct account, pattern string, privileges []string) error { + if len(privileges) == 0 { + return nil + } + q, err := grantSQL(privileges, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil { + return fmt.Errorf("mysql: grant %s to %s: %w", strings.Join(privileges, ", "), acct, err) + } + return nil +} + +// revokeIfHeld revokes privileges that the account may or may not +// hold. One statement each: MySQL 8.4 refuses to revoke a privilege +// that is not held, and a REVOKE of several is atomic, so a single +// statement would also keep the ones that are. +func (d *Driver) revokeIfHeld(ctx context.Context, acct account, pattern string, privileges []string) error { + for _, p := range privileges { + q, err := revokeSQL([]string{p}, pattern) + if err != nil { + return err + } + if err := d.conn.exec(ctx, q, acct.User, acct.Host); err != nil && !isMySQLError(err, erNoSuchGrant) { + return fmt.Errorf("mysql: revoke %s from %s: %w", p, acct, err) + } + } + return nil +} + func (d *Driver) secretData(db, user, password string) map[string][]byte { port := strconv.Itoa(d.cfg.Port) hostPort := net.JoinHostPort(d.cfg.Host, port) diff --git a/pkg/drivers/mysql/driver_test.go b/pkg/drivers/mysql/driver_test.go index 7e1e573..d46ce33 100644 --- a/pkg/drivers/mysql/driver_test.go +++ b/pkg/drivers/mysql/driver_test.go @@ -429,6 +429,63 @@ func TestGrantAppliesChangedPassword(t *testing.T) { } } +// A user created in this pass holds nothing: no REVOKE, which +// MySQL 8.4 refuses for privileges that are not held. +func TestGrantFirstMintReaderRevokesNothing(t *testing.T) { + fc := &fakeConn{} + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err != nil { + t.Fatal(err) + } + q := fc.queries() + if len(q) != 2 || q[0] != createUserSQL || !strings.HasPrefix(q[1], "GRANT SELECT ON ") { + t.Fatalf("statements: %q", q) + } +} + +// A re-keyed user's grants are unknown: the rest of the managed set +// is revoked one privilege at a time, and "no such grant" is the +// expected answer for each privilege it does not hold. +func TestGrantRekeyedReaderRevokesEachPrivilege(t *testing.T) { + fc := &fakeConn{} + fc.onExec = func(q string, _ []any) error { + switch { + case q == createUserSQL: + return &gomysql.MySQLError{Number: erCannotUser} + case strings.HasPrefix(q, "REVOKE INSERT "): + return nil + case strings.HasPrefix(q, "REVOKE "): + return &gomysql.MySQLError{Number: erNoSuchGrant} + } + return nil + } + // No password in the Secret: deleting it rotates the password. + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err != nil { + t.Fatal(err) + } + var revokes []string + for _, q := range fc.queries() { + if strings.HasPrefix(q, "REVOKE ") { + revokes = append(revokes, q) + } + } + if len(revokes) != len(readWritePrivileges)-1 { + t.Fatalf("want one REVOKE per non-Reader privilege, got %q", revokes) + } + + fc.onExec = func(q string, _ []any) error { + switch { + case q == createUserSQL: + return &gomysql.MySQLError{Number: erCannotUser} + case strings.HasPrefix(q, "REVOKE "): + return &gomysql.MySQLError{Number: 1227} + } + return nil + } + if _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Reader", nil)); err == nil { + t.Fatal("a REVOKE refused for another reason was swallowed") + } +} + func TestGrantVerificationFailure(t *testing.T) { orig := verifyBackoff verifyBackoff = 0 From 93bc52f2c03c9d9ef4cb74cd651d33a9178558df Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 06:40:13 +0200 Subject: [PATCH 07/15] docs, tests: no pointers to private documents A gcs test comment pointed at a requirements document in a private repository; it now says what the case is. Two docs/mysql.md paragraphs are reflowed. Co-Authored-By: Claude Opus 5.5 --- docs/mysql.md | 23 ++++++++++++----------- pkg/drivers/gcs/driver_test.go | 5 ++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/mysql.md b/docs/mysql.md index 65f9e8f..f60fbd9 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -120,15 +120,16 @@ Privileges are granted on the one database, by its escaped name | `ReadWrite` (default) | `SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES` | | `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret | -ReadWrite includes the DDL that schema migrations need (an application -that migrates its own schema on startup, as Keycloak does). Database-level `DROP` and -`CREATE` also let a ReadWrite user drop its own database, or -recreate it; nothing beyond it. On every reconcile the driver -grants what is missing and revokes everything else the user holds -on the database by its exact name, including privileges granted out -of band (`EXECUTE`, `CREATE VIEW`, ...) and `GRANT OPTION`. Grants -it cannot see are listed under *Known limitations*. `Scoped` is -true, so `ScopingNotImplemented` never surfaces for this driver. +ReadWrite includes the DDL that schema migrations need (an +application that migrates its own schema on startup, as Keycloak +does). Database-level `DROP` and `CREATE` also let a ReadWrite +user drop its own database, or recreate it; nothing beyond it. +On every reconcile the driver grants what is missing and revokes +everything else the user holds on the database by its exact +name, including privileges granted out of band (`EXECUTE`, +`CREATE VIEW`, ...) and `GRANT OPTION`. Grants it cannot see are +listed under *Known limitations*. `Scoped` is true, so +`ScopingNotImplemented` never surfaces for this driver. ## Credentials and drift @@ -235,8 +236,8 @@ RSA key unauthenticated before sending the password; configure ## Design decisions -1. **Roles.** ReadWrite includes DDL; no separate Owner - role. The CRD's `Writer` is refused rather than given a guessed +1. **Roles.** ReadWrite includes DDL; no separate Owner role. + The CRD's `Writer` is refused rather than given a guessed meaning; a DML-only Writer (`SELECT, INSERT, UPDATE, DELETE`) is the obvious candidate if one is wanted. 2. **Naming.** Literal prefix in `spec.name`, validated, 64 diff --git a/pkg/drivers/gcs/driver_test.go b/pkg/drivers/gcs/driver_test.go index 42da236..5b016d8 100644 --- a/pkg/drivers/gcs/driver_test.go +++ b/pkg/drivers/gcs/driver_test.go @@ -148,9 +148,8 @@ func TestParseLifecycle(t *testing.T) { t.Errorf("rule[1].numNewerVersions: %d", r1.Condition.NumNewerVersions) } - // The blobs-per-org data plane's exact shape (checkit - // cluster-g2/buckety-controller/GCS_DRIVER_REQUIREMENTS_FROM_BLOBS.md - // must-have 3): multiple concurrent matchesPrefix rules. + // A data plane that expires each prefix on its own schedule: + // multiple concurrent matchesPrefix rules. blobs := `{"rule": [ {"action": {"type": "Delete"}, "condition": {"age": 7, "matchesPrefix": ["board-prints/"]}}, {"action": {"type": "Delete"}, "condition": {"age": 1, "matchesPrefix": [".staging/"]}} From 07618292918e028b3a93b4f4ed9837e0e307dfab Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 06:40:13 +0200 Subject: [PATCH 08/15] ci: the mysql integration test against MariaDB and MySQL in Docker test/integration/mysql.sh runs TestIntegration against MariaDB 10.11 and 11.4 and MySQL 8.0 and 8.4, one container each. The controller account is created the way a cluster creates it: the bootstrap script from examples/mysql runs from /docker-entrypoint-initdb.d as root on the socket, root only from localhost, so the script is tested against every image too. The e2e workflow runs the same script as its own job, and publish waits for it. BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto takes the address the server sees the test connect from, so the userHost-change step runs there as well. A new step rotates a Reader's password by deleting its Secret, the re-keyed path whose grants are unknown; on MySQL 8.4 that path needs one REVOKE per privilege. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e.yaml | 19 ++++- SPEC.md | 2 +- docs/mysql.md | 6 +- pkg/drivers/mysql/integration_test.go | 44 +++++++++- test/integration/mysql.sh | 117 ++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 8 deletions(-) create mode 100755 test/integration/mysql.sh diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 927e644..58b5096 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -154,6 +154,23 @@ jobs: kubectl -n mysql describe pod -l app=mariadb || true kubectl -n mysql logs statefulset/mariadb --tail=100 || true + # The mysql driver against real servers (MariaDB 10.11 and 11.4, + # MySQL 8.0 and 8.4), one container each; the script is the same + # one contributors run, and it owns the image list. + mysql-integration: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: mysql driver integration test + run: test/integration/mysql.sh + # The image push job runs only on main-branch builds in the canonical # Yolean org with a successful e2e. It rebuilds (proves reproducibility), # asserts the same digest the e2e job verified, then crane pushes to @@ -164,7 +181,7 @@ jobs: github.ref == 'refs/heads/main' && github.event_name == 'push' && github.repository_owner == 'Yolean' - needs: e2e + needs: [e2e, mysql-integration] runs-on: ubuntu-latest permissions: contents: read diff --git a/SPEC.md b/SPEC.md index 7706278..6091f50 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1198,7 +1198,7 @@ Required CI matrix for v1alpha1: | `kadm` | redpanda | | `s3` | versitygw, minio | | `gcs` | fakegcs (fake-gcs-server; covers the JSON-API control plane — real-GCS-only behaviours like HMAC auth enforcement, the 90-day UBLA disable window and the serviceAccounts IAM surface (no iam.googleapis.com or bucket-IAM emulation; unit-tested against an httptest fake instead) are documented, not e2e-gated) | -| `mysql` | mariadb (MariaDB 10.11; MySQL 8.0 is covered by the driver's integration test, run outside CI) | +| `mysql` | mariadb (MariaDB 10.11; the driver's integration test covers MariaDB 10.11 and 11.4 and MySQL 8.0 and 8.4 in a separate CI job, `test/integration/mysql.sh`) | Adding an implementation later (e.g. AWS S3 once the project has credentials and a budget) requires no example or harness changes, diff --git a/docs/mysql.md b/docs/mysql.md index f60fbd9..0dd46ea 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -12,8 +12,10 @@ naming rules and the choices behind them. Setup steps are in | `retentionPolicy: Delete` | `DROP DATABASE` (adopted databases are retained, as for every driver) | | `BucketyAccess` deletion | `DROP USER`, under every retention policy | -Tested against MariaDB 10.11 and MySQL 8.0 (the integration test -in `pkg/drivers/mysql/integration_test.go`). +CI tests it against MariaDB 10.11 and 11.4 and MySQL 8.0 and 8.4 +(`test/integration/mysql.sh`, which runs +`pkg/drivers/mysql/integration_test.go` against each in Docker), +and end to end against MariaDB 10.11. ## The controller's account diff --git a/pkg/drivers/mysql/integration_test.go b/pkg/drivers/mysql/integration_test.go index 447bac0..9964c86 100644 --- a/pkg/drivers/mysql/integration_test.go +++ b/pkg/drivers/mysql/integration_test.go @@ -2,7 +2,9 @@ package mysql // Integration test against a real MariaDB or MySQL server, skipped // unless BUCKETY_MYSQL_TEST_DSN holds the controller account's DSN -// in go-sql-driver form, for an account set up as in docs/mysql.md: +// in go-sql-driver form, for an account set up as in docs/mysql.md. +// test/integration/mysql.sh runs it against every supported server +// in Docker, as CI does; against a server of your own: // // BUCKETY_MYSQL_TEST_DSN="buckety:${PW}@tcp(127.0.0.1:3306)/" \ // go test ./pkg/drivers/mysql -run TestIntegration -v -count=1 @@ -11,7 +13,8 @@ package mysql // account's grant pattern; BUCKETY_MYSQL_TEST_USER_HOST (default %) // must admit the address the test connects from; // BUCKETY_MYSQL_TEST_ALT_USER_HOST (e.g. 127.0.0.1 through a -// port-forward) enables the userHost-change step. +// port-forward, or auto for the address the server sees) enables +// the userHost-change step. // // The test creates one database and two users named after a random // run id under the prefix, and drops them at the end. It never @@ -288,6 +291,23 @@ func TestIntegration(t *testing.T) { mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") }) + step("deleting the Secret rotates the password", func(t *testing.T) { + // The re-keyed path: the user exists, its grants are unknown, + // and the Reader must still be refused the ReadWrite set. + rotated := grant(t, roReq, nil) + if rotated.Minted || string(rotated.SecretData["password"]) == string(ro.SecretData["password"]) { + t.Fatalf("minted=%v, password changed=%v", rotated.Minted, string(rotated.SecretData["password"]) != string(ro.SecretData["password"])) + } + loginDenied(t, ro.SecretData) + u := connect(t, rotated.SecretData) + var n int + if err := u.QueryRowContext(ctx, "SELECT COUNT(*) FROM items").Scan(&n); err != nil { + t.Fatalf("select after rotation: %v", err) + } + denied(t, u, "INSERT INTO items (id, name) VALUES (9, 'nine')") + ro = rotated + }) + step("character set drift is reported, not repaired", func(t *testing.T) { mustExec(t, admin, "ALTER DATABASE "+quoteIdent(db)+" CHARACTER SET latin1 COLLATE latin1_swedish_ci") err := d.EnsureBuckety(ctx, registry.EnsureRequest{Name: db, Parameters: params}) @@ -320,8 +340,24 @@ func TestIntegration(t *testing.T) { }) // Optional: a userHost other than the default that also admits - // the test's address (127.0.0.1 through a port-forward). - if alt := os.Getenv("BUCKETY_MYSQL_TEST_ALT_USER_HOST"); alt != "" { + // the test's address (127.0.0.1 through a port-forward). "auto" + // takes the address the server sees this test connect from. + alt := os.Getenv("BUCKETY_MYSQL_TEST_ALT_USER_HOST") + if alt == "auto" { + var self string + if err := admin.QueryRowContext(ctx, "SELECT HOST FROM information_schema.PROCESSLIST WHERE ID = CONNECTION_ID()").Scan(&self); err != nil { + t.Fatalf("own address for BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto: %v", err) + } + if h, _, err := net.SplitHostPort(self); err == nil { + self = h + } + alt = self + if alt == cfg.UserHost || checkHost(alt) != nil { + t.Logf("connecting from %q: no usable alternative userHost, skipping the userHost change", self) + alt = "" + } + } + if alt != "" { step("userHost change moves the account", func(t *testing.T) { altCfg := *cfg altCfg.UserHost = alt diff --git a/test/integration/mysql.sh b/test/integration/mysql.sh new file mode 100755 index 0000000..f833bdb --- /dev/null +++ b/test/integration/mysql.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Runs the mysql driver's integration test +# (pkg/drivers/mysql/integration_test.go) against real servers in +# Docker, one container per image. CI runs this same script. +# +# Each server gets the controller's account the way a cluster +# does: examples/mysql/bootstrap/initdb/buckety-account.sh runs +# from /docker-entrypoint-initdb.d on first boot, as root on the +# local socket, with root allowed only from localhost. So this +# also tests the bootstrap script against every server image. +# +# Inputs (env): +# MYSQL_IMAGES Space-separated images. Default: every image in +# DEFAULT_IMAGES below. +# KEEP true leaves the containers running. +# +# Needs docker and go. Never prints a password. + +set -euo pipefail + +here() { cd "$(dirname "${BASH_SOURCE[0]}")" && pwd; } +REPO="$(cd "$(here)/../.." && pwd)" +KEEP="${KEEP:-false}" + +# The MariaDB the e2e backing service runs, the next MariaDB LTS, +# and both MySQL lines the driver supports. +DEFAULT_IMAGES=" +ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab +mariadb:11.4.13@sha256:03744ebde1e401fabe7ae7243ae2536d83f4f5ef14b87769cd269e072556c4c3 +mysql:8.0.46@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b +mysql:8.4.11@sha256:6ea90827b1100f8f2ae306a539f86d2c264a26ed435a2a9f75551dd5c3aeb242 +" +read -r -a IMAGES <<<"$(echo ${MYSQL_IMAGES:-$DEFAULT_IMAGES})" + +log() { printf '[mysql-it] %s\n' "$*" >&2; } +fail() { printf '[mysql-it][FAIL] %s\n' "$*" >&2; exit 1; } + +WORK="$(mktemp -d)" +CONTAINERS=() +cleanup() { + if [[ "$KEEP" != true ]]; then + for c in "${CONTAINERS[@]}"; do docker rm -f "$c" >/dev/null 2>&1 || true; done + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +# The password file is mounted like the Secret in a cluster. The +# server's init scripts run as the mysql user, hence world-readable +# inside a directory only this run knows about. +password="$(head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" +mkdir -p "$WORK/secret" +printf '%s' "$password" >"$WORK/secret/password" +chmod 0755 "$WORK" "$WORK/secret" +chmod 0644 "$WORK/secret/password" + +# start : boots a server with the bootstrap script. +start() { + local image="$1" name="$2" + docker run -d --name "$name" \ + -p 127.0.0.1::3306 \ + -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ + -e MYSQL_ROOT_HOST=localhost \ + -e MYSQL_INITDB_SKIP_TZINFO=yes \ + -v "$REPO/examples/mysql/bootstrap/initdb/buckety-account.sh:/docker-entrypoint-initdb.d/buckety-account.sh:ro" \ + -v "$WORK/secret:/run/secrets/buckety:ro" \ + "$image" >/dev/null +} + +# wait_account : the account logs in over TCP, which only the +# final server accepts (the entrypoint's init server is socket-only). +wait_account() { + local name="$1" client + for _ in $(seq 1 90); do + client="$(docker exec "$name" sh -c 'command -v mariadb || command -v mysql' 2>/dev/null || true)" + if [[ -n "$client" ]] && docker exec -e MYSQL_PWD="$password" "$name" \ + "$client" --protocol=tcp -h127.0.0.1 -ubuckety -e 'SELECT 1' >/dev/null 2>&1; then + return 0 + fi + if [[ "$(docker inspect -f '{{.State.Running}}' "$name")" != true ]]; then + docker logs --tail 50 "$name" >&2 || true + fail "$name exited" + fi + sleep 2 + done + docker logs --tail 50 "$name" >&2 || true + fail "$name: the buckety account cannot log in after 180s" +} + +failed=() +for i in "${!IMAGES[@]}"; do + image="${IMAGES[$i]}" + name="buckety-mysql-it-$$-$i" + CONTAINERS+=("$name") + log "=== ${image%@*}" + start "$image" "$name" + wait_account "$name" + addr="$(docker port "$name" 3306/tcp | head -n1)" + log "server up at $addr" + # auto: the test uses the address the server sees it connect + # from as a second userHost, which covers moving accounts. + if BUCKETY_MYSQL_TEST_DSN="buckety:${password}@tcp(${addr})/" \ + BUCKETY_MYSQL_TEST_ALT_USER_HOST=auto \ + go -C "$REPO" test ./pkg/drivers/mysql -run '^TestIntegration$' -count=1 -v; then + log "PASS ${image%@*}" + else + log "FAIL ${image%@*}" + docker logs --tail 50 "$name" >&2 || true + failed+=("${image%@*}") + fi +done + +if (( ${#failed[@]} > 0 )); then + fail "failed against: ${failed[*]}" +fi +log "all ${#IMAGES[@]} servers PASS" From 3b3fa17a1f07aabb7330d4b1e563247ed3f8b38d Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 06:45:34 +0200 Subject: [PATCH 09/15] mysql: a refused role holds no user; DROP DATABASE gives up in time Two findings from a second review, of how the driver meets the reconcile loops: - Changing an existing access's role to Writer was refused before anything reached the server, so the access reported GrantFailed while its user kept ReadWrite's grants and its Secret kept working. A role without a grant set now drops the access's user (DROP USER IF EXISTS, one statement per failing reconcile). Changing the role back recreates the user with the password still in the Secret. - DROP DATABASE waits for open transactions on the database, and consumer sessions outlive DROP USER. The server's default wait is a day (MariaDB) or a year (MySQL), while the client gives up after 60s, so every retry queued another DROP whose pending lock blocks new queries on that database. The controller's sessions now set lock_wait_timeout to 50s, below the client timeout. The integration test covers both against every server image. Co-Authored-By: Claude Opus 5.5 --- pkg/drivers/mysql/config.go | 13 +++++++++++-- pkg/drivers/mysql/config_test.go | 3 +++ pkg/drivers/mysql/driver.go | 17 +++++++++++++---- pkg/drivers/mysql/driver_test.go | 25 +++++++++++++++++++++++-- pkg/drivers/mysql/integration_test.go | 23 +++++++++++++++++++++++ pkg/drivers/mysql/statements.go | 2 +- 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/pkg/drivers/mysql/config.go b/pkg/drivers/mysql/config.go index 31868c7..d966e31 100644 --- a/pkg/drivers/mysql/config.go +++ b/pkg/drivers/mysql/config.go @@ -112,8 +112,9 @@ func (c *Config) validate() error { // Connection timeouts. Reads are generous because DROP DATABASE on // a large database returns only when the files are gone. const ( - dialTimeout = 10 * time.Second - ioTimeout = 60 * time.Second + dialTimeout = 10 * time.Second + ioTimeout = 60 * time.Second + lockWaitTimeout = 50 * time.Second ) // clientConfig builds the go-sql-driver config for the admin @@ -133,6 +134,14 @@ func (c *Config) clientConfig() (*gomysql.Config, error) { mc.WriteTimeout = ioTimeout mc.InterpolateParams = true mc.Collation = "utf8mb4_general_ci" + // The server gives up on a metadata lock before the client + // gives up on the reply. DROP DATABASE waits for open + // transactions on the database (consumer sessions outlive + // DROP USER), by default for a day on MariaDB and a year on + // MySQL; without this, each timed-out retry would queue + // another DROP behind them, and the pending lock blocks new + // queries on that database. + mc.Params = map[string]string{"lock_wait_timeout": strconv.Itoa(int(lockWaitTimeout / time.Second))} if c.TLS != nil { tc := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.TLS.ServerName} if tc.ServerName == "" { diff --git a/pkg/drivers/mysql/config_test.go b/pkg/drivers/mysql/config_test.go index e69dc89..42b1ff5 100644 --- a/pkg/drivers/mysql/config_test.go +++ b/pkg/drivers/mysql/config_test.go @@ -144,6 +144,9 @@ func TestClientConfig(t *testing.T) { if mc, _ := c.clientConfig(); mc.Addr != "[fd00::1]:3306" { t.Errorf("IPv6 addr = %s", mc.Addr) } + if mc.Params["lock_wait_timeout"] != "50" || lockWaitTimeout >= ioTimeout { + t.Errorf("lock_wait_timeout %q must be set and below the read timeout %s", mc.Params["lock_wait_timeout"], ioTimeout) + } } func TestClientConfigTLS(t *testing.T) { diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go index 36ade80..79e792a 100644 --- a/pkg/drivers/mysql/driver.go +++ b/pkg/drivers/mysql/driver.go @@ -165,10 +165,6 @@ func (d *Driver) DeleteBuckety(ctx context.Context, req registry.DeleteRequest) // Secret keys: host, port, database (resource-type key), username, // password, jdbcUrl, url. func (d *Driver) GrantAccess(ctx context.Context, req registry.GrantRequest) (registry.GrantResult, error) { - want, err := rolePrivileges(req.Role) - if err != nil { - return registry.GrantResult{}, err - } db := req.BucketyName if err := d.checkDatabase(db); err != nil { return registry.GrantResult{}, fmt.Errorf("mysql: %w", err) @@ -179,6 +175,19 @@ func (d *Driver) GrantAccess(ctx context.Context, req registry.GrantRequest) (re } acct := account{User: user, Host: d.cfg.UserHost} + want, err := rolePrivileges(req.Role) + if err != nil { + // An access with no grant set holds nothing. Its user may + // exist from an earlier role, and refusing without dropping + // it would leave a downgrade to Writer with ReadWrite's + // grants and a working Secret. Changing the role back + // recreates the user with the Secret's password. + if derr := d.conn.exec(ctx, dropUserSQL, acct.User, acct.Host); derr != nil { + return registry.GrantResult{}, fmt.Errorf("%w; dropping the user %s it may hold from an earlier role failed: %v", err, acct, derr) + } + return registry.GrantResult{}, err + } + password, fromSecret := string(req.ExistingSecretData["password"]), true if password == "" { fromSecret = false diff --git a/pkg/drivers/mysql/driver_test.go b/pkg/drivers/mysql/driver_test.go index d46ce33..b4e495e 100644 --- a/pkg/drivers/mysql/driver_test.go +++ b/pkg/drivers/mysql/driver_test.go @@ -548,8 +548,6 @@ func TestGrantKeepsPasswordOutOfErrors(t *testing.T) { func TestGrantRefusals(t *testing.T) { cases := map[string]registry.GrantRequest{ - "Writer role": grantReq("Writer", nil), - "unknown role": grantReq("Admin", nil), "database outside": {BucketyName: "keycloak", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, "system database": {BucketyName: "mysql", AccessNamespace: "t1", AccessName: "app", Role: "ReadWrite"}, "no access name": {BucketyName: "b_t1_orders", Role: "ReadWrite"}, @@ -572,6 +570,29 @@ func TestGrantRefusals(t *testing.T) { } } +// A refused role holds no user: a downgrade to Writer must not keep +// the grants of the earlier role. +func TestGrantUnsupportedRoleDropsUser(t *testing.T) { + for _, role := range []string{"Writer", "Admin"} { + fc := &fakeConn{} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq(role, withPassword(secretPassword))) + if err == nil || !strings.Contains(err.Error(), role) { + t.Fatalf("%s: want a role error, got %v", role, err) + } + if len(fc.execs) != 1 || fc.execs[0].query != dropUserSQL || !slices.Equal(fc.execs[0].args, []any{"b_t1_app", "%"}) { + t.Fatalf("%s: statements %q", role, fc.queries()) + } + if len(fc.loginCalls) != 0 { + t.Errorf("%s: logged in", role) + } + } + fc := &fakeConn{onExec: func(string, []any) error { return errors.New("connection refused") }} + _, err := testDriver(fc).GrantAccess(context.Background(), grantReq("Writer", nil)) + if err == nil || !strings.Contains(err.Error(), "Writer") || !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("a failed drop should be reported with the role error: %v", err) + } +} + func TestRevokeAccess(t *testing.T) { fc := &fakeConn{} d := testDriver(fc) diff --git a/pkg/drivers/mysql/integration_test.go b/pkg/drivers/mysql/integration_test.go index 9964c86..9de399d 100644 --- a/pkg/drivers/mysql/integration_test.go +++ b/pkg/drivers/mysql/integration_test.go @@ -32,6 +32,7 @@ import ( "slices" "strconv" "testing" + "time" gomysql "github.com/go-sql-driver/mysql" @@ -291,6 +292,28 @@ func TestIntegration(t *testing.T) { mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (4, 'four')") }) + step("a downgrade to Writer drops the user, and back restores it", func(t *testing.T) { + toWriter := rwReq + toWriter.Role, toWriter.ExistingSecretData = "Writer", rw.SecretData + if _, err := d.GrantAccess(ctx, toWriter); err == nil { + t.Fatal("Writer accepted") + } + loginDenied(t, rw.SecretData) + back := grant(t, rwReq, rw.SecretData) + if string(back.SecretData["password"]) != string(rw.SecretData["password"]) { + t.Fatal("the Secret's password was not kept") + } + mustExec(t, connect(t, rw.SecretData), "INSERT INTO items (id, name) VALUES (6, 'six')") + mustExec(t, connect(t, rw.SecretData), "DELETE FROM items WHERE id = 6") + }) + + step("the server enforces the lock wait timeout", func(t *testing.T) { + var v int + if err := admin.QueryRowContext(ctx, "SELECT @@SESSION.lock_wait_timeout").Scan(&v); err != nil || v != int(lockWaitTimeout/time.Second) { + t.Fatalf("lock_wait_timeout = %d, %v", v, err) + } + }) + step("deleting the Secret rotates the password", func(t *testing.T) { // The re-keyed path: the user exists, its grants are unknown, // and the Reader must still be refused the ReadWrite set. diff --git a/pkg/drivers/mysql/statements.go b/pkg/drivers/mysql/statements.go index 525c3ad..d0db3ae 100644 --- a/pkg/drivers/mysql/statements.go +++ b/pkg/drivers/mysql/statements.go @@ -27,7 +27,7 @@ func rolePrivileges(role string) ([]string, error) { case "", "ReadWrite": return readWritePrivileges, nil case "Writer": - return nil, fmt.Errorf("mysql: role Writer is not supported; use Reader (SELECT) or ReadWrite (data and schema changes)") + return nil, fmt.Errorf("mysql: role Writer is not supported, and the access holds no user; use Reader (SELECT) or ReadWrite (data and schema changes)") default: return nil, fmt.Errorf("mysql: unknown role %q", role) } From 3731842c96351df23f89f5a5059dc934f59e7e38 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 06:45:34 +0200 Subject: [PATCH 10/15] docs/mysql.md: what a second review found the page left out - an access changed to Writer loses its user; - user names are unique within a cluster, not across clusters: one controller per server and namePrefix; - a generated password is verified on the next re-check, not in the pass that sets it, and the Galera retry is about four seconds; - grant changes reach an open session when it next selects the database; DROP DATABASE gives up after lock_wait_timeout; - more ways a user ends up recorded by no access, and that a shadowing account costs replicated statements on every re-check. Co-Authored-By: Claude Opus 5.5 --- docs/mysql.md | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/mysql.md b/docs/mysql.md index 0dd46ea..50ec035 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -104,7 +104,9 @@ restrict `adoption: Adopt`) with an admission policy. Kubernetes names contain no `_`, so the first form has one underscore after the prefix and the second two: the forms cannot collide, and two accesses share a user only through an 80-bit hash -collision. The limit is 32 characters: MariaDB allows 80, MySQL 8 +collision. Names are unique within one cluster, not across +clusters: run one controller per server and `namePrefix`, or give +each cluster's backend a prefix of its own. The limit is 32 characters: MariaDB allows 80, MySQL 8 only 32, and one scheme for both keeps a backend portable between them. @@ -120,7 +122,7 @@ Privileges are granted on the one database, by its escaped name | --- | --- | | `Reader` | `SELECT` | | `ReadWrite` (default) | `SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, CREATE TEMPORARY TABLES, LOCK TABLES` | -| `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret | +| `Writer` | refused: `Ready=False`, reason `GrantFailed`, no Secret; an access changed to `Writer` loses its user | ReadWrite includes the DDL that schema migrations need (an application that migrates its own schema on startup, as Keycloak @@ -164,7 +166,10 @@ logging in as it with the Secret's password: per access per re-check and no statements that replicate. - login is refused: the user is missing or its password differs. `CREATE USER`, or `ALTER USER` when it exists, then grant, then - log in again (retrying for a few seconds, for Galera) to verify. + log in again (retrying for about four seconds, for Galera) to + verify. A password the driver has just generated is not verified + in the same pass: it must reach the Secret first, and the next + re-check logs in with it. So **`userHost` must admit the controller's own address**; with the default `%` it does. Changing `userHost` moves each access to @@ -255,17 +260,27 @@ RSA key unauthenticated before sending the password; configure Order Isolation, which briefly blocks the whole cluster, so the driver writes only on change. A login check right after a change may reach a node that has not applied it yet; verification - retries for about five seconds. + retries for about four seconds. ## Known limitations - `DROP USER` and `ALTER USER` do not end open sessions; revoking - an access stops new logins, not running ones. Ending sessions - would need `CONNECTION ADMIN`/`SUPER`. + an access stops new logins, not running ones. Database-level + grant changes, such as a role change from ReadWrite to Reader, + reach an open session only when it next selects the database, + usually on reconnect. Ending sessions would need + `CONNECTION ADMIN`/`SUPER`. +- `DROP DATABASE` waits for open transactions on the database. The + controller's sessions set `lock_wait_timeout` to 50 seconds, so a + blocked drop fails and is retried rather than queueing behind + long-lived consumer sessions. - A user created in a pass whose Secret write then fails is - dropped again; a controller crash between `CREATE USER` and the - status write can still leave a user that no `BucketyAccess` - records. Such users are found by the prefix. + dropped again. A user can still end up recorded by no + `BucketyAccess`: a controller crash between `CREATE USER` and the + status write, a failed status write, or a failed drop of a + replaced principal followed by the access's deletion. Such users + are found by the prefix. An access later created with the same + namespace and name takes the user over with its own password. - Grants the driver cannot see are not managed: table- and column-level grants, grants that reach the database through a wildcard pattern (`b\_%`, `b_t1%`), global privileges and roles. @@ -273,5 +288,6 @@ RSA key unauthenticated before sending the password; configure exact database name. - An account with the same user name and a more specific host (created by hand) shadows the managed one: the driver cannot - inspect the managed account through it and re-asserts it on every - reconcile. + inspect the managed account through it, so every reconcile sets + its password and grants again, which on Galera means replicated + account statements on every re-check. Drop the shadowing account. From b65246e6cdcc58d39c16d113ce98448bb1bdda69 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 07:04:13 +0200 Subject: [PATCH 11/15] mysql: ParameterDrift says how to resume after an out-of-band fix ParameterDrift pauses re-checks until the Buckety changes, by the controller's design for drift that needs a human. The mysql message told users to ALTER DATABASE out of band, which alone leaves the Buckety at Ready=False indefinitely; e2e found it. The message and docs/mysql.md now say to annotate the Buckety afterwards. Co-Authored-By: Claude Opus 5.5 --- docs/mysql.md | 5 ++++- pkg/drivers/mysql/driver.go | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/mysql.md b/docs/mysql.md index 50ec035..208697a 100644 --- a/docs/mysql.md +++ b/docs/mysql.md @@ -202,7 +202,10 @@ immutable. The driver never runs `ALTER DATABASE`: it changes the default for new tables only, and converging silently would hide that existing tables still differ. A database whose defaults differ from the parameters (changed out of band, or adopted) surfaces -`ParameterDrift`; fix it out of band or recreate the `Buckety`. +`ParameterDrift`, and the controller stops re-checking it until the +`Buckety` changes. Fix it out of band and then touch the `Buckety` +(`kubectl annotate buckety/ example.com/recheck="$(date +%s)" +--overwrite`, any annotation will do), or recreate the `Buckety`. `utf8` is refused as ambiguous (utf8mb3 today, utf8mb4 later). ## Adoption and retention diff --git a/pkg/drivers/mysql/driver.go b/pkg/drivers/mysql/driver.go index 79e792a..7fb62df 100644 --- a/pkg/drivers/mysql/driver.go +++ b/pkg/drivers/mysql/driver.go @@ -139,7 +139,7 @@ func (d *Driver) EnsureBuckety(ctx context.Context, req registry.EnsureRequest) } if len(drift) > 0 { return ®istry.ErrParameterDrift{Reason: fmt.Sprintf( - "database %q %s; the driver does not change an existing database's defaults or convert its tables: ALTER DATABASE (and the tables) out of band, or recreate the Buckety", + "database %q %s; the driver does not change an existing database's defaults or convert its tables: ALTER DATABASE (and the tables) out of band and then annotate the Buckety to re-check it, or recreate the Buckety", req.Name, strings.Join(drift, ", "))} } return nil From ae8dfe1f23bf7f19bbda98ff722959391896d415 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 07:04:13 +0200 Subject: [PATCH 12/15] e2e: mysql scenarios for drift, adoption, retention and parameters mysql had happy-path and multi-consumer only (both now run on a local k3d against the MariaDB backing service). Added: - the shared bodies: backend-stickiness, driver-version, misconfigured-startup (plus an adminUser inside namePrefix) and scaled-to-zero; - oob-drift: DROP USER, extra grants with GRANT OPTION, DROP DATABASE and a changed character set, each made as root; password rotation by deleting the Secret, and a password written into it; - adoption: AdoptEmpty refuses a database with a table, adopts an empty one, Adopt claims the first, and adopted databases survive retentionPolicy=Delete; - retention-policy: Retain keeps the rows and drops the user, Delete drops both; - parameter-mutation: immutable characterSet/collation, unknown keys, utf8, a name outside namePrefix, access parameters. lib.sh gains mysql_root (root on the pod's socket), mysql_login (a Secret's credentials over TCP, password on stdin) and wait_until. backend_stickiness_scenario deletes sticky-new while its backend is still registered, and accepts the block showing on the implicit access: an access holding a principal of its own (a mysql user) cannot be revoked once its backend is gone, and left the namespace stuck terminating. kadm, s3 and gcs pass unchanged. Co-Authored-By: Claude Opus 5.5 --- examples/mysql/README.md | 3 +- examples/mysql/adoption/README.md | 22 ++++++ examples/mysql/adoption/assert.sh | 59 ++++++++++++++ examples/mysql/adoption/fresh.yaml | 12 +++ examples/mysql/adoption/kustomization.yaml | 5 ++ examples/mysql/adoption/pre.yaml | 14 ++++ examples/mysql/adoption/void.yaml | 14 ++++ examples/mysql/backend-stickiness/README.md | 15 ++++ examples/mysql/backend-stickiness/assert.sh | 6 ++ .../backend-stickiness/kustomization.yaml | 5 ++ .../mysql/backend-stickiness/original.yaml | 12 +++ .../mysql/backend-stickiness/renamed.yaml | 13 ++++ examples/mysql/driver-version/README.md | 10 +++ examples/mysql/driver-version/assert.sh | 6 ++ examples/mysql/driver-version/buckety.yaml | 12 +++ .../mysql/driver-version/kustomization.yaml | 4 + .../mysql/misconfigured-startup/README.md | 12 +++ .../mysql/misconfigured-startup/assert.sh | 6 ++ .../broken-configs/admin-in-prefix.yaml | 10 +++ .../broken-configs/missing-required.yaml | 6 ++ .../broken-configs/strict-decode.yaml | 7 ++ .../broken-configs/undefined-var.yaml | 7 ++ .../misconfigured-startup/expectations.txt | 8 ++ .../misconfigured-startup/kustomization.yaml | 6 ++ examples/mysql/oob-drift/README.md | 19 +++++ examples/mysql/oob-drift/assert.sh | 78 +++++++++++++++++++ examples/mysql/oob-drift/buckety.yaml | 35 +++++++++ examples/mysql/oob-drift/kustomization.yaml | 4 + examples/mysql/parameter-mutation/README.md | 22 ++++++ examples/mysql/parameter-mutation/assert.sh | 75 ++++++++++++++++++ .../mysql/parameter-mutation/buckety.yaml | 15 ++++ .../parameter-mutation/kustomization.yaml | 4 + examples/mysql/retention-policy/README.md | 12 +++ examples/mysql/retention-policy/assert.sh | 40 ++++++++++ examples/mysql/retention-policy/buckety.yaml | 25 ++++++ .../mysql/retention-policy/kustomization.yaml | 4 + examples/mysql/scaled-to-zero/README.md | 6 ++ examples/mysql/scaled-to-zero/assert.sh | 6 ++ examples/mysql/scaled-to-zero/buckety.yaml | 12 +++ .../mysql/scaled-to-zero/consumer-job.yaml | 33 ++++++++ .../mysql/scaled-to-zero/kustomization.yaml | 7 ++ test/e2e/lib.sh | 68 ++++++++++++++-- 42 files changed, 732 insertions(+), 7 deletions(-) create mode 100644 examples/mysql/adoption/README.md create mode 100755 examples/mysql/adoption/assert.sh create mode 100644 examples/mysql/adoption/fresh.yaml create mode 100644 examples/mysql/adoption/kustomization.yaml create mode 100644 examples/mysql/adoption/pre.yaml create mode 100644 examples/mysql/adoption/void.yaml create mode 100644 examples/mysql/backend-stickiness/README.md create mode 100755 examples/mysql/backend-stickiness/assert.sh create mode 100644 examples/mysql/backend-stickiness/kustomization.yaml create mode 100644 examples/mysql/backend-stickiness/original.yaml create mode 100644 examples/mysql/backend-stickiness/renamed.yaml create mode 100644 examples/mysql/driver-version/README.md create mode 100755 examples/mysql/driver-version/assert.sh create mode 100644 examples/mysql/driver-version/buckety.yaml create mode 100644 examples/mysql/driver-version/kustomization.yaml create mode 100644 examples/mysql/misconfigured-startup/README.md create mode 100755 examples/mysql/misconfigured-startup/assert.sh create mode 100644 examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml create mode 100644 examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml create mode 100644 examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml create mode 100644 examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml create mode 100644 examples/mysql/misconfigured-startup/expectations.txt create mode 100644 examples/mysql/misconfigured-startup/kustomization.yaml create mode 100644 examples/mysql/oob-drift/README.md create mode 100755 examples/mysql/oob-drift/assert.sh create mode 100644 examples/mysql/oob-drift/buckety.yaml create mode 100644 examples/mysql/oob-drift/kustomization.yaml create mode 100644 examples/mysql/parameter-mutation/README.md create mode 100755 examples/mysql/parameter-mutation/assert.sh create mode 100644 examples/mysql/parameter-mutation/buckety.yaml create mode 100644 examples/mysql/parameter-mutation/kustomization.yaml create mode 100644 examples/mysql/retention-policy/README.md create mode 100755 examples/mysql/retention-policy/assert.sh create mode 100644 examples/mysql/retention-policy/buckety.yaml create mode 100644 examples/mysql/retention-policy/kustomization.yaml create mode 100644 examples/mysql/scaled-to-zero/README.md create mode 100755 examples/mysql/scaled-to-zero/assert.sh create mode 100644 examples/mysql/scaled-to-zero/buckety.yaml create mode 100644 examples/mysql/scaled-to-zero/consumer-job.yaml create mode 100644 examples/mysql/scaled-to-zero/kustomization.yaml diff --git a/examples/mysql/README.md b/examples/mysql/README.md index 04d2dee..0dca2df 100644 --- a/examples/mysql/README.md +++ b/examples/mysql/README.md @@ -9,7 +9,8 @@ The design and the security model are in | `bootstrap/` | Yolean/kubernetes-mysql-cluster `variants/scale-1`, plus the `initdb/` component that creates the controller's account on first boot | | `buckety-controller.yaml` | the backend config | | `controller-env-patch.yaml` | the controller Deployment env the config reads | -| `happy-path/`, `multi-consumer/` | e2e scenarios: `Buckety`, `BucketyAccess` and consumer Jobs | +| `happy-path/`, `multi-consumer/`, `oob-drift/`, `adoption/`, `parameter-mutation/`, `retention-policy/` | e2e scenarios for this driver's behaviour | +| `backend-stickiness/`, `driver-version/`, `misconfigured-startup/`, `scaled-to-zero/` | e2e scenarios whose bodies every driver shares (`test/e2e/lib.sh`) | ## 1. Generate the account password once diff --git a/examples/mysql/adoption/README.md b/examples/mysql/adoption/README.md new file mode 100644 index 0000000..231b46b --- /dev/null +++ b/examples/mysql/adoption/README.md @@ -0,0 +1,22 @@ +# mysql / adoption + +SPEC scenario 11 ("Adoption") for the `mysql` driver: a `Buckety` +whose resolved name collides with an existing database must not +silently claim it, and deleting an adopted `Buckety` must never +drop data that predates the CR. A database is empty when it holds +no tables, views, routines or events. + +- `adopt-pre` collides with a pre-created database holding a table: + the default policy (AdoptEmpty) surfaces + `Ready=False/BackendResourceExists` and mints no Secret; + `spec.adoption=Adopt` unblocks it with `status.provenance=Adopted`, + and the minted user reads the existing rows. +- `adopt-void` collides with a pre-created empty database and adopts + under the default policy. +- `adopt-fresh` proves `provenance=Created` and that created + databases still honour `retentionPolicy=Delete`. +- Deleting either adopted `Buckety` keeps its database despite + `retentionPolicy=Delete`; assert.sh drops them at the end. + +Only names inside the backend's `namePrefix` can be adopted; the +driver refuses every other name. diff --git a/examples/mysql/adoption/assert.sh b/examples/mysql/adoption/assert.sh new file mode 100755 index 0000000..3f9e499 --- /dev/null +++ b/examples/mysql/adoption/assert.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" +here="$(cd "$(dirname "$0")" && pwd)" + +pre="b_${E2E_NAMESPACE}_adopt-pre" +void="b_${E2E_NAMESPACE}_adopt-void" + +kc apply -f "$here/fresh.yaml" +wait_ready buckety/adopt-fresh 120s +assert_provenance adopt-fresh Created +fresh="$(secret_value adopt-fresh-db database)" + +log "pre-creating $pre (with a table) and $void (empty)" +mysql_root "CREATE DATABASE \`$pre\`; CREATE TABLE \`$pre\`.precious (note VARCHAR(40)); INSERT INTO \`$pre\`.precious VALUES ('predates the CR')" +mysql_root "CREATE DATABASE \`$void\`" + +# Non-empty: refused, nothing stamped, no Secret. +kc apply -f "$here/pre.yaml" +wait_ready_reason buckety/adopt-pre BackendResourceExists 60 +if kc get secret/adopt-pre-db >/dev/null 2>&1; then + fail "Secret minted while adoption was refused" +fi + +# Empty: adopted under the default policy. +kc apply -f "$here/void.yaml" +wait_ready buckety/adopt-void 120s +assert_provenance adopt-void Adopted + +# Explicit opt-in claims the database; its rows are untouched and +# the minted user can read them. +log "unblocking adopt-pre with spec.adoption=Adopt" +kc patch buckety/adopt-pre --type=merge -p '{"spec":{"adoption":"Adopt"}}' +wait_ready buckety/adopt-pre 120s +assert_provenance adopt-pre Adopted +wait_ready bucketyaccess/adopt-pre 120s +[[ "$(mysql_login adopt-pre-db "SELECT note FROM precious")" == "predates the CR" ]] \ + || fail "the adopted database's rows are not readable with the minted Secret" + +# Deleting adopted Bucketys retains the databases despite +# retentionPolicy=Delete. +log "deleting adopted Bucketys (retentionPolicy=Delete must degrade to Retain)" +kc delete buckety/adopt-pre --wait=true --timeout=90s +kc delete buckety/adopt-void --wait=true --timeout=90s +mysql_database_exists "$pre" || fail "adopted database $pre was dropped" +mysql_database_exists "$void" || fail "adopted database $void was dropped" +[[ "$(mysql_root "SELECT COUNT(*) FROM \`$pre\`.precious")" == 1 ]] \ + || fail "adopted database $pre lost its rows" + +# Created databases still delete normally. +kc delete buckety/adopt-fresh --wait=true --timeout=90s +if mysql_database_exists "$fresh"; then + fail "created database $fresh survived retentionPolicy=Delete" +fi + +log "cleaning up retained databases out-of-band" +mysql_root "DROP DATABASE \`$pre\`; DROP DATABASE \`$void\`" + +log "adoption PASS" diff --git a/examples/mysql/adoption/fresh.yaml b/examples/mysql/adoption/fresh.yaml new file mode 100644 index 0000000..b7bf218 --- /dev/null +++ b/examples/mysql/adoption/fresh.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-fresh +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-fresh-db diff --git a/examples/mysql/adoption/kustomization.yaml b/examples/mysql/adoption/kustomization.yaml new file mode 100644 index 0000000..98129bd --- /dev/null +++ b/examples/mysql/adoption/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# CRs are applied in stages by assert.sh: the databases they +# collide with must be pre-created out-of-band first. +resources: [] diff --git a/examples/mysql/adoption/pre.yaml b/examples/mysql/adoption/pre.yaml new file mode 100644 index 0000000..b30b237 --- /dev/null +++ b/examples/mysql/adoption/pre.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +# Collides with the pre-created database that holds a table; the +# default adoption policy (AdoptEmpty) must refuse it. +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-pre +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-pre-db diff --git a/examples/mysql/adoption/void.yaml b/examples/mysql/adoption/void.yaml new file mode 100644 index 0000000..68b909e --- /dev/null +++ b/examples/mysql/adoption/void.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +# Collides with a pre-created empty database: adopted under the +# default policy. +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: adopt-void +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: adopt-void-db diff --git a/examples/mysql/backend-stickiness/README.md b/examples/mysql/backend-stickiness/README.md new file mode 100644 index 0000000..c4d7169 --- /dev/null +++ b/examples/mysql/backend-stickiness/README.md @@ -0,0 +1,15 @@ +# mysql backend stickiness + +SPEC scenario 7 for the mysql driver: `status.backend` is stamped +at first reconcile and sticky. Renaming the backend in the +controller config surfaces `BackendUnavailable` on existing +resources without mutating their stamped backend; deletion with +`retentionPolicy: Delete` blocks while the backend is missing (the +database would be orphaned otherwise) and completes once the +backend is restored. + +With mysql the implicit access holds a user of its own, so the +block shows on the access first (`cannot revoke principal ...`, +reason `BackendUnavailable`) and the `Buckety` waits for it. The +scenario deletes `sticky-new` before restoring the original config: +once its backend is unregistered, nothing could revoke its user. diff --git a/examples/mysql/backend-stickiness/assert.sh b/examples/mysql/backend-stickiness/assert.sh new file mode 100755 index 0000000..5f8ef05 --- /dev/null +++ b/examples/mysql/backend-stickiness/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +backend_stickiness_scenario mysql "$(dirname "$0")/renamed.yaml" diff --git a/examples/mysql/backend-stickiness/kustomization.yaml b/examples/mysql/backend-stickiness/kustomization.yaml new file mode 100644 index 0000000..0934c1a --- /dev/null +++ b/examples/mysql/backend-stickiness/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# renamed.yaml is applied by assert.sh after the config swap. +resources: +- original.yaml diff --git a/examples/mysql/backend-stickiness/original.yaml b/examples/mysql/backend-stickiness/original.yaml new file mode 100644 index 0000000..6d19893 --- /dev/null +++ b/examples/mysql/backend-stickiness/original.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: sticky-orig +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: sticky-orig-db diff --git a/examples/mysql/backend-stickiness/renamed.yaml b/examples/mysql/backend-stickiness/renamed.yaml new file mode 100644 index 0000000..3e5a7ba --- /dev/null +++ b/examples/mysql/backend-stickiness/renamed.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: sticky-new +spec: + backend: mysql-renamed + name: "b_${namespace}_${name}" + # Deleted by the scenario while the renamed backend still exists. + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: sticky-new-db diff --git a/examples/mysql/driver-version/README.md b/examples/mysql/driver-version/README.md new file mode 100644 index 0000000..fef8016 --- /dev/null +++ b/examples/mysql/driver-version/README.md @@ -0,0 +1,10 @@ +# mysql / driver-version + +**Scenario:** SPEC "Driver versioning" for the mysql driver, the +same image rotation as `examples/kadm/driver-version`. + +Patch bump auto-applies and advances `status.driverBuildVersion`; +major bump surfaces `DriverVersionIncompatible` and pauses +reconcile with `status.driverMajor` unchanged. The expected +versions derive from what the base image stamps, so the scenario +needs no knowledge of the driver's current version. diff --git a/examples/mysql/driver-version/assert.sh b/examples/mysql/driver-version/assert.sh new file mode 100755 index 0000000..7cf6cb4 --- /dev/null +++ b/examples/mysql/driver-version/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +driver_version_scenario pinned diff --git a/examples/mysql/driver-version/buckety.yaml b/examples/mysql/driver-version/buckety.yaml new file mode 100644 index 0000000..0a774f7 --- /dev/null +++ b/examples/mysql/driver-version/buckety.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: pinned +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: pinned-db diff --git a/examples/mysql/driver-version/kustomization.yaml b/examples/mysql/driver-version/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/driver-version/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/misconfigured-startup/README.md b/examples/mysql/misconfigured-startup/README.md new file mode 100644 index 0000000..9e4eefc --- /dev/null +++ b/examples/mysql/misconfigured-startup/README.md @@ -0,0 +1,12 @@ +# mysql misconfigured controller startup + +SPEC scenario 10 for the mysql driver: each broken config variant +makes the controller exit non-zero with a log message that is +enough to diagnose the mistake. The assert swaps the controller's +config Secret, watches for the expected message in the crash +logs, and restores the original config afterwards. + +Variants: strict-decode failure (typoed field), missing required +`host`, undefined `${VAR}` without a default, and an `adminUser` +inside `namePrefix` (the prefix boundary would not exclude the +controller's own account). diff --git a/examples/mysql/misconfigured-startup/assert.sh b/examples/mysql/misconfigured-startup/assert.sh new file mode 100755 index 0000000..bb73d28 --- /dev/null +++ b/examples/mysql/misconfigured-startup/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +misconfigured_startup_scenario "$(cd "$(dirname "$0")" && pwd)" diff --git a/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml b/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml new file mode 100644 index 0000000..9cb9f82 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/admin-in-prefix.yaml @@ -0,0 +1,10 @@ +backends: +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + # The controller's own account must be outside the names it + # manages, or it could alter or drop itself. + adminUser: b_admin + adminPassword: literal + namePrefix: b_ diff --git a/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml b/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml new file mode 100644 index 0000000..d141444 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/missing-required.yaml @@ -0,0 +1,6 @@ +backends: +- name: mysql + driver: mysql + config: + adminUser: buckety + adminPassword: literal diff --git a/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml b/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml new file mode 100644 index 0000000..bce35d7 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/strict-decode.yaml @@ -0,0 +1,7 @@ +backends: +- name: mysql + driver: mysql + config: + hots: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: literal diff --git a/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml b/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml new file mode 100644 index 0000000..6125846 --- /dev/null +++ b/examples/mysql/misconfigured-startup/broken-configs/undefined-var.yaml @@ -0,0 +1,7 @@ +backends: +- name: mysql + driver: mysql + config: + host: mysql.mysql.svc.cluster.local + adminUser: buckety + adminPassword: ${BUCKETY_E2E_UNSET_VAR} diff --git a/examples/mysql/misconfigured-startup/expectations.txt b/examples/mysql/misconfigured-startup/expectations.txt new file mode 100644 index 0000000..d432012 --- /dev/null +++ b/examples/mysql/misconfigured-startup/expectations.txt @@ -0,0 +1,8 @@ +# +# Patterns match against raw JSON-formatted controller log. Quoted +# field names appear as \", so regexes use distinctive substrings +# rather than literal quote matching. +strict-decode.yaml unknown field.*hots +missing-required.yaml missing required field.*host +undefined-var.yaml undefined variable.*BUCKETY_E2E_UNSET_VAR +admin-in-prefix.yaml adminUser.*starts with namePrefix diff --git a/examples/mysql/misconfigured-startup/kustomization.yaml b/examples/mysql/misconfigured-startup/kustomization.yaml new file mode 100644 index 0000000..378dbee --- /dev/null +++ b/examples/mysql/misconfigured-startup/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# This scenario operates on the controller's own config Secret; +# the harness handles apply/restore. Nothing namespace-scoped +# is applied via kustomize here. +resources: [] diff --git a/examples/mysql/oob-drift/README.md b/examples/mysql/oob-drift/README.md new file mode 100644 index 0000000..8029772 --- /dev/null +++ b/examples/mysql/oob-drift/README.md @@ -0,0 +1,19 @@ +# mysql / oob-drift + +**Scenario:** SPEC.md §End-to-end coverage #4 - out-of-band +changes, for the `mysql` driver. Each change is made as root on the +server; the controller repairs it on a periodic re-check, or reports +it when repairing would hide something. + +1. `DROP USER` of the ReadWrite user: recreated with the password + in its Secret, so consumers need no restart. +2. `GRANT INSERT ... WITH GRANT OPTION` to the Reader: revoked, + `GRANT OPTION` included. +3. `DROP DATABASE`: recreated, empty, and the user can use it. +4. `ALTER DATABASE ... CHARACTER SET latin1`: `ParameterDrift` + with `Ready=False`; the driver does not convert the database. + Restoring the character set and annotating the `Buckety` (drift + pauses re-checks until the resource changes) clears it. +5. Deleting the access Secret rotates the password: a new one is + generated, set on the server and written to a new Secret. +6. A password written into the Secret is applied to the server. diff --git a/examples/mysql/oob-drift/assert.sh b/examples/mysql/oob-drift/assert.sh new file mode 100755 index 0000000..a29838d --- /dev/null +++ b/examples/mysql/oob-drift/assert.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# The e2e controller re-checks every 15s (--periodic-recheck), so +# every repair below is expected within a few re-checks. + +wait_ready buckety/drift 120s +wait_ready bucketyaccess/drift-rw 120s +wait_ready bucketyaccess/drift-reader 120s +db="$(secret_value drift-db database)" +rw="$(secret_value drift-db username)" +ro="$(secret_value drift-reader-db username)" +mysql_login drift-db "CREATE TABLE t (id INT PRIMARY KEY); INSERT INTO t VALUES (1)" \ + || fail "the ReadWrite user cannot create a table" + +# A user dropped out of band is recreated with the Secret's password. +log "out-of-band: DROP USER $rw" +mysql_root "DROP USER '$rw'@'%'" +if mysql_login drift-db "SELECT 1" 2>/dev/null; then + fail "login still works after DROP USER" +fi +wait_until 90 "$rw recreated with the Secret's password" \ + mysql_login drift-db "SELECT COUNT(*) FROM t" + +# Privileges granted out of band are revoked, GRANT OPTION too. +pattern="${db//_/\\_}" +log "out-of-band: GRANT INSERT ... WITH GRANT OPTION to $ro" +mysql_root "GRANT INSERT ON \`$pattern\`.* TO '$ro'@'%' WITH GRANT OPTION" +reader_is_reader() { + local g + g="$(mysql_root "SHOW GRANTS FOR '$ro'@'%'")" + ! grep -qE 'INSERT|GRANT OPTION' <<<"$g" && grep -q 'GRANT SELECT ON' <<<"$g" +} +wait_until 90 "INSERT and GRANT OPTION revoked from $ro" reader_is_reader + +# A database dropped out of band is recreated (empty). +log "out-of-band: DROP DATABASE $db" +mysql_root "DROP DATABASE \`$db\`" +wait_until 90 "database $db recreated" mysql_database_exists "$db" +mysql_login drift-db "CREATE TABLE t (id INT PRIMARY KEY)" \ + || fail "the ReadWrite user cannot use the recreated database" + +# A changed character set is reported, never converted. +log "out-of-band: ALTER DATABASE $db CHARACTER SET latin1" +mysql_root "ALTER DATABASE \`$db\` CHARACTER SET latin1 COLLATE latin1_swedish_ci" +wait_condition buckety/drift ParameterDrift True 90s +[[ "$(condition_status buckety/drift Ready)" == "False" ]] \ + || fail "Ready should be False under ParameterDrift" +[[ "$(mysql_root "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == latin1 ]] \ + || fail "the controller altered the database's character set" +# ParameterDrift pauses re-checks until the Buckety changes: the +# documented resolution is to fix it out of band, then touch it. +mysql_root "ALTER DATABASE \`$db\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" +kc annotate buckety/drift e2e.buckety.yolean.se/recheck="$(date +%s)" --overwrite +wait_ready buckety/drift 90s + +# Deleting the Secret rotates the password. +before="$(secret_value drift-db password)" +log "deleting Secret/drift-db to rotate the password" +kc delete secret/drift-db +rotated() { + kc get secret/drift-db >/dev/null 2>&1 \ + && [[ "$(secret_value drift-db password)" != "$before" ]] \ + && mysql_login drift-db "SELECT 1" +} +wait_until 90 "a new password in Secret/drift-db that logs in" rotated + +# A password written into the Secret is applied to the server. +pw="$(head -c 24 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" +patch="$(mktemp)" +trap 'rm -f "$patch"' EXIT +printf '{"stringData":{"password":"%s"}}' "$pw" >"$patch" +log "writing a new password into Secret/drift-db" +kc patch secret/drift-db --type=merge --patch-file="$patch" +wait_until 90 "the written password logs in" mysql_login drift-db "SELECT 1" + +log "oob-drift PASS" diff --git a/examples/mysql/oob-drift/buckety.yaml b/examples/mysql/oob-drift/buckety.yaml new file mode 100644 index 0000000..b7f45ca --- /dev/null +++ b/examples/mysql/oob-drift/buckety.yaml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: drift +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +# Explicit accesses: one would replace an implicit defaultAccess. +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: drift-rw +spec: + bucketyRef: + name: drift + credentialsSecretName: drift-db + role: ReadWrite +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/bucketyaccess.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: drift-reader +spec: + bucketyRef: + name: drift + credentialsSecretName: drift-reader-db + role: Reader diff --git a/examples/mysql/oob-drift/kustomization.yaml b/examples/mysql/oob-drift/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/oob-drift/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/parameter-mutation/README.md b/examples/mysql/parameter-mutation/README.md new file mode 100644 index 0000000..3270445 --- /dev/null +++ b/examples/mysql/parameter-mutation/README.md @@ -0,0 +1,22 @@ +# mysql / parameter-mutation + +**Scenario:** SPEC.md §End-to-end coverage, parameter validation, +for the `mysql` driver. Unlike bucket parameters, `characterSet` +and `collation` are set at creation and immutable: changing a +database's default does not convert its tables, so the driver +refuses to pretend it did. + +The admission webhook refuses: + +- a changed `characterSet`, or a removed `collation` (immutable); +- an unknown parameter key; +- `characterSet: utf8` on a new `Buckety` (ambiguous: utf8mb3 now, + utf8mb4 later); +- a `Buckety` whose resolved name lacks the backend's `namePrefix`. + +`BucketyAccess` parameters (the mysql driver accepts none) are +refused by the reconciler: `Ready=False`, reason +`InvalidParameters`, no Secret. + +The database keeps the character set and collation it was created +with throughout. diff --git a/examples/mysql/parameter-mutation/assert.sh b/examples/mysql/parameter-mutation/assert.sh new file mode 100755 index 0000000..65f86c0 --- /dev/null +++ b/examples/mysql/parameter-mutation/assert.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/shape 120s +db="$(secret_value shape-db database)" +[[ "$(mysql_root "SELECT CONCAT(DEFAULT_CHARACTER_SET_NAME, ' ', DEFAULT_COLLATION_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == "utf8mb4 utf8mb4_unicode_ci" ]] \ + || fail "database $db was not created with the requested character set and collation" + +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +# expect_refused +expect_refused() { + local what="$1" regex="$2"; shift 2 + log "expecting admission to refuse: $what" + if "$@" 2>"$err"; then + fail "admission accepted $what" + fi + grep -qE "$regex" "$err" || fail "refusal of $what should match /$regex/: $(cat "$err")" +} + +expect_refused "a characterSet change (immutable)" "immutable" \ + kc patch buckety/shape --type=merge -p '{"spec":{"parameters":{"characterSet":"latin1"}}}' +expect_refused "removing collation (immutable)" "immutable" \ + kc patch buckety/shape --type=json -p '[{"op":"remove","path":"/spec/parameters/collation"}]' +expect_refused "an unknown parameter" "unknownKey" \ + kc patch buckety/shape --type=merge -p '{"spec":{"parameters":{"unknownKey":"x"}}}' + +# New resources: the ambiguous utf8, and a name outside namePrefix. +expect_refused "characterSet utf8" "ambiguous" kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape-utf8 +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8 +YAML +expect_refused "a database name outside namePrefix" "namePrefix" kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape-noprefix +spec: + backend: mysql +YAML +# BucketyAccess parameters are checked by the reconciler, which can +# resolve the driver (admission may run before the Buckety exists). +kc apply -f - <<'YAML' +apiVersion: buckety.yolean.se/v1alpha1 +kind: BucketyAccess +metadata: + name: shape-params +spec: + bucketyRef: + name: shape + credentialsSecretName: shape-params + parameters: + host: "%" +YAML +wait_ready_reason bucketyaccess/shape-params InvalidParameters 60 +if kc get secret/shape-params >/dev/null 2>&1; then + fail "a Secret was minted for an access with parameters" +fi +kc delete bucketyaccess/shape-params --wait=true --timeout=60s + +# Nothing was changed on the server by the refused updates. +[[ "$(mysql_root "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$db'")" == utf8mb4 ]] \ + || fail "database $db character set changed" +wait_ready buckety/shape 30s + +log "parameter-mutation PASS" diff --git a/examples/mysql/parameter-mutation/buckety.yaml b/examples/mysql/parameter-mutation/buckety.yaml new file mode 100644 index 0000000..c3e3236 --- /dev/null +++ b/examples/mysql/parameter-mutation/buckety.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: shape +spec: + backend: mysql + name: "b_${namespace}_${name}" + parameters: + characterSet: utf8mb4 + collation: utf8mb4_unicode_ci + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: shape-db diff --git a/examples/mysql/parameter-mutation/kustomization.yaml b/examples/mysql/parameter-mutation/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/parameter-mutation/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/retention-policy/README.md b/examples/mysql/retention-policy/README.md new file mode 100644 index 0000000..1a2878f --- /dev/null +++ b/examples/mysql/retention-policy/README.md @@ -0,0 +1,12 @@ +# mysql / retention-policy + +**Scenario:** SPEC.md §End-to-end coverage, retention, for the +`mysql` driver. + +- `keep-me` (`Retain`): deleting the `Buckety` keeps the database + and its rows; its user is dropped with the implicit access, so a + retained database keeps no credentials. +- `drop-me` (`Delete`): the database is dropped with its tables, + and its user with it. + +assert.sh drops the retained database at the end. diff --git a/examples/mysql/retention-policy/assert.sh b/examples/mysql/retention-policy/assert.sh new file mode 100755 index 0000000..5e65851 --- /dev/null +++ b/examples/mysql/retention-policy/assert.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +wait_ready buckety/keep-me 120s +wait_ready buckety/drop-me 120s +keep_db="$(secret_value keep-me-db database)" +drop_db="$(secret_value drop-me-db database)" +keep_user="$(secret_value keep-me-db username)" +drop_user="$(secret_value drop-me-db username)" +for s in keep-me-db drop-me-db; do + mysql_login "$s" "CREATE TABLE t (id INT PRIMARY KEY); INSERT INTO t VALUES (1)" \ + || fail "$s: cannot write" +done + +user_count() { mysql_root "SELECT COUNT(*) FROM mysql.user WHERE User = '$1'"; } + +# Retain: the database and its data survive; the user does not. +log "deleting Buckety/keep-me (Retain)" +kc delete buckety/keep-me --wait=true --timeout=90s +resource_absent secret/keep-me-db 30s +mysql_database_exists "$keep_db" || fail "retained database $keep_db is gone" +[[ "$(mysql_root "SELECT COUNT(*) FROM \`$keep_db\`.t")" == 1 ]] \ + || fail "retained database $keep_db lost its rows" +[[ "$(user_count "$keep_user")" == 0 ]] \ + || fail "user $keep_user survived its access; a retained database keeps no credentials" + +# Delete: the database goes, with its tables, and the user. +log "deleting Buckety/drop-me (Delete)" +kc delete buckety/drop-me --wait=true --timeout=90s +resource_absent secret/drop-me-db 30s +if mysql_database_exists "$drop_db"; then + fail "database $drop_db still present after retentionPolicy=Delete" +fi +[[ "$(user_count "$drop_user")" == 0 ]] || fail "user $drop_user survived its access" + +# The retained database is this scenario's to clean up. +mysql_root "DROP DATABASE \`$keep_db\`" + +log "retention-policy PASS" diff --git a/examples/mysql/retention-policy/buckety.yaml b/examples/mysql/retention-policy/buckety.yaml new file mode 100644 index 0000000..6896e7d --- /dev/null +++ b/examples/mysql/retention-policy/buckety.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: keep-me +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Retain + defaultAccess: + role: ReadWrite + credentialsSecretName: keep-me-db +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: drop-me +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: drop-me-db diff --git a/examples/mysql/retention-policy/kustomization.yaml b/examples/mysql/retention-policy/kustomization.yaml new file mode 100644 index 0000000..86932e6 --- /dev/null +++ b/examples/mysql/retention-policy/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- buckety.yaml diff --git a/examples/mysql/scaled-to-zero/README.md b/examples/mysql/scaled-to-zero/README.md new file mode 100644 index 0000000..e048aa8 --- /dev/null +++ b/examples/mysql/scaled-to-zero/README.md @@ -0,0 +1,6 @@ +# mysql operator scaled to zero + +SPEC scenario 5 for the mysql driver: after the Secret is minted +the controller is scaled to zero, and a consumer Job still writes +and reads a row using only the Secret. The operator is off the +data path. diff --git a/examples/mysql/scaled-to-zero/assert.sh b/examples/mysql/scaled-to-zero/assert.sh new file mode 100755 index 0000000..1303230 --- /dev/null +++ b/examples/mysql/scaled-to-zero/assert.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +. "${E2E_LIB:-$(cd "$(dirname "$0")/../../../test/e2e" && pwd)}/lib.sh" + +# Body in test/e2e/lib.sh: controller behaviour, identical for every driver. +scaled_to_zero_scenario dial-tone dial-tone-db "$(dirname "$0")/consumer-job.yaml" dial-tone-roundtrip host port database username password jdbcUrl url diff --git a/examples/mysql/scaled-to-zero/buckety.yaml b/examples/mysql/scaled-to-zero/buckety.yaml new file mode 100644 index 0000000..fdfe0e2 --- /dev/null +++ b/examples/mysql/scaled-to-zero/buckety.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Yolean/buckety-controller/main/schema/buckety-mysql.schema.json +apiVersion: buckety.yolean.se/v1alpha1 +kind: Buckety +metadata: + name: dial-tone +spec: + backend: mysql + name: "b_${namespace}_${name}" + retentionPolicy: Delete + defaultAccess: + role: ReadWrite + credentialsSecretName: dial-tone-db diff --git a/examples/mysql/scaled-to-zero/consumer-job.yaml b/examples/mysql/scaled-to-zero/consumer-job.yaml new file mode 100644 index 0000000..8b1c5ef --- /dev/null +++ b/examples/mysql/scaled-to-zero/consumer-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: dial-tone-roundtrip +spec: + backoffLimit: 2 + template: + spec: + restartPolicy: Never + containers: + - name: roundtrip + image: ghcr.io/yolean/mariadb:10.11.19-jammy@sha256:7f22313fc130a377a44999965bcb0a08dd5b21e8502824c1b864f792f9bc66ab + env: + - name: DB_HOST + valueFrom: { secretKeyRef: { name: dial-tone-db, key: host } } + - name: DB_PORT + valueFrom: { secretKeyRef: { name: dial-tone-db, key: port } } + - name: DB_NAME + valueFrom: { secretKeyRef: { name: dial-tone-db, key: database } } + - name: DB_USER + valueFrom: { secretKeyRef: { name: dial-tone-db, key: username } } + # The client reads MYSQL_PWD, keeping the password off the + # command line. + - name: MYSQL_PWD + valueFrom: { secretKeyRef: { name: dial-tone-db, key: password } } + command: [bash, -ec] + args: + - | + sql() { mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --batch --skip-column-names "$DB_NAME" -e "$1"; } + sql "CREATE TABLE IF NOT EXISTS probe (id INT PRIMARY KEY, msg VARCHAR(40))" + sql "ALTER TABLE probe ADD COLUMN IF NOT EXISTS at TIMESTAMP NULL" + sql "REPLACE INTO probe (id, msg) VALUES (1, 'hello-buckety')" + [ "$(sql 'SELECT msg FROM probe WHERE id = 1')" = hello-buckety ] diff --git a/examples/mysql/scaled-to-zero/kustomization.yaml b/examples/mysql/scaled-to-zero/kustomization.yaml new file mode 100644 index 0000000..4c65358 --- /dev/null +++ b/examples/mysql/scaled-to-zero/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# The consumer Job is applied separately by assert.sh after the +# operator is scaled to zero; only the Buckety is provisioned +# upfront here. +resources: +- buckety.yaml diff --git a/test/e2e/lib.sh b/test/e2e/lib.sh index 2334fcc..f73a55b 100755 --- a/test/e2e/lib.sh +++ b/test/e2e/lib.sh @@ -6,7 +6,7 @@ # E2E_NAMESPACE The namespace the scenario was applied into. # E2E_KUBECONFIG kubeconfig path (falls back to $KUBECONFIG). # E2E_CONTROLLER_NS Namespace the buckety-controller runs in (default: buckety). -# E2E_IMPLEMENTATION versitygw | minio | redpanda (informational; some assertions branch on it). +# E2E_IMPLEMENTATION redpanda | versitygw | minio | fakegcs | mariadb (informational; some assertions branch on it). # # Downstream consumers writing their own platform e2e against # buckety-controller: do NOT source this file from outside this @@ -17,7 +17,8 @@ # secret_value, resource_absent # # Coupled to this repo's harness (assume namespace/service layout): -# kafka_topic_exists, s3_bucket_exists +# kafka_topic_exists, s3_bucket_exists, gcs_*, mysql_root, +# mysql_database_exists, mysql_login # # The coupled helpers kubectl-run pods in conventional namespaces # (redpanda/, buckety/) reading bootstrap addresses from harness @@ -471,21 +472,32 @@ backend_stickiness_scenario() { log "applying new Buckety against $backend-renamed" kc apply -f "$renamed_cr" wait_ready buckety/sticky-new 120s + # Gone before the original config returns: an access that holds a + # principal of its own (mysql users) cannot be revoked once its + # backend is unregistered, and would hold up namespace cleanup. + kc delete buckety/sticky-new --wait=true --timeout=90s # Deletion with retentionPolicy=Delete blocks while the backend # is missing: removing the finalizer would silently orphan the - # backend resource. + # backend resource. Where the implicit access holds a principal + # of its own (mysql users), it is that access's revoke that + # blocks first, and the Buckety waits for it. log "deleting sticky-orig while its backend is missing; expecting the deletion to block" kc delete buckety/sticky-orig --wait=false - local blocked="" + local blocked="" access_msg="" for _ in $(seq 1 20); do blocked="$(kc get buckety/sticky-orig \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true)" [[ "$blocked" == *"deletion blocked"* ]] && break + if [[ "$blocked" == *"to revoke before teardown"* ]]; then + access_msg="$(kc get bucketyaccess/sticky-orig \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' 2>/dev/null || true)" + [[ "$access_msg" == *"cannot revoke principal"* ]] && break + fi sleep 3 done - [[ "$blocked" == *"deletion blocked"* ]] \ - || fail "sticky-orig deletion did not surface 'deletion blocked' (Ready message: '$blocked')" + [[ "$blocked" == *"deletion blocked"* || "$access_msg" == *"cannot revoke principal"* ]] \ + || fail "sticky-orig deletion did not surface 'deletion blocked' (Ready message: '$blocked', access: '$access_msg')" kc get buckety/sticky-orig >/dev/null 2>&1 \ || fail "sticky-orig disappeared while its backend was missing; the backend resource would be orphaned" @@ -631,3 +643,47 @@ scaled_to_zero_scenario() { log "scaled-to-zero PASS" } + +# ---- mysql helpers (coupled camp: assume the e2e MariaDB, a +# StatefulSet mariadb in namespace mysql whose root connects only +# on the local socket, as in kubernetes-mysql-cluster) ---- + +# mysql_root +# Runs SQL as root inside the MariaDB pod; prints rows +# tab-separated without column names. +mysql_root() { + kcg -n "${E2E_MYSQL_NAMESPACE:-mysql}" exec "${E2E_MYSQL_POD:-mariadb-0}" -c mariadb -- \ + mariadb -uroot --batch --skip-column-names -e "$1" +} + +# mysql_database_exists +mysql_database_exists() { + [[ "$(mysql_root "SELECT COUNT(*) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$1'")" == 1 ]] +} + +# mysql_login +# Connects over TCP with a Secret's username, password and +# database and runs SQL. The password travels on stdin, so it is +# in no command line. Caller checks the exit code. +mysql_login() { + local secret="$1" sql="$2" user db + user="$(secret_value "$secret" username)" + db="$(secret_value "$secret" database)" + secret_value "$secret" password \ + | kcg -n "${E2E_MYSQL_NAMESPACE:-mysql}" exec -i "${E2E_MYSQL_POD:-mariadb-0}" -c mariadb -- \ + sh -c 'IFS= read -r MYSQL_PWD || [ -n "$MYSQL_PWD" ]; export MYSQL_PWD; exec mariadb --protocol=tcp -h127.0.0.1 -u"$1" --batch --skip-column-names "$2" -e "$3"' \ + sh "$user" "$db" "$sql" +} + +# wait_until +# Polls the command every 3s until it succeeds. +wait_until() { + local timeout="$1" what="$2"; shift 2 + log "waiting up to ${timeout}s for: $what" + local deadline=$(( $(date +%s) + timeout )) + while (( $(date +%s) < deadline )); do + "$@" >/dev/null 2>&1 && return 0 + sleep 3 + done + fail "not within ${timeout}s: $what" +} From 6b14c505e8314b3a9b6b5be281109c0f5672da9b Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 07:07:44 +0200 Subject: [PATCH 13/15] release: pin the mysql driver build Co-Authored-By: Claude Opus 5.5 --- deploy/kustomize/release/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/kustomize/release/kustomization.yaml b/deploy/kustomize/release/kustomization.yaml index 370a865..215d187 100644 --- a/deploy/kustomize/release/kustomization.yaml +++ b/deploy/kustomize/release/kustomization.yaml @@ -7,7 +7,7 @@ kind: Kustomization resources: - ../base images: -- digest: sha256:19e3172abce9c8be53ea19fbcd60ffc3ec636b11c3457af56e829d063e877efe +- digest: sha256:d7c3d65d470e7c841db4ebd64213bdbca2db83fc34221ff95824b03b66364411 name: ghcr.io/yolean/buckety-controller newName: ghcr.io/yolean/buckety-controller - newTag: 20260904T064507Z + newTag: 20261002T050712Z From 15c16c5f6c508be7b94ddca0f3bb1226fdfe78ba Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 07:10:55 +0200 Subject: [PATCH 14/15] go: toolchain go1.27.1, and the release pin built with it go.mod requires go 1.27.0 and names toolchain go1.27.1 explicitly (a toolchain line equal to the go line is dropped by go mod tidy). CI's setup-go reads go-version-file: go.mod and installs the toolchain directive's version, so CI rebuilds the pinned image with the same compiler as scripts/bump-release.sh, which refuses any other. Co-Authored-By: Claude Opus 5.5 --- deploy/kustomize/release/kustomization.yaml | 4 ++-- go.mod | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/kustomize/release/kustomization.yaml b/deploy/kustomize/release/kustomization.yaml index 215d187..2b7dce0 100644 --- a/deploy/kustomize/release/kustomization.yaml +++ b/deploy/kustomize/release/kustomization.yaml @@ -7,7 +7,7 @@ kind: Kustomization resources: - ../base images: -- digest: sha256:d7c3d65d470e7c841db4ebd64213bdbca2db83fc34221ff95824b03b66364411 +- digest: sha256:8c3c493780935b8084e2241722f60605464f2cf3d70f83a9c68c2edd43f222d6 name: ghcr.io/yolean/buckety-controller newName: ghcr.io/yolean/buckety-controller - newTag: 20261002T050712Z + newTag: 20261002T051036Z diff --git a/go.mod b/go.mod index ecd80d6..1e38991 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/Yolean/buckety-controller -go 1.26.1 +go 1.27.0 -toolchain go1.26.5 +toolchain go1.27.1 require ( cloud.google.com/go/storage v1.63.1 From bffe5962e222ebda0e45c37473c53e873bd86541 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 2 Oct 2026 07:29:12 +0200 Subject: [PATCH 15/15] e2e: MinIO from Yolean's mirror docker.io/minio/minio no longer serves anonymous pulls, so the backing service never became ready and CI failed before any scenario ran. ghcr.io/yolean/minio mirrors the releases; its newest is RELEASE.2025-07-18T21-56-31Z, pinned by digest like the other backing-service images. All s3 scenarios pass against it. Co-Authored-By: Claude Opus 5.5 --- test/e2e/backing-services/minio.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/backing-services/minio.yaml b/test/e2e/backing-services/minio.yaml index 2a459f9..320588b 100644 --- a/test/e2e/backing-services/minio.yaml +++ b/test/e2e/backing-services/minio.yaml @@ -47,7 +47,9 @@ spec: spec: containers: - name: minio - image: docker.io/minio/minio:RELEASE.2025-09-07T16-13-09Z + # Yolean's mirror: docker.io/minio/minio no longer serves + # anonymous pulls. Its newest release, pinned like the rest. + image: ghcr.io/yolean/minio:RELEASE.2025-07-18T21-56-31Z@sha256:da0fe26595711d0fb93dd28e24552520f68897195f4a23f5a17ffa9924ec3fac env: - name: MINIO_ROOT_USER valueFrom: