diff --git a/cmd/fleet/prepare.go b/cmd/fleet/prepare.go index 71cb85170df..e3d7a880217 100644 --- a/cmd/fleet/prepare.go +++ b/cmd/fleet/prepare.go @@ -138,16 +138,6 @@ To setup Fleet infrastructure, use one of the available commands. } // <<< OPENFRAME(host-assignments) - // >>> OPENFRAME(mysql-multitenancy): grant the Debezium account its replication - // privileges from the same run that owns the schema — openframe/docs/mysql-multitenancy-feature.md. - if fleet.IsOpenframeMultitenancy() { - if err := ds.EnsureOpenframeCdcPrivileges(cmd.Context(), config.Mysql.Username); err != nil { - initFatal(err, "granting openframe cdc privileges") - } - fmt.Printf("OpenFrame CDC privileges granted to %q.\n", config.Mysql.Username) - } - // <<< OPENFRAME(mysql-multitenancy) - fmt.Println("Migrations completed.") }, } diff --git a/openframe/docs/mysql-multitenancy-feature.md b/openframe/docs/mysql-multitenancy-feature.md index c19e5f9e7b9..3bf99f0b0c5 100644 --- a/openframe/docs/mysql-multitenancy-feature.md +++ b/openframe/docs/mysql-multitenancy-feature.md @@ -127,41 +127,12 @@ without touching Fleet's API. Platform side of this pipeline: shared connector r (the MeshCentral pattern — per-event tenant resolution, gated by `openframe.fleet.multi-tenancy.enabled`). -## CDC privileges (`fleet prepare db`) - -Debezium streams as **Fleet's own database user** — one account, shared by the Fleet servers and -the connector — so the only provisioning needed is the binlog grant. -`EnsureOpenframeCdcPrivileges` (`server/datastore/mysql/openframe.go`) is called from -`cmd/fleet/prepare.go` after the OpenFrame migrations and issues a single idempotent statement: -`GRANT REPLICATION CLIENT, REPLICATION SLAVE ON *.* TO ''@'%'`. - -This replaces the standalone privileged Job that used to do it out of band -(`openframe-saas-tenant/manifests/tenant/templates/mysql-fleetmdm/init-privileges-job.yaml`): the -grant now ships and versions with the schema it serves, and a failure fails the migration loudly -instead of leaving CDC silently producing nothing. - -**`RELOAD`/`FLUSH_TABLES` are deliberately not granted**, though that Job did grant them: they -exist only to satisfy Debezium's default snapshot locking, Cloud SQL cannot grant them at all, and -every environment therefore runs the connector with `snapshot.locking.mode=none`. `SELECT` is not -granted either — the app user already has it. - -The account is the migration's own connection user (`FLEET_MYSQL_USERNAME`) — on the shared DB, -Fleet and the connector authenticate as one MySQL user, so the migration self-grants and nothing -extra has to be configured. The step is gated on the master flag, like the `GET_LOCK` guard above; -flag off ⇒ no statement is issued. A per-tenant deployment (flag off, own MySQL, own connector) -therefore keeps granting via its chart's `init-privileges-job`. - -Prerequisite: that user must be able to grant these privileges to itself. On Cloud SQL any -API-created user qualifies — `cloudsqlsuperuser` carries both WITH GRANT OPTION — so no extra -credentials are needed. Confirm with `SHOW GRANTS FOR CURRENT_USER;` on stage before rollout. - ## Helm / config wiring (`charts/fleet/`) `values.yaml` adds `fleet.openframe.multiTenancy` (`enabled: false` default; `tenantUuid` / `existingConfigMap`+`tenantUuidKey` / `teamId`). `deployment.yaml` injects `FLEET_OPENFRAME_MULTI_TENANCY_ENABLED` (+ optional `FLEET_OPENFRAME_TENANT_UUID` / -`FLEET_OPENFRAME_TEAM_ID`); `job-migration.yaml` gets the flag too, which now drives both the -`GET_LOCK` guard and the CDC grant — no additional wiring. +`FLEET_OPENFRAME_TEAM_ID`); `job-migration.yaml` gets the flag too (so the `GET_LOCK` guard engages). ## Backward compatibility @@ -177,11 +148,8 @@ flag-on-pinned (team auto-created + secret seeded, `team_id=1`), and flag-on-sha MySQL-backed (`MYSQL_TEST=1`), all in `*_openframe_test.go`: enrollment isolation, host-identity per-team, host by-id/list/identifier fences, policy/query CRUD + by-id + GitOps, enroll-secret fence, host-assignment fence, live-query target fence, teams read fence, app-config isolation, -`EnsureOpenframeTeamID` (incl. secret seeding), delete-global-policies pin, migration pipeline, -`EnsureOpenframeCdcPrivileges` (grant set, idempotence, rejected input). -Flag-parsing / mode-precedence unit tests in `server/fleet/openframe_test.go`; the CDC grant's -account-literal escaping has a database-free unit test (`TestOpenframeQuoting`) that runs on every -`go test`. Middleware tests in +`EnsureOpenframeTeamID` (incl. secret seeding), delete-global-policies pin, migration pipeline. +Flag-parsing / mode-precedence unit tests in `server/fleet/openframe_test.go`. Middleware tests in `server/service/openframe_middleware_test.go`. Harness: `make openframe-verify` (add `MYSQL_TEST=1` + Docker for the deep tier). diff --git a/server/datastore/mysql/openframe.go b/server/datastore/mysql/openframe.go index 70c0d72f14d..c5ad7901d1d 100644 --- a/server/datastore/mysql/openframe.go +++ b/server/datastore/mysql/openframe.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "strings" "time" "github.com/fleetdm/fleet/v4/server" @@ -220,24 +219,3 @@ func (ds *Datastore) AcquireOpenframeMigrationLock(ctx context.Context, timeout _ = conn.Close() }, nil } - -const openframeAnyHostLiteral = "'%'" - -// EnsureOpenframeCdcPrivileges grants Fleet's own database user — the account Debezium also streams -// as — the two privileges it needs to read the binary log. -func (ds *Datastore) EnsureOpenframeCdcPrivileges(ctx context.Context, username string) error { - if username == "" { - return ctxerr.New(ctx, "openframe cdc username must not be empty") - } - - account := openframeQuoteString(username) + "@" + openframeAnyHostLiteral - //nolint:gosec // G201/G202: MySQL accepts no bind parameters for account names or privilege targets; - if _, err := ds.writer(ctx).ExecContext(ctx, "GRANT REPLICATION CLIENT, REPLICATION SLAVE ON *.* TO "+account); err != nil { - return ctxerr.Wrapf(ctx, err, "granting openframe cdc privileges to %q", username) - } - return nil -} - -func openframeQuoteString(s string) string { - return "'" + strings.NewReplacer("'", "''", `\`, `\\`).Replace(s) + "'" -} diff --git a/server/datastore/mysql/openframe_cdc_user_test.go b/server/datastore/mysql/openframe_cdc_user_test.go deleted file mode 100644 index 6a7ddd8eeac..00000000000 --- a/server/datastore/mysql/openframe_cdc_user_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package mysql - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -// TestOpenframeEnsureCdcPrivileges covers the grant `fleet prepare db` issues for Debezium: the -// account ends up with exactly the two replication privileges the connector needs and none of the -// ones managed MySQL cannot provide, and re-running is a no-op. This is the in-code replacement -// for the standalone privileged Job, so the grant set is the assertion that matters. -// Runs only under MYSQL_TEST=1. -func TestOpenframeEnsureCdcPrivileges(t *testing.T) { - ds := CreateMySQLDS(t) - ctx := context.Background() - - // Stand in for Fleet's own app user: accounts are server-global, so create and drop it here. - const username = "openframe_cdc_test" - _, err := ds.writer(ctx).ExecContext(ctx, "CREATE USER IF NOT EXISTS 'openframe_cdc_test'@'%' IDENTIFIED BY 'pw'") - require.NoError(t, err) - t.Cleanup(func() { - _, _ = ds.writer(ctx).ExecContext(ctx, "DROP USER IF EXISTS 'openframe_cdc_test'@'%'") - }) - - require.NoError(t, ds.EnsureOpenframeCdcPrivileges(ctx, username)) - - grants := showGrantsFor(t, ds, username) - require.Contains(t, grants, "REPLICATION CLIENT", "Debezium needs the binlog position") - require.Contains(t, grants, "REPLICATION SLAVE", "Debezium needs to stream the binlog") - // Cloud SQL grants neither, so the connector runs with snapshot.locking.mode=none everywhere - // and must never depend on them being present. - require.NotContains(t, grants, "RELOAD") - require.NotContains(t, grants, "FLUSH_TABLES") - require.NotContains(t, grants, "ALL PRIVILEGES") - - // Idempotent: GRANT re-issues rather than accumulates, so a second run is a no-op. - require.NoError(t, ds.EnsureOpenframeCdcPrivileges(ctx, username)) - require.Equal(t, grants, showGrantsFor(t, ds, username)) -} - -// TestOpenframeEnsureCdcPrivilegesRejectsInvalidInput verifies an account name cannot break out of -// the GRANT. The name is operator config (Fleet's own MySQL user), so it is escaped rather than -// constrained to a character set — any legal account name has to work. A hostile name therefore -// reaches MySQL as one literal, matches no account, and fails without granting anything. -func TestOpenframeEnsureCdcPrivilegesRejectsInvalidInput(t *testing.T) { - ds := CreateMySQLDS(t) - ctx := context.Background() - - require.Error(t, ds.EnsureOpenframeCdcPrivileges(ctx, ""), - "an empty username must be refused before a statement is built") - - for name, username := range map[string]string{ - "quote": `bad'user`, - "backslash": `bad\user`, - "space": "bad user", - "statement breakout": `x'@'%'; GRANT ALL PRIVILEGES ON *.* TO 'x'@'%`, - "too long": strings.Repeat("u", 33), - } { - t.Run(name, func(t *testing.T) { - require.Error(t, ds.EnsureOpenframeCdcPrivileges(ctx, username)) - }) - } - - // The decisive assertion: no account the injected string tried to name exists, so nothing was - // granted to one. (MySQL 8 will not create an account via GRANT, so a name that matches nothing - // fails loudly.) - var accounts int - require.NoError(t, ds.writer(ctx).GetContext(ctx, &accounts, - "SELECT COUNT(*) FROM mysql.user WHERE user LIKE 'bad%' OR user LIKE 'x%'")) - require.Zero(t, accounts) -} - -// TestOpenframeQuoting pins the escaping the CDC grant depends on. It needs no database: this -// helper is the only thing standing between a values-supplied account name and a statement MySQL -// parses, so it is worth checking on every run, not only under MYSQL_TEST=1. -func TestOpenframeQuoting(t *testing.T) { - for _, tc := range []struct { - in, want string - }{ - {"", "''"}, - {"fleet", "'fleet'"}, - {`p'w`, `'p''w'`}, - {`p\w`, `'p\\w'`}, - // A value ending in a backslash is the classic break-out: unescaped it would consume the - // closing quote and let the rest be parsed as SQL. - {`pw\`, `'pw\\'`}, - {`' OR 1=1 -- `, `''' OR 1=1 -- '`}, - } { - require.Equal(t, tc.want, openframeQuoteString(tc.in), "quoting %q", tc.in) - } -} - -func showGrantsFor(t *testing.T, ds *Datastore, username string) string { - t.Helper() - var grants []string - require.NoError(t, ds.writer(t.Context()).SelectContext(t.Context(), &grants, - fmt.Sprintf("SHOW GRANTS FOR %s@'%%'", openframeQuoteString(username)))) - return strings.Join(grants, "\n") -}