From 4d438d0ad0b0dec51788d666d2a824b8e7731dec Mon Sep 17 00:00:00 2001 From: Oleksandr Didukh Date: Tue, 21 Jul 2026 14:42:18 +0200 Subject: [PATCH 1/4] feat(openframe): shared-DB multi-tenancy (tenant = Fleet team) Let one Fleet server + one shared MySQL serve many tenants, with tenant = Fleet team (team_id) and row-level isolation enforced at the datastore. All behavior is behind the master flag FLEET_OPENFRAME_MULTI_TENANCY_ENABLED (platform property openframe.fleet.multi-tenancy.enabled); with the flag off Fleet behaves exactly as the fork did before, the schema change aside. Identity & pin (server/fleet/openframe.go, server/datastore/mysql/openframe.go): - OpenframeTeamID(ctx): ctx pin -> UUID-resolved process pin -> env fallback (env inert unless the flag is on, so a stray FLEET_OPENFRAME_TEAM_ID cannot activate fences). Master-flag + mode helpers, unit-tested. - EnsureOpenframeTeamID: UUID->team_id bridge (teams.openframe_tenant_uuid), resolve-or-create, race-safe; a newly created team is seeded with one random team-scoped enroll secret in the same tx so a fresh tenant can enroll. Migrations (idempotent): teams.openframe_tenant_uuid bridge; hosts + labels identity/name unique per team via a generated column IFNULL(team_id,0) -- flag off collapses to the original global uniqueness bit-for-bit. Datastore fences (no-op when unpinned): hosts (list/count/by-id/identifier/ delete/enroll-match), policies & queries (list/by-id/CRUD/GitOps), enroll secrets (get/apply/verify), live-query targets, host-assignments, teams reads, team-keyed app_config cache. Per-request pinning: WithOpenframeTenant middleware (X-Tenant-Id, fail closed, agent paths exempt); agent host pins (openframePinHostTeam) and enrollment pins from secret.TeamID. Ops: GET_LOCK('openframe_fleet_migrations') serializes prepare-db across clusters on the shared DB; Helm flag/env wiring in charts/fleet. Tests: *_openframe_test.go (MYSQL_TEST=1) across enrollment, host/policy/query/ label/target/team/app-config fences, EnsureOpenframeTeamID + secret seeding; flag-parsing unit tests; middleware tests. Doc: openframe/docs/mysql-multitenancy-feature.md --- CLAUDE.md | 3 +- charts/fleet/templates/deployment.yaml | 21 ++ charts/fleet/templates/job-migration.yaml | 6 + charts/fleet/values.yaml | 9 + cmd/fleet/prepare.go | 21 ++ cmd/fleet/serve.go | 37 ++ openframe/docs/helm-chart.md | 40 +- openframe/docs/mysql-multitenancy-feature.md | 146 ++++++++ openframe/scripts/verify.sh | 3 +- server/datastore/cached_mysql/cached_mysql.go | 21 +- .../cached_mysql/cached_mysql_test.go | 46 +++ server/datastore/mysql/app_configs.go | 77 +++- .../mysql/app_configs_openframe_test.go | 68 ++++ .../mysql/enroll_secrets_openframe_test.go | 64 ++++ .../mysql/host_assignments_openframe_test.go | 86 +++++ server/datastore/mysql/hosts.go | 144 +++++++- .../datastore/mysql/hosts_openframe_test.go | 344 ++++++++++++++++++ server/datastore/mysql/labels.go | 101 ++--- .../datastore/mysql/labels_openframe_test.go | 97 +++++ ...260620000001_ScopeLabelUniqueNameToTeam.go | 85 +++++ ...626000001_ScopeHostIdentityUniqueToTeam.go | 100 +++++ ...60629000001_AddTeamsOpenframeTenantUUID.go | 58 +++ .../mysql/migrations/openframe/migration.go | 31 ++ .../mysql/migrations_openframe_test.go | 220 +++++++++++ server/datastore/mysql/openframe.go | 221 +++++++++++ server/datastore/mysql/policies.go | 165 ++++++++- .../mysql/policies_queries_openframe_test.go | 261 +++++++++++++ server/datastore/mysql/queries.go | 97 ++++- server/datastore/mysql/targets.go | 33 +- .../datastore/mysql/targets_openframe_test.go | 67 ++++ server/datastore/mysql/teams.go | 17 +- .../datastore/mysql/teams_openframe_test.go | 95 +++++ .../mysql/teams_transfer_openframe_test.go | 101 +++++ server/fleet/labels.go | 5 + server/fleet/openframe.go | 174 ++++++++- server/fleet/openframe_test.go | 185 ++++++++++ server/service/endpoint_middleware.go | 24 ++ server/service/global_policies.go | 9 + .../service/global_policies_openframe_test.go | 71 ++++ server/service/openframe_middleware.go | 104 ++++++ server/service/openframe_middleware_test.go | 177 +++++++++ server/service/orbit.go | 10 + server/service/osquery.go | 10 + server/service/osquery_header_auth.go | 16 + 44 files changed, 3583 insertions(+), 87 deletions(-) create mode 100644 openframe/docs/mysql-multitenancy-feature.md create mode 100644 server/datastore/mysql/app_configs_openframe_test.go create mode 100644 server/datastore/mysql/enroll_secrets_openframe_test.go create mode 100644 server/datastore/mysql/host_assignments_openframe_test.go create mode 100644 server/datastore/mysql/hosts_openframe_test.go create mode 100644 server/datastore/mysql/labels_openframe_test.go create mode 100644 server/datastore/mysql/migrations/openframe/20260620000001_ScopeLabelUniqueNameToTeam.go create mode 100644 server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go create mode 100644 server/datastore/mysql/migrations/openframe/20260629000001_AddTeamsOpenframeTenantUUID.go create mode 100644 server/datastore/mysql/openframe.go create mode 100644 server/datastore/mysql/policies_queries_openframe_test.go create mode 100644 server/datastore/mysql/targets_openframe_test.go create mode 100644 server/datastore/mysql/teams_openframe_test.go create mode 100644 server/datastore/mysql/teams_transfer_openframe_test.go create mode 100644 server/fleet/openframe_test.go create mode 100644 server/service/global_policies_openframe_test.go create mode 100644 server/service/openframe_middleware.go create mode 100644 server/service/openframe_middleware_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0a39c227b45..a5b41919982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,8 @@ Every fork edit inside a shared upstream file is wrapped in sentinel comments: Find them all: `grep -rn "OPENFRAME(" --include='*.go' --include='*.yaml' --include='*.tpl' .` Net-new fork-only code lives under `openframe/`, `server/service/openframe/`, `server/datastore/mysql/migrations/openframe/`, `server/datastore/redis/keyprefix.go`, -and `server/fleet/openframe.go`. +`server/fleet/openframe.go`, `server/datastore/mysql/openframe.go`, and +`server/service/openframe_middleware.go`. ## Syncing from upstream (the important workflow) diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml index aab25c11f3c..ee73f66f329 100644 --- a/charts/fleet/templates/deployment.yaml +++ b/charts/fleet/templates/deployment.yaml @@ -209,6 +209,27 @@ spec: - name: FLEET_OPENFRAME_MODE value: {{ .Values.fleet.setup.openframeMode | quote }} # <<< OPENFRAME(helm) + # >>> OPENFRAME(mysql-multitenancy): shared-DB multitenancy feature envs. + # Master switch (maps openframe.fleet.multi-tenancy.enabled). "false" ⇒ pre-feature + # fork behavior. "true" + tenant UUID ⇒ pinned mode (one Fleet per tenant); + # "true" + no tenant UUID ⇒ shared per-request mode (one Fleet per cluster, fail closed). + - name: FLEET_OPENFRAME_MULTI_TENANCY_ENABLED + value: {{ .Values.fleet.openframe.multiTenancy.enabled | quote }} + {{- if .Values.fleet.openframe.multiTenancy.existingConfigMap }} + - name: FLEET_OPENFRAME_TENANT_UUID + valueFrom: + configMapKeyRef: + name: {{ .Values.fleet.openframe.multiTenancy.existingConfigMap }} + key: {{ default "FLEET_OPENFRAME_TENANT_UUID" .Values.fleet.openframe.multiTenancy.tenantUuidKey }} + {{- else if .Values.fleet.openframe.multiTenancy.tenantUuid }} + - name: FLEET_OPENFRAME_TENANT_UUID + value: {{ .Values.fleet.openframe.multiTenancy.tenantUuid | quote }} + {{- end }} + {{- if .Values.fleet.openframe.multiTenancy.teamId }} + - name: FLEET_OPENFRAME_TEAM_ID + value: {{ .Values.fleet.openframe.multiTenancy.teamId | quote }} + {{- end }} + # <<< OPENFRAME(mysql-multitenancy) ## END FLEET SECTION ## BEGIN MYSQL SECTION # >>> OPENFRAME(helm): fork-externalized DB connection from ConfigMap/Secret refs — openframe/docs/helm-chart.md diff --git a/charts/fleet/templates/job-migration.yaml b/charts/fleet/templates/job-migration.yaml index e44b5f1c35a..fb45af75068 100644 --- a/charts/fleet/templates/job-migration.yaml +++ b/charts/fleet/templates/job-migration.yaml @@ -102,6 +102,12 @@ spec: - name: FLEET_SERVER_KEY value: "/secrets/tls/{{ .Values.fleet.tls.keySecretKey }}" {{- end }} + # >>> OPENFRAME(mysql-multitenancy): with multitenancy on, `fleet prepare db` + # serializes concurrent migration runs against the shared MySQL via a named lock + # (GET_LOCK) — the flag must reach the migration Job, not only the server. + - name: FLEET_OPENFRAME_MULTI_TENANCY_ENABLED + value: {{ .Values.fleet.openframe.multiTenancy.enabled | quote }} + # <<< OPENFRAME(mysql-multitenancy) ## END FLEET SECTION ## BEGIN MYSQL SECTION - name: FLEET_MYSQL_HOST diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index db8e3a10b49..c28db984824 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -111,6 +111,15 @@ fleet: existingSecret: "" # Name of a K8s Secret. If set, secretKeyValue is ignored. secretKeyKey: "FLEET_SETUP_ADMIN_PASSWORD" # Key name within the secret to read the value from. secretKeyValue: "fleet" # Plain text password (for dev/test only). Ignored if secret is set. + # >>> OPENFRAME(mysql-multitenancy): OpenFrame feature block. + openframe: + multiTenancy: + enabled: false + tenantUuid: "" # pinned mode only; static value (ignored if existingConfigMap is set) + existingConfigMap: "" # optional: read the tenant UUID from a ConfigMap instead + tenantUuidKey: "" # key within existingConfigMap (default FLEET_OPENFRAME_TENANT_UUID) + teamId: "" # escape hatch: direct FLEET_OPENFRAME_TEAM_ID pin (prefer tenantUuid) + # <<< OPENFRAME(mysql-multitenancy) mdm: windows: wstepIdentityCertKey: "" diff --git a/cmd/fleet/prepare.go b/cmd/fleet/prepare.go index 2c8dc70baa0..e3d7a880217 100644 --- a/cmd/fleet/prepare.go +++ b/cmd/fleet/prepare.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "time" "github.com/WatchBeam/clock" "github.com/fleetdm/fleet/v4/server/config" @@ -32,6 +33,13 @@ To setup Fleet infrastructure, use one of the available commands. // Whether to show table stats before and after the migration showTableStats := false + // >>> OPENFRAME(mysql-multitenancy): how long a `prepare db` run waits for a concurrent + // migration run against the same shared MySQL to finish before giving up. Generous because + // index builds on a large shared hosts table can take minutes; a K8s Job that fails here is + // retried by its backoff policy anyway. + const openframeMigrationLockWait = 15 * time.Minute + // <<< OPENFRAME(mysql-multitenancy) + dbCmd := &cobra.Command{ Use: "db", Short: "Given correct database configurations, prepare the databases for use", @@ -49,6 +57,19 @@ To setup Fleet infrastructure, use one of the available commands. initFatal(err, "creating db connection") } + // >>> OPENFRAME(mysql-multitenancy): on a shared MySQL, serialize `prepare db` + // runs across clusters/jobs/replicas with a named MySQL lock — Fleet's goose has + // no advisory lock, so concurrent runs race on DDL. Held on a dedicated session + // (auto-released if the job dies). Flag-off runs are untouched. + if fleet.IsOpenframeMultitenancy() { + release, err := ds.AcquireOpenframeMigrationLock(cmd.Context(), openframeMigrationLockWait) + if err != nil { + initFatal(err, "acquiring openframe migration lock") + } + defer release() + } + // <<< OPENFRAME(mysql-multitenancy) + status, err := ds.MigrationStatus(cmd.Context()) if err != nil { initFatal(err, "retrieving migration status") diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index aa6c92ee98e..adbfe32f073 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -157,6 +157,15 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev applyDevFlags(&config) } + // >>> OPENFRAME(mysql-multitenancy): validate the multitenancy configuration — with + // FLEET_OPENFRAME_MULTI_TENANCY_ENABLED on, a pinned process (tenant UUID/team id) and an + // unpinned shared-mode process are both valid, but a set-yet-unparsable team pin refuses to + // boot so a typo cannot silently change the isolation mode. + if err := fleet.ValidateOpenframeMultitenancy(); err != nil { + initFatal(err, "validating OpenFrame multitenancy configuration") + } + // <<< OPENFRAME(mysql-multitenancy) + license, err := initLicense(&config, devLicense, devExpiredLicense) if err != nil { initFatal( @@ -263,6 +272,26 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev os.Exit(1) } + // >>> OPENFRAME(mysql-multitenancy): pinned mode — resolve the Flamingo tenant UUID to its + // Fleet team id (create-if-absent via the teams.openframe_tenant_uuid bridge) and pin the + // process. The pin is then read by fleet.OpenframeTeamID everywhere the datastore fences scope + // by team. In shared mode there is no process pin: every request is pinned individually by the + // tenant middleware / host auth / enroll secret. Runs only under + // FLEET_OPENFRAME_MULTI_TENANCY_ENABLED; requires the openframe migrations to be applied (prepare db). + if fleet.IsOpenframeMultitenancy() { + if tenantUUID, ok := fleet.OpenframeTenantUUID(); ok { + teamID, err := mds.EnsureOpenframeTeamID(cmd.Context(), tenantUUID) + if err != nil { + initFatal(err, "resolving OpenFrame tenant team from FLEET_OPENFRAME_TENANT_UUID") + } + fleet.SetOpenframeTeamID(teamID) + logger.InfoContext(cmd.Context(), "OpenFrame multitenancy: pinned mode", "tenant_uuid", tenantUUID, "team_id", teamID) + } else if fleet.IsOpenframeSharedMode() { + logger.InfoContext(cmd.Context(), "OpenFrame multitenancy: shared per-request mode (no process pin)") + } + } + // <<< OPENFRAME(mysql-multitenancy) + if initializingDS, ok := ds.(initializer); ok { if err := initializingDS.Initialize(); err != nil { initFatal(err, "loading built in data") @@ -730,6 +759,14 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev } apiHandler = service.WithMDMSSOCallbackRedirect(svc, logger, apiHandler) + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin every control-plane API request + // to the tenant team named by the gateway-injected X-Tenant-Id header (fail closed for + // non-agent paths without it). Returns apiHandler unchanged unless the process runs in + // shared per-request mode. Must come after apiendpoints.Validate, which type-asserts the + // raw *mux.Router. + apiHandler = service.WithOpenframeTenant(mds, logger, apiHandler) + // <<< OPENFRAME(mysql-multitenancy) + if serveCSP { // Only injecting this if CSP is turned on since the default security headers add some overhead to each request apiHandler = endpointer.BrowserSecurityHeadersHandler(serveCSP, apiHandler) diff --git a/openframe/docs/helm-chart.md b/openframe/docs/helm-chart.md index 06bb3699310..2bd2739197c 100644 --- a/openframe/docs/helm-chart.md +++ b/openframe/docs/helm-chart.md @@ -36,6 +36,44 @@ assignments and the query-results TTL cleanup (see [architecture-host-assignments.md](architecture-host-assignments.md), [query-results-ttl-cleanup.md](query-results-ttl-cleanup.md)). +## MySQL-multitenancy feature envs (`mysql-multitenancy`) + +`values.yaml` exposes a `fleet.openframe.multiTenancy` block — the chart-side wiring of the +platform property `openframe.fleet.multi-tenancy.enabled` (see +[process-team-pin.md](process-team-pin.md) for the flag/mode semantics): + +```yaml +fleet: + openframe: + multiTenancy: + enabled: false # → FLEET_OPENFRAME_MULTI_TENANCY_ENABLED (deployment + migration Job) + tenantUuid: "" # pinned mode: static FLEET_OPENFRAME_TENANT_UUID + existingConfigMap: "" # pinned mode: read the UUID from a ConfigMap instead (wins over tenantUuid) + tenantUuidKey: "" # key within existingConfigMap (default FLEET_OPENFRAME_TENANT_UUID) + teamId: "" # escape hatch: direct FLEET_OPENFRAME_TEAM_ID pin (prefer tenantUuid) +``` + +Rendered env vars ([deployment.yaml](../../charts/fleet/templates/deployment.yaml)): +`FLEET_OPENFRAME_MULTI_TENANCY_ENABLED` is always emitted (`"false"` by default — pre-feature +fork behavior); `FLEET_OPENFRAME_TENANT_UUID` only when a source is configured (pinned mode); +neither pin ⇒ shared per-request mode. The **flag is also emitted into +[job-migration.yaml](../../charts/fleet/templates/job-migration.yaml)** so `fleet prepare db` +takes the `GET_LOCK` serialization on a shared MySQL. + +Downstream (openframe-saas-tenant) pinned-mode wiring can reuse the existing per-namespace +`tenant` ConfigMap, whose `TENANT_ID` key already holds the tenant UUID (it is the same key the +Redis prefix reads): + +```yaml +fleetmdm: + fleet: + openframe: + multiTenancy: + enabled: true + existingConfigMap: "tenant" + tenantUuidKey: "TENANT_ID" +``` + ## Externalized configuration (ConfigMaps + Secrets) Where upstream takes Redis/MySQL connection details as plain Helm values, the fork @@ -218,7 +256,7 @@ helm upgrade --install fleet oci://ghcr.io/flamingo-stack/fleetmdm/helm-charts/f | `charts/fleet/values.yaml` | OpenFrame mode, externalized DB/cache/setup config, `cache.keyPrefixKey`, `waitForMysql`, `additionalCAs`, `vulnProcessing`, `deploymentAnnotations` | | `charts/fleet/templates/configmap.yaml` | **New** — generated DB/cache ConfigMaps | | `charts/fleet/templates/secret.yaml` | **New** — generated DB password / admin-setup Secrets | -| `charts/fleet/templates/deployment.yaml` | `FLEET_OPENFRAME_MODE`, `FLEET_REDIS_KEY_PREFIX`, ConfigMap/Secret refs, annotations, CA init container | +| `charts/fleet/templates/deployment.yaml` | `FLEET_OPENFRAME_MODE`, `FLEET_OPENFRAME_MULTI_TENANCY_ENABLED` / `FLEET_OPENFRAME_TENANT_UUID` / `FLEET_OPENFRAME_TEAM_ID`, `FLEET_REDIS_KEY_PREFIX`, ConfigMap/Secret refs, annotations, CA init container | | `charts/fleet/templates/job-migration.yaml` | `waitForMysql` init container, hook removal, TTL removal | | `charts/fleet/templates/vulnprocessing/cronjob.yaml` | Dedicated vuln-processing cron + `FLEET_REDIS_KEY_PREFIX`, feed-cache PVC mount, fsGroup, schedule stagger (moved from `templates/cron-vulnprocessing.yaml`) | | `charts/fleet/templates/vulnprocessing/pvc.yaml` | **New** — PVC persisting the vulnerability feed cache across cron runs | diff --git a/openframe/docs/mysql-multitenancy-feature.md b/openframe/docs/mysql-multitenancy-feature.md new file mode 100644 index 00000000000..5976e4ec568 --- /dev/null +++ b/openframe/docs/mysql-multitenancy-feature.md @@ -0,0 +1,146 @@ +# Fleet MySQL multi-tenancy (OpenFrame) + +Single-document reference for the OpenFrame shared-database multi-tenancy feature in this fork. +It lets **one Fleet server + one shared MySQL serve many tenants**, where **tenant = Fleet team +(`team_id`)**, with row-level isolation enforced at the datastore. Everything is behind a master +flag; **with the flag off, Fleet behaves exactly as the fork did before this feature** (schema +change aside — see [Backward compatibility](#backward-compatibility)). + +## The master flag + +`FLEET_OPENFRAME_MULTI_TENANCY_ENABLED` (maps the platform property +`openframe.fleet.multi-tenancy.enabled`). Parsed once, cached. Three states: + +| State | Meaning | +|---|---| +| **off** (default) | Pre-feature fork behavior. Every fence is inert; a stray `FLEET_OPENFRAME_TEAM_ID` does **not** pin the process. | +| **on + process pin** (`FLEET_OPENFRAME_TENANT_UUID`, or `FLEET_OPENFRAME_TEAM_ID`) | **Pinned mode** — one Fleet per tenant on the shared DB; the whole process is scoped to one team. | +| **on + no pin** | **Shared mode** — one Fleet per cluster; **every request is pinned individually** (X-Tenant-Id header / host team / enroll-secret team), fail-closed. | + +`ValidateOpenframeMultitenancy()` (called in `cmd/fleet/serve.go`) refuses to boot only on a +`FLEET_OPENFRAME_TEAM_ID` that is set-but-unparsable (a typo must not silently change the mode). +Key API in `server/fleet/openframe.go`: `IsOpenframeMultitenancy()`, `IsOpenframeSharedMode()`, +`OpenframeTeamID(ctx)`, `NewOpenframeTeamContext(ctx, teamID)`, `SetOpenframeTeamID(teamID)`. + +## Identity: the team pin and the UUID→team bridge + +`OpenframeTeamID(ctx)` is the single source of the current tenant team, in precedence order: +1. **ctx value** — per-request pin (shared-mode middleware, agent pins, tests); +2. **process pin** — team resolved from `FLEET_OPENFRAME_TENANT_UUID` at startup; +3. **env fallback** — `FLEET_OPENFRAME_TEAM_ID` (inert unless the flag is on). +Returns `ok=false` when none yields a non-zero team; callers must then assume no tenant scope +(fences no-op). + +**`EnsureOpenframeTeamID(ctx, tenantUUID)`** (`server/datastore/mysql/openframe.go`) bridges the +platform's UUID tenant identity to Fleet's integer `team_id`: it resolves-or-creates the team keyed +by `teams.openframe_tenant_uuid` (unique; race-safe via insert-lose-reselect). A **newly created +team is seeded, in the same transaction, with one random team-scoped enroll secret** (same default +as EE team creation) — without it a fresh tenant could never enroll an agent (the pinned +`GET /spec/enroll_secret` would be empty). The resolve path never touches an existing team's +secrets. In pinned mode `serve.go` calls this at startup and `SetOpenframeTeamID`s the result. + +## Schema migrations (`server/datastore/mysql/migrations/openframe/`) + +Applied by `fleet prepare db`. Idempotent (`columnExists`/`indexExists` guards). + +| Migration | Change | Why | +|---|---|---| +| `20260629000001_AddTeamsOpenframeTenantUUID` | `teams.openframe_tenant_uuid CHAR(36)` + unique key | the UUID→team bridge | +| `20260626000001_ScopeHostIdentityUniqueToTeam` | `hosts` virtual col `openframe_team_key = IFNULL(team_id,0)` + `UNIQUE(osquery_host_id, openframe_team_key)`, drop global `UNIQUE(osquery_host_id)` | host identity unique **per team** — the same device can exist in two tenants | +| `20260620000001_ScopeLabelUniqueNameToTeam` | same generated-column pattern for `labels.name` | label names unique per team; built-ins stay global | + +The `IFNULL(team_id,0)` sentinel collapses all NULL-team rows onto key `0` (team ids start at 1), so +**flag-off / pre-backfill the uniqueness is bit-for-bit the old global uniqueness**, and the +`labels` ODKU upsert (`ApplyLabelSpecs`) keeps working. `fleet.Label` carries an ignored +`OpenframeTeamKey` field so `SELECT l.*` scans don't break. + +## Datastore fences (the tenant boundary) + +The pattern everywhere: `if teamID, ok := fleet.OpenframeTeamID(ctx); ok { … AND team_id = ? … }` +— a **no-op when unpinned**. On the shared DB this is the real isolation (the Fleet team was only a +role-authz grouping upstream; here it is a hard boundary regardless of token role). Coverage: + +- **hosts** — `ListHosts`/`CountHosts` (in `applyHostFilters`), by-id `Host`/`HostLite`, bulk delete + (`filterHostIDsByTeam`), enrollment matcher (`matchHostDuringEnrollment`), `AddHostsToTeam`, and + the minor getters `HostLiteByIdentifier`/`HostLiteByID`, `ListHostsLiteByIDs`, `HostIDsByIdentifier`. + Deliberately **unfenced**: `HostByUUID` (pre-auth iDevice identity lookup — no pin yet). +- **policies** — list/count/by-id (`Policy`, `PolicyLite`, `PoliciesByID`), create→pinned, save + (verify-on-primary), delete (filter foreign ids); service-layer `DeleteGlobalPolicies` treats an + own-pinned-team policy as deletable (creation re-homes policies to the team). +- **queries** — list/by-id/name, create→pinned, save-verify, delete, `ApplyQueries` re-home. +- **enroll_secrets** (`app_configs.go`) — `GetEnrollSecrets`/`ApplyEnrollSecrets` force `teamID = + pinned`; `VerifyEnrollSecret` only accepts a secret whose `team_id = pinned` (agent boundary). +- **live-query targets** (`targets.go`) — `HostIDsInTargets`/`CountHostsInTargets` scoped. +- **host-assignments** (`policy_hosts`/`query_hosts`) — parent verified in team + foreign host ids + dropped (pre-existing `OPENFRAME(host-assignments)` feature, extended here). +- **teams** — `TeamLite`/`ListTeams` read fence. +- **app_config** cache (`cached_mysql.go`) — cache key includes the pinned team so one tenant's + config is never served to another; unpinned keeps the constant key. + +## Per-request pinning + +- **Control-plane / UI** — `service.WithOpenframeTenant` (`server/service/openframe_middleware.go`), + wired in `serve.go` after `apiendpoints.Validate`. Shared mode only (returns `next` unchanged + otherwise). Pins each `/api/**` request from the gateway-injected trusted `X-Tenant-Id`; a + non-exempt request without it is **401 (fail closed)**. Exempt (tenant comes from host/secret): + paths containing `/osquery/`, `/fleet/orbit/`, `/fleet/device/`, `/mdm/`, `/fleet/ota_enrollment`. +- **Agent plane** — `openframePinHostTeam` pins from the authenticated `host.team_id` in + `authenticatedHost`/`authenticatedOrbitHost`/`authenticatedDevice` (`endpoint_middleware.go`) and + the osquery header pre-auth paths (`osquery_header_auth.go`); fail-closed on a team-less host. +- **Enrollment** — after `VerifyEnrollSecret`, `osquery.go`/`orbit.go` pin from `secret.TeamID` + (reject if the secret has no team). The host row is then created carrying that `team_id`. + +Agents send **no tenant header** — tenant identity flows in via the enroll secret and thereafter via +the host record (node key → host → team). This is by design and stronger than a header. + +## Migration serialization on a shared DB + +`fleet prepare db` wraps the migration sequence in a MySQL named lock +`GET_LOCK('openframe_fleet_migrations', 900s)` (`AcquireOpenframeMigrationLock` in +`server/datastore/mysql/openframe.go`, called from `cmd/fleet/prepare.go`) — **flag-gated**. With N +clusters' migration Jobs pointed at one DB, the first wins and migrates; the rest block, then find +the schema already applied and no-op. Session-scoped (auto-released if the Job dies). Flag-off runs +are untouched. + +## 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 (so the `GET_LOCK` guard engages). + +## Backward compatibility + +Flag off ⇒ pre-feature fork behavior everywhere **except the schema**: the three migrations run +unconditionally at `prepare db`, but are semantics-preserving — all rows are `team_id = NULL` → +generated key `0` → the original global uniqueness, byte-for-byte. Verified locally (fresh +branch-native DB): `prepare db` applies all three, the server boots healthy flag-off, +flag-on-pinned (team auto-created + secret seeded, `team_id=1`), and flag-on-shared. The upstream +`TestLabels`/`TestHosts` suites pass on the migrated schema with no OpenFrame env. + +## Tests + +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. +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). + +## Known deferred (latent, non-applicable to OpenFrame today) + +- **Users/sessions + software/vulns/os_versions/activities read-views** — unfenced; only matters if + the Fleet UI is exposed beyond the gateway allowlist. Not today. +- **Custom-label creation while unpinned** → `team_id NULL` → distributed globally. OpenFrame seeds + only built-ins and targets via host-assignments; pinned label writes are team-scoped. +- **Legacy 2017 "user packs" scheduled-query-stats join** — resolves by global pack name; packs are + unused. New-format stats are team-scoped. +- **`IsEnrollSecretAvailable`** — intentionally unfenced (cross-team uniqueness check). + +## Deployment / cutover + +Roll out in order: (1) deploy the fenced code (inert until pinned); (2) run migrations +(`prepare db`); (3) **backfill each tenant's existing rows' `team_id` before flipping its flag** — +un-backfilled rows fail closed (hide data, never leak); (4) enable the flag per tenant. Greenfield +(empty shared DB, agents re-enroll) skips the backfill at the cost of host history. diff --git a/openframe/scripts/verify.sh b/openframe/scripts/verify.sh index 947db4b54d4..6f6d22491d2 100755 --- a/openframe/scripts/verify.sh +++ b/openframe/scripts/verify.sh @@ -59,7 +59,8 @@ step "OPENFRAME marker coverage (every fork-token line is marked)" if python3 - <<'PYEOF' import re, subprocess, sys SKIP=('/migrations/openframe/','/service/openframe/','/migrations/tables/','/migrations/data/','/server/mock/','/node_modules/','/vendor/','/tools/fleet-mcp/') -SKIP_EXACT={'server/fleet/openframe.go','server/datastore/redis/keyprefix.go'} +SKIP_EXACT={'server/fleet/openframe.go','server/datastore/redis/keyprefix.go', + 'server/datastore/mysql/openframe.go','server/service/openframe_middleware.go'} TOKENS=re.compile('|'.join([ r'[Oo]pen[Ff]rame',r'FLEET_OPENFRAME_MODE',r'ORBIT_OPENFRAME',r'policy_hosts',r'query_hosts', r'HostsIncludeAny',r'\bHostIdent\b',r'loadHostsFor(Policies|Queries)', diff --git a/server/datastore/cached_mysql/cached_mysql.go b/server/datastore/cached_mysql/cached_mysql.go index 9f86f9dbdc4..f996ca49a80 100644 --- a/server/datastore/cached_mysql/cached_mysql.go +++ b/server/datastore/cached_mysql/cached_mysql.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" "time" @@ -37,6 +38,7 @@ import ( // and add tests in cached_mysql_test.go to ensure it works as expected. const ( appConfigKey = "AppConfig:%s" + openframeAppConfigKeyPrefix = "AppConfig:openframe_team:" // OPENFRAME(mysql-multitenancy) defaultAppConfigExpiration = 1 * time.Second packsHostKey = "Packs:host:%d" defaultPacksExpiration = 1 * time.Minute @@ -231,19 +233,30 @@ func New(ds fleet.Datastore, opts ...Option) fleet.Datastore { return c } +// >>> OPENFRAME(mysql-multitenancy): key the AppConfig cache by the request's team so a shared +// process never serves one tenant's config to another; unpinned keeps the constant key. +func openframeAppConfigKey(ctx context.Context) string { + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + return openframeAppConfigKeyPrefix + strconv.FormatUint(uint64(teamID), 10) + } + return appConfigKey +} + +// <<< OPENFRAME(mysql-multitenancy) + func (ds *cachedMysql) NewAppConfig(ctx context.Context, info *fleet.AppConfig) (*fleet.AppConfig, error) { ac, err := ds.Datastore.NewAppConfig(ctx, info) if err != nil { return nil, err } - ds.c.Set(ctx, appConfigKey, ac, ds.appConfigExp) + ds.c.Set(ctx, openframeAppConfigKey(ctx), ac, ds.appConfigExp) // OPENFRAME(mysql-multitenancy) return ac, nil } func (ds *cachedMysql) AppConfig(ctx context.Context) (*fleet.AppConfig, error) { - if x, found := ds.c.Get(ctx, appConfigKey); found { + if x, found := ds.c.Get(ctx, openframeAppConfigKey(ctx)); found { // OPENFRAME(mysql-multitenancy) ac, ok := x.(*fleet.AppConfig) if ok { return ac, nil @@ -255,7 +268,7 @@ func (ds *cachedMysql) AppConfig(ctx context.Context) (*fleet.AppConfig, error) return nil, err } - ds.c.Set(ctx, appConfigKey, ac, ds.appConfigExp) + ds.c.Set(ctx, openframeAppConfigKey(ctx), ac, ds.appConfigExp) // OPENFRAME(mysql-multitenancy) return ac, nil } @@ -266,7 +279,7 @@ func (ds *cachedMysql) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) return err } - ds.c.Set(ctx, appConfigKey, info, ds.appConfigExp) + ds.c.Set(ctx, openframeAppConfigKey(ctx), info, ds.appConfigExp) // OPENFRAME(mysql-multitenancy) return nil } diff --git a/server/datastore/cached_mysql/cached_mysql_test.go b/server/datastore/cached_mysql/cached_mysql_test.go index ef1bb8008ca..77e148748a8 100644 --- a/server/datastore/cached_mysql/cached_mysql_test.go +++ b/server/datastore/cached_mysql/cached_mysql_test.go @@ -1059,3 +1059,49 @@ func TestCachedFMANamesByIdentifier(t *testing.T) { require.Equal(t, "VS Code Updated", names5["com.microsoft.VSCode"]) require.True(t, mockedDS.GetFMANamesByIdentifierFuncInvoked) } + +// OPENFRAME(mysql-multitenancy): the AppConfig cache key must carry the request's tenant team +// pin, so one tenant's cached config is never served to another in a shared multi-tenant +// process. Unpinned requests keep the historical constant key (upstream behavior). +func TestOpenframeCachedAppConfigPerTeam(t *testing.T) { + t.Parallel() + + mockedDS := new(mock.Store) + ds := New(mockedDS) + + configsByTeam := map[uint]*fleet.AppConfig{ + 1: {OrgInfo: fleet.OrgInfo{OrgName: "tenant-one"}}, + 2: {OrgInfo: fleet.OrgInfo{OrgName: "tenant-two"}}, + } + mockedDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + teamID, ok := fleet.OpenframeTeamID(ctx) + require.True(t, ok) + return configsByTeam[teamID], nil + } + + ctxA := fleet.NewOpenframeTeamContext(context.Background(), 1) + ctxB := fleet.NewOpenframeTeamContext(context.Background(), 2) + + // Prime tenant 1's cache, then read tenant 2: the cache must MISS (different key) and + // return tenant 2's config, not tenant 1's. + acA, err := ds.AppConfig(ctxA) + require.NoError(t, err) + require.Equal(t, "tenant-one", acA.OrgInfo.OrgName) + + acB, err := ds.AppConfig(ctxB) + require.NoError(t, err) + require.Equal(t, "tenant-two", acB.OrgInfo.OrgName, "tenant 2 must never see tenant 1's cached config") + + // Both now cached under their own keys: repeat reads don't hit the datastore. + mockedDS.AppConfigFuncInvoked = false + acA2, err := ds.AppConfig(ctxA) + require.NoError(t, err) + require.Equal(t, "tenant-one", acA2.OrgInfo.OrgName) + acB2, err := ds.AppConfig(ctxB) + require.NoError(t, err) + require.Equal(t, "tenant-two", acB2.OrgInfo.OrgName) + require.False(t, mockedDS.AppConfigFuncInvoked) + + // An unpinned ctx uses the historical constant key. + require.Equal(t, appConfigKey, openframeAppConfigKey(context.Background())) +} diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index ff0e8989670..a311fb5453f 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -42,11 +42,31 @@ func (ds *Datastore) AppConfig(ctx context.Context) (*fleet.AppConfig, error) { func appConfigDB(ctx context.Context, q sqlx.QueryerContext) (*fleet.AppConfig, error) { info := &fleet.AppConfig{} var bytes []byte - err := sqlx.GetContext(ctx, q, &bytes, `SELECT json_value FROM app_config_json LIMIT 1`) + + // >>> OPENFRAME(mysql-multitenancy): app_config is a single-row table (id PK). Under + // shared-DB multitenancy each tenant's config is stored under id = its team id; read this + // process's team config so tenants don't share one config. + stmt := `SELECT json_value FROM app_config_json LIMIT 1` + var args []interface{} + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt = `SELECT json_value FROM app_config_json WHERE id = ? LIMIT 1` + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + + err := sqlx.GetContext(ctx, q, &bytes, stmt, args...) if err != nil && err != sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, err, "selecting app config") } if err == sql.ErrNoRows { + // >>> OPENFRAME(mysql-multitenancy): a pinned tenant may have no app_config_json row yet + // (team created, config never saved) — return defaults so software inventory / host users + // aren't silently off. Unpinned keeps the upstream bare-config behavior. + if _, ok := fleet.OpenframeTeamID(ctx); ok { + info.ApplyDefaults() + return info, nil + } + // <<< OPENFRAME(mysql-multitenancy) return &fleet.AppConfig{}, nil } @@ -62,7 +82,17 @@ func appConfigDB(ctx context.Context, q sqlx.QueryerContext) (*fleet.AppConfig, func (ds *Datastore) AppConfigUrls(ctx context.Context) (*fleet.AppConfigUrls, error) { info := &fleet.AppConfigUrls{} var bytes []byte - err := sqlx.GetContext(ctx, ds.reader(ctx), &bytes, `SELECT json_value FROM app_config_json LIMIT 1`) + + // >>> OPENFRAME(mysql-multitenancy): read this process's team config (see appConfigDB). + stmt := `SELECT json_value FROM app_config_json LIMIT 1` + var args []interface{} + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt = `SELECT json_value FROM app_config_json WHERE id = ? LIMIT 1` + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + + err := sqlx.GetContext(ctx, ds.reader(ctx), &bytes, stmt, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, ctxerr.Wrap(ctx, err, "selecting app config urls") } @@ -84,10 +114,20 @@ func (ds *Datastore) SaveAppConfig(ctx context.Context, info *fleet.AppConfig) e return ctxerr.Wrap(ctx, err, "marshaling config") } - _, err = tx.ExecContext(ctx, - `INSERT INTO app_config_json(json_value) VALUES(?) ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`, - configBytes, - ) + // >>> OPENFRAME(mysql-multitenancy): store this tenant's config under id = its team id + // so tenants don't overwrite a shared single config row. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + _, err = tx.ExecContext(ctx, + `INSERT INTO app_config_json(id, json_value) VALUES(?, ?) ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`, + teamID, configBytes, + ) + } else { + _, err = tx.ExecContext(ctx, + `INSERT INTO app_config_json(json_value) VALUES(?) ON DUPLICATE KEY UPDATE json_value = VALUES(json_value)`, + configBytes, + ) + } + // <<< OPENFRAME(mysql-multitenancy) if err != nil { return ctxerr.Wrap(ctx, err, "insert app_config_json") } @@ -136,7 +176,17 @@ func (ds *Datastore) SetAndroidEnabledAndConfigured(ctx context.Context, configu func (ds *Datastore) VerifyEnrollSecret(ctx context.Context, secret string) (*fleet.EnrollSecret, error) { var s fleet.EnrollSecret - err := sqlx.GetContext(ctx, ds.reader(ctx), &s, "SELECT team_id FROM enroll_secrets WHERE secret = ?", secret) + // >>> OPENFRAME(mysql-multitenancy): an agent may only enroll using THIS process's tenant secret. + // On a shared DB, reject a secret belonging to another team (or a pre-backfill global secret) so + // an agent can't enroll into the wrong tenant. No-op when unpinned. + stmt := "SELECT team_id FROM enroll_secrets WHERE secret = ?" + args := []interface{}{secret} + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt += " AND team_id = ?" + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + err := sqlx.GetContext(ctx, ds.reader(ctx), &s, stmt, args...) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ctxerr.Wrap(ctx, notFound("EnrollSecret"), "no matching secret found") @@ -170,6 +220,12 @@ func (ds *Datastore) IsEnrollSecretAvailable(ctx context.Context, secret string, } func (ds *Datastore) ApplyEnrollSecrets(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + // >>> OPENFRAME(mysql-multitenancy): a per-tenant process manages only its own team's secrets; + // scope writes to the pinned team (global → pinned, foreign → pinned) on a shared DB. No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + teamID = &pinned + } + // <<< OPENFRAME(mysql-multitenancy) return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { return applyEnrollSecretsDB(ctx, tx, teamID, secrets) }) @@ -248,6 +304,13 @@ func applyEnrollSecretsDB(ctx context.Context, q sqlx.ExtContext, teamID *uint, } func (ds *Datastore) GetEnrollSecrets(ctx context.Context, teamID *uint) ([]*fleet.EnrollSecret, error) { + // >>> OPENFRAME(mysql-multitenancy): scope enroll-secret reads to this process's team (global → + // pinned, foreign → pinned) so a tenant cannot read another tenant's secrets on a shared DB. + // No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + teamID = &pinned + } + // <<< OPENFRAME(mysql-multitenancy) return getEnrollSecretsDB(ctx, ds.reader(ctx), teamID) } diff --git a/server/datastore/mysql/app_configs_openframe_test.go b/server/datastore/mysql/app_configs_openframe_test.go new file mode 100644 index 00000000000..769da8af712 --- /dev/null +++ b/server/datastore/mysql/app_configs_openframe_test.go @@ -0,0 +1,68 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +// TestOpenframeAppConfigTeamIsolation verifies the OPENFRAME(mysql-multitenancy) change to the +// app_config read/write: when the request is scoped to a tenant team (via context, or +// FLEET_OPENFRAME_TEAM_ID in production), its config is stored/read under id = team id, so +// different tenants do not share the single app_config row. Runs only under MYSQL_TEST=1. +func TestOpenframeAppConfigTeamIsolation(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + ctx5 := fleet.NewOpenframeTeamContext(ctx, 5) + ctx6 := fleet.NewOpenframeTeamContext(ctx, 6) + + save := func(c context.Context, orgName string) { + require.NoError(t, ds.SaveAppConfig(c, &fleet.AppConfig{OrgInfo: fleet.OrgInfo{OrgName: orgName}})) + } + readOrg := func(c context.Context) string { + ac, err := ds.AppConfig(c) + require.NoError(t, err) + return ac.OrgInfo.OrgName + } + + // Each team starts with no config of its own. + require.Empty(t, readOrg(ctx5)) + require.Empty(t, readOrg(ctx6)) + + save(ctx5, "team5-org") + save(ctx6, "team6-org") + + // Each team reads back its own config, not the other's. + require.Equal(t, "team5-org", readOrg(ctx5)) + require.Equal(t, "team6-org", readOrg(ctx6)) + + // Overwriting team 6 does not affect team 5. + save(ctx6, "team6-org-v2") + require.Equal(t, "team5-org", readOrg(ctx5)) + require.Equal(t, "team6-org-v2", readOrg(ctx6)) +} + +// TestOpenframeAppConfigDefaultsForConfiglessTenant verifies that a pinned tenant with no +// app_config_json row yet (team created, config never saved) reads defaults — not zero-value +// config that would silently disable host users. The team id must be > 1 to avoid the legacy +// singleton row (id = 1). +func TestOpenframeAppConfigDefaultsForConfiglessTenant(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + _, err := ds.NewTeam(ctx, &fleet.Team{Name: "configless-filler"}) // consumes team id 1 + require.NoError(t, err) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "configless"}) + require.NoError(t, err) + require.Greater(t, team.ID, uint(1)) + ctxT := fleet.NewOpenframeTeamContext(ctx, team.ID) + + ac, err := ds.AppConfig(ctxT) + require.NoError(t, err) + require.True(t, ac.Features.EnableHostUsers, "defaults must be applied for a config-less tenant") + require.Equal(t, 24*time.Hour, ac.WebhookSettings.Interval.Duration) +} diff --git a/server/datastore/mysql/enroll_secrets_openframe_test.go b/server/datastore/mysql/enroll_secrets_openframe_test.go new file mode 100644 index 00000000000..5da4de4e696 --- /dev/null +++ b/server/datastore/mysql/enroll_secrets_openframe_test.go @@ -0,0 +1,64 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +// TestOpenframeEnrollSecretTeamFence verifies the OPENFRAME(mysql-multitenancy) enroll-secret +// fences: an agent (VerifyEnrollSecret) may only enroll with this process's tenant secret, and the +// read/write paths (GetEnrollSecrets/ApplyEnrollSecrets) are scoped to the pinned team. No-op when +// unpinned. Runs only under MYSQL_TEST=1. +func TestOpenframeEnrollSecretTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "es-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "es-b"}) + require.NoError(t, err) + + // Seed each team's secret while unpinned (explicit team). + require.NoError(t, ds.ApplyEnrollSecrets(ctx, &teamA.ID, []*fleet.EnrollSecret{{Secret: "secret-A"}})) + require.NoError(t, ds.ApplyEnrollSecrets(ctx, &teamB.ID, []*fleet.EnrollSecret{{Secret: "secret-B"}})) + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("VerifyEnrollSecret: own ok, foreign rejected", func(t *testing.T) { + s, err := ds.VerifyEnrollSecret(ctxA, "secret-A") + require.NoError(t, err) + require.NotNil(t, s.TeamID) + require.Equal(t, teamA.ID, *s.TeamID) + + _, err = ds.VerifyEnrollSecret(ctxA, "secret-B") + require.True(t, fleet.IsNotFound(err), "a foreign tenant's secret must be rejected, got %v", err) + }) + + t.Run("GetEnrollSecrets: pinned returns only this team's", func(t *testing.T) { + secs, err := ds.GetEnrollSecrets(ctxA, nil) // ask "global" → scoped to pinned + require.NoError(t, err) + got := map[string]bool{} + for _, s := range secs { + got[s.Secret] = true + } + require.True(t, got["secret-A"]) + require.False(t, got["secret-B"], "another tenant's secret must not be returned") + }) + + t.Run("ApplyEnrollSecrets: pinned writes to this team only", func(t *testing.T) { + // Apply with nil team while pinned → forced to team A. + require.NoError(t, ds.ApplyEnrollSecrets(ctxA, nil, []*fleet.EnrollSecret{{Secret: "secret-A"}, {Secret: "secret-A2"}})) + + secs, err := ds.GetEnrollSecrets(ctxA, nil) + require.NoError(t, err) + require.Len(t, secs, 2) + + // Team B is untouched. + s, err := ds.VerifyEnrollSecret(ctx, "secret-B") // unpinned + require.NoError(t, err) + require.Equal(t, teamB.ID, *s.TeamID) + }) +} diff --git a/server/datastore/mysql/host_assignments_openframe_test.go b/server/datastore/mysql/host_assignments_openframe_test.go new file mode 100644 index 00000000000..c8310e0b4b4 --- /dev/null +++ b/server/datastore/mysql/host_assignments_openframe_test.go @@ -0,0 +1,86 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +// TestOpenframeHostAssignmentTeamFence verifies the OPENFRAME(mysql-multitenancy) fences on the +// host-assignment CRUD (policy_hosts / query_hosts): a pinned process cannot assign/list against +// another tenant's policy/query (NotFound), and foreign host ids are dropped from assignments to +// its own. Runs only under MYSQL_TEST=1. +func TestOpenframeHostAssignmentTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // policy_hosts / query_hosts live in the openframe migration pipeline (not schema.sql). + require.NoError(t, ds.MigrateOpenframe(ctx)) + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "ha-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "ha-b"}) + require.NoError(t, err) + + mkHost := func(team *fleet.Team, key string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), + OsqueryHostID: ptr.String(key), NodeKey: ptr.String("nk-" + key), UUID: key, Hostname: "h-" + key, + Platform: "darwin", TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + hostA := mkHost(teamA, "ha-A") + hostB := mkHost(teamB, "ha-B") + + polA, err := ds.NewTeamPolicy(ctx, teamA.ID, nil, fleet.PolicyPayload{Name: "haPolA", Query: "SELECT 1"}) + require.NoError(t, err) + polB, err := ds.NewTeamPolicy(ctx, teamB.ID, nil, fleet.PolicyPayload{Name: "haPolB", Query: "SELECT 1"}) + require.NoError(t, err) + + qA, err := ds.NewQuery(ctx, &fleet.Query{Name: "haQA", Query: "SELECT 1", Saved: true, TeamID: &teamA.ID, Logging: fleet.LoggingSnapshot}) + require.NoError(t, err) + qB, err := ds.NewQuery(ctx, &fleet.Query{Name: "haQB", Query: "SELECT 1", Saved: true, TeamID: &teamB.ID, Logging: fleet.LoggingSnapshot}) + require.NoError(t, err) + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("policy assignment", func(t *testing.T) { + // Assigning a mix to own policy → only own host added. + n, err := ds.AddPolicyHosts(ctxA, polA.ID, []uint{hostA.ID, hostB.ID}) + require.NoError(t, err) + require.Equal(t, uint(1), n) + + hosts, _, err := ds.ListPolicyHosts(ctxA, polA.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, hosts, 1) + require.Equal(t, hostA.ID, hosts[0].HostID) + + // Operating on another tenant's policy → NotFound. + _, err = ds.AddPolicyHosts(ctxA, polB.ID, []uint{hostB.ID}) + require.True(t, fleet.IsNotFound(err), "add to foreign policy must be NotFound, got %v", err) + _, _, err = ds.ListPolicyHosts(ctxA, polB.ID, fleet.ListOptions{}) + require.True(t, fleet.IsNotFound(err), "list foreign policy hosts must be NotFound, got %v", err) + }) + + t.Run("query assignment", func(t *testing.T) { + n, err := ds.AddQueryHosts(ctxA, qA.ID, []uint{hostA.ID, hostB.ID}) + require.NoError(t, err) + require.Equal(t, uint(1), n) + + hosts, _, err := ds.ListQueryHosts(ctxA, qA.ID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, hosts, 1) + require.Equal(t, hostA.ID, hosts[0].HostID) + + _, err = ds.AddQueryHosts(ctxA, qB.ID, []uint{hostB.ID}) + require.True(t, fleet.IsNotFound(err), "add to foreign query must be NotFound, got %v", err) + _, _, err = ds.ListQueryHosts(ctxA, qB.ID, fleet.ListOptions{}) + require.True(t, fleet.IsNotFound(err), "list foreign query hosts must be NotFound, got %v", err) + }) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 879efde2c30..febe73f389b 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -660,6 +660,21 @@ func deleteHosts(ctx context.Context, tx sqlx.ExtContext, hostIDs []uint) error if len(hostIDs) == 0 { return nil } + + // >>> OPENFRAME(mysql-multitenancy): fence deletion to the pinned team; the child-ref deletes + // below are keyed by host_id, so filtering the host set first keeps them in-tenant. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + owned, err := filterHostIDsByTeam(ctx, tx, hostIDs, teamID) + if err != nil { + return err + } + if len(owned) == 0 { + return nil + } + hostIDs = owned + } + // <<< OPENFRAME(mysql-multitenancy) + delHostRef := func(tx sqlx.ExtContext, table string) error { stmt, args, err := sqlx.In(fmt.Sprintf("DELETE FROM %s WHERE host_id IN (?)", table), hostIDs) if err != nil { @@ -868,11 +883,16 @@ FROM LEFT JOIN host_issues ON h.id = host_issues.host_id ` + hostMDMJoin + ` WHERE - h.id = ? -LIMIT - 1 -` + h.id = ?` args := []interface{}{id} + // >>> OPENFRAME(mysql-multitenancy): scope by-id read to the pinned team; a foreign id matches + // no rows → the NotFound path → 404. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + sqlStatement += " AND h.team_id = ?" + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + sqlStatement += " LIMIT 1" var host fleet.Host err := sqlx.GetContext(ctx, ds.reader(ctx), &host, sqlStatement, args...) @@ -1535,6 +1555,14 @@ func (ds *Datastore) applyHostFilters( sqlStmt, whereParams = filterHostsByProfileStatus(sqlStmt, opt, whereParams) sqlStmt, whereParams = hostSearchLike(sqlStmt, whereParams, opt.MatchQuery, append(hostSearchColumns, "display_name")...) + // >>> OPENFRAME(mysql-multitenancy): scope host list/count to the pinned team (the caller's + // role-based TeamFilter is not a tenant boundary). + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + sqlStmt += " AND h.team_id = ?" + whereParams = append(whereParams, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + sqlStmt, whereParams, err = appendListOptionsWithCursorToSQLSecure(sqlStmt, whereParams, &opt.ListOptions, hostAllowedOrderKeys) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "apply list options") @@ -2289,14 +2317,27 @@ func matchHostDuringEnrollment( nodeKeyColumn = "orbit_node_key" } + // >>> OPENFRAME(mysql-multitenancy): scope enrollment matching to the pinned team so a host in + // another tenant with the same serial/uuid/identifier can't be hijacked. + teamFilter := "" + var teamArg interface{} + if id, ok := fleet.OpenframeTeamID(ctx); ok { + teamFilter = " AND team_id = ?" + teamArg = id + } + // <<< OPENFRAME(mysql-multitenancy) + if osqueryID != "" || uuid != "" { - _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 1 priority, platform FROM hosts WHERE osquery_host_id = ?)`, nodeKeyColumn)) + _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 1 priority, platform FROM hosts WHERE osquery_host_id = ?%s)`, nodeKeyColumn, teamFilter)) osqueryHostID := osqueryID if osqueryID == "" { // special-case, if there's no osquery identifier, use the uuid osqueryHostID = uuid } args = append(args, osqueryHostID) + if teamFilter != "" { + args = append(args, teamArg) + } } // We want to prevent orbit enrolling with an osquery identifier to be matched with the serial number. @@ -2307,8 +2348,11 @@ func matchHostDuringEnrollment( if query.Len() > 0 { _, _ = query.WriteString(" UNION ") } - _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 2 priority, platform FROM hosts WHERE hardware_serial = ? AND (platform = 'darwin' OR platform = 'ios' OR platform = 'ipados') ORDER BY id LIMIT 1)`, nodeKeyColumn)) + _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 2 priority, platform FROM hosts WHERE hardware_serial = ? AND (platform = 'darwin' OR platform = 'ios' OR platform = 'ipados')%s ORDER BY id LIMIT 1)`, nodeKeyColumn, teamFilter)) args = append(args, serial) + if teamFilter != "" { + args = append(args, teamArg) + } } // Android-specific UUID match @@ -2316,8 +2360,11 @@ func matchHostDuringEnrollment( if query.Len() > 0 { _, _ = query.WriteString(" UNION ") } - _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 3 priority, platform FROM hosts WHERE uuid = ? AND (platform = 'android') ORDER BY id LIMIT 1)`, nodeKeyColumn)) + _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 3 priority, platform FROM hosts WHERE uuid = ? AND (platform = 'android')%s ORDER BY id LIMIT 1)`, nodeKeyColumn, teamFilter)) args = append(args, uuid) + if teamFilter != "" { + args = append(args, teamArg) + } } if err := sqlx.SelectContext(ctx, q, &rows, query.String(), args...); err != nil { @@ -3282,7 +3329,17 @@ func (ds *Datastore) HostIDsByIdentifier(ctx context.Context, filter fleet.TeamF `, ds.whereFilterHostsByTeams(filter, "hosts"), ) - sql, args, err := sqlx.In(sqlStatement, hostIdentifiers, hostIdentifiers, hostIdentifiers, hostIdentifiers, hostIdentifiers) + inArgs := []interface{}{hostIdentifiers, hostIdentifiers, hostIdentifiers, hostIdentifiers, hostIdentifiers} + // >>> OPENFRAME(mysql-multitenancy): the TeamFilter above is the caller's ROLE filter (a + // global-admin token passes everything) — it is not a tenant boundary. Scope identifier→id + // resolution (used to target labels and live-query campaigns) to the pinned team; foreign + // identifiers simply resolve to nothing. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + sqlStatement += " AND hosts.team_id = ?" + inArgs = append(inArgs, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + sql, args, err := sqlx.In(sqlStatement, inArgs...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to get host IDs by identifier") } @@ -3378,7 +3435,17 @@ SELECT FROM hosts WHERE id IN (?)` - stmt, args, err := sqlx.In(stmt, ids) + inArgs := []interface{}{ids} + // >>> OPENFRAME(mysql-multitenancy): scope the batch by-id read to the pinned team — its + // callers are validation reads on otherwise-fenced paths (delete-hosts, hosts_include_any), + // where an unfenced read is a cross-tenant host-id existence oracle. Foreign ids drop out and + // read as nonexistent. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt += ` AND team_id = ?` + inArgs = append(inArgs, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + stmt, args, err := sqlx.In(stmt, inArgs...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to select hosts by id") } @@ -3448,11 +3515,18 @@ func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (* LEFT JOIN host_updates hu ON (h.id = hu.host_id) LEFT JOIN host_disks hd ON hd.host_id = h.id ` + hostMDMJoin + ` - WHERE ? IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial) - LIMIT 1 - ` + WHERE ? IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial)` + args := []interface{}{identifier} + // >>> OPENFRAME(mysql-multitenancy): scope identifier lookup to the pinned team (uuid/hostname/ + // serial aren't globally unique) → foreign match gives NotFound. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt += " AND h.team_id = ?" + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + stmt += " LIMIT 1" host := &fleet.Host{} - err := sqlx.GetContext(ctx, ds.reader(ctx), host, stmt, identifier) + err := sqlx.GetContext(ctx, ds.reader(ctx), host, stmt, args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("Host").WithName(identifier)) @@ -3470,6 +3544,12 @@ func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (* } // HostByUUID matches only against the uuid column. +// +// OPENFRAME(mysql-multitenancy): deliberately NOT team-fenced. Its only caller is +// AuthenticateIDeviceByURL — a PRE-authentication identity lookup (like VerifyEnrollSecret): at +// call time no tenant is pinned yet; the host it resolves is what establishes the request's team +// (openframePinHostTeam fails closed afterwards if the host has no team). A pin-based fence here +// would be a no-op on that path. func (ds *Datastore) HostByUUID(ctx context.Context, uuid string) (*fleet.Host, error) { stmt := ` SELECT @@ -3557,6 +3637,24 @@ func (ds *Datastore) AddHostsToTeam(ctx context.Context, params *fleet.AddHostsT return nil } + // >>> OPENFRAME(mysql-multitenancy): a tenant may only move its own hosts, and only within its + // own team — refuse a foreign or "No team" target and drop any hosts it does not own, so a + // shared-DB "transfer" cannot move hosts across tenants. No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + if teamID == nil || *teamID != pinned { + return ctxerr.Wrap(ctx, notFound("Fleet").WithMessage("target team is not accessible")) + } + owned, err := filterHostIDsByTeam(ctx, ds.writer(ctx), hostIDs, pinned) + if err != nil { + return ctxerr.Wrap(ctx, err, "scoping hosts to team for transfer") + } + hostIDs = owned + if len(hostIDs) == 0 { + return nil + } + } + // <<< OPENFRAME(mysql-multitenancy) + for i := 0; i < len(hostIDs); i += batchSize { start := i end := i + batchSize @@ -5625,6 +5723,13 @@ ON DUPLICATE KEY UPDATE // // If the host doesn't exist, a NotFoundError is returned. func (ds *Datastore) HostLite(ctx context.Context, id uint) (*fleet.Host, error) { + // >>> OPENFRAME(mysql-multitenancy): scope by-id host read to this process's team so a tenant + // cannot read another tenant's host by id; a foreign id matches no rows → NotFound (404). + whereExprs := []goqu.Expression{goqu.I("id").Eq(id)} + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + whereExprs = append(whereExprs, goqu.I("team_id").Eq(teamID)) + } + // <<< OPENFRAME(mysql-multitenancy) query, args, err := dialect.From(goqu.I("hosts")).Select( "id", "created_at", @@ -5648,7 +5753,7 @@ func (ds *Datastore) HostLite(ctx context.Context, id uint) (*fleet.Host, error) "policy_updated_at", "refetch_requested", "refetch_critical_queries_until", - ).Where(goqu.I("id").Eq(id)).ToSQL() + ).Where(whereExprs...).ToSQL() if err != nil { return nil, ctxerr.Wrap(ctx, err, "sql build") } @@ -6515,8 +6620,17 @@ func (ds *Datastore) loadHostLite(ctx context.Context, id *uint, identifier *str whereClause = "WHERE id = ?" arg = id } + queryArgs := []interface{}{arg} + // >>> OPENFRAME(mysql-multitenancy): scope the lite host lookup (by id or by free-form + // identifier — hostname/uuid/serial are guessable) to this process's pinned team so a tenant + // cannot resolve another tenant's host on a shared DB; foreign → NotFound. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + whereClause += " AND h.team_id = ?" + queryArgs = append(queryArgs, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) host := &fleet.HostLite{} - err := sqlx.GetContext(ctx, ds.reader(ctx), host, fmt.Sprintf(stmt, whereClause), arg) + err := sqlx.GetContext(ctx, ds.reader(ctx), host, fmt.Sprintf(stmt, whereClause), queryArgs...) if err != nil { if err == sql.ErrNoRows { if identifier != nil { diff --git a/server/datastore/mysql/hosts_openframe_test.go b/server/datastore/mysql/hosts_openframe_test.go new file mode 100644 index 00000000000..30d8543f926 --- /dev/null +++ b/server/datastore/mysql/hosts_openframe_test.go @@ -0,0 +1,344 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +// TestOpenframeEnrollmentTeamIsolation verifies the OPENFRAME(mysql-multitenancy) change to +// matchHostDuringEnrollment: when the request is scoped to a tenant team (via context, or +// FLEET_OPENFRAME_TEAM_ID in production), an agent enrolling under that team must NOT be matched +// to (and hijack) a host that belongs to a different team but shares a hardware serial. The +// serial-match path is the exploitable cross-tenant vector (hardware_serial is not unique). +// Runs only under MYSQL_TEST=1. +func TestOpenframeEnrollmentTeamIsolation(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "tenant-b"}) + require.NoError(t, err) + + const sharedSerial = "SHARED-SERIAL-123" + + // DEP-style pre-created Apple host in a team, with the shared serial. + makeAppleHost := func(team *fleet.Team, uuid, osqueryID string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(osqueryID), + NodeKey: ptr.String("nk-" + osqueryID), + UUID: uuid, + Hostname: "host-" + osqueryID, + Platform: "darwin", + HardwareSerial: sharedSerial, + TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + + t.Run("baseline (no team scope): cross-team serial match occurs", func(t *testing.T) { + hostA := makeAppleHost(teamA, "uuid-base-A", "osq-base-A") + + enrolled, err := ds.EnrollOsquery(ctx, + fleet.WithEnrollOsqueryHostID("osq-base-B"), + fleet.WithEnrollOsqueryHardwareSerial(sharedSerial), + fleet.WithEnrollOsqueryNodeKey("nk-base-B"), + fleet.WithEnrollOsqueryMDMEnabled(true), + fleet.WithEnrollOsqueryTeamID(&teamB.ID), + ) + require.NoError(t, err) + // Legacy behavior: team B's enroll matched team A's host (the hijack this fix prevents). + require.Equal(t, hostA.ID, enrolled.ID) + }) + + t.Run("team-scoped: enrollment cannot match another tenant's host", func(t *testing.T) { + hostA := makeAppleHost(teamA, "uuid-of-A", "osq-of-A") + + // Scope the enroll to team B via context (mirrors a team-B-pinned process). + enrolled, err := ds.EnrollOsquery(fleet.NewOpenframeTeamContext(ctx, teamB.ID), + fleet.WithEnrollOsqueryHostID("osq-of-B"), + fleet.WithEnrollOsqueryHardwareSerial(sharedSerial), + fleet.WithEnrollOsqueryNodeKey("nk-of-B"), + fleet.WithEnrollOsqueryMDMEnabled(true), + fleet.WithEnrollOsqueryTeamID(&teamB.ID), + ) + require.NoError(t, err) + + // Must NOT hijack team A's host; a distinct host is created for team B. + require.NotEqual(t, hostA.ID, enrolled.ID) + + // Team A's host is untouched (its osquery identifier was not overwritten). + reloadedA, err := ds.Host(ctx, hostA.ID) + require.NoError(t, err) + require.NotNil(t, reloadedA.OsqueryHostID) + require.Equal(t, "osq-of-A", *reloadedA.OsqueryHostID) + require.NotNil(t, reloadedA.TeamID) + require.Equal(t, teamA.ID, *reloadedA.TeamID) + }) +} + +// TestOpenframeSameDeviceEnrollsIntoTwoTeams verifies migration 20260626000001: once +// osquery-identity uniqueness is scoped to (team_id, osquery_host_id), the SAME device (same +// osquery_host_id / uuid) can enroll into two different tenant teams — each gets its own host +// row. Under the upstream global UNIQUE(osquery_host_id) the second enroll's INSERT would fail +// with a duplicate-key error. Runs only under MYSQL_TEST=1. +func TestOpenframeSameDeviceEnrollsIntoTwoTeams(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // Apply the OpenFrame migrations so osquery_host_id is unique per (team_id, osquery_host_id). + require.NoError(t, ds.MigrateOpenframe(ctx)) + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "dup-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "dup-tenant-b"}) + require.NoError(t, err) + + const sharedID = "DEVICE-OSQUERY-ID-XYZ" + + // Same device identifier enrolls under team A (process pinned to A via context). + enrolledA, err := ds.EnrollOsquery(fleet.NewOpenframeTeamContext(ctx, teamA.ID), + fleet.WithEnrollOsqueryHostID(sharedID), + fleet.WithEnrollOsqueryHardwareSerial("SERIAL-A"), + fleet.WithEnrollOsqueryNodeKey("nk-dup-A"), + fleet.WithEnrollOsqueryTeamID(&teamA.ID), + ) + require.NoError(t, err) + + // The same device identifier enrolls under team B. With the team-scoped unique this must + // succeed and create a distinct host (rather than fail on a global duplicate-key). + enrolledB, err := ds.EnrollOsquery(fleet.NewOpenframeTeamContext(ctx, teamB.ID), + fleet.WithEnrollOsqueryHostID(sharedID), + fleet.WithEnrollOsqueryHardwareSerial("SERIAL-B"), + fleet.WithEnrollOsqueryNodeKey("nk-dup-B"), + fleet.WithEnrollOsqueryTeamID(&teamB.ID), + ) + require.NoError(t, err) + + require.NotEqual(t, enrolledA.ID, enrolledB.ID, "same device must get a distinct host per team") + + hostA, err := ds.Host(ctx, enrolledA.ID) + require.NoError(t, err) + require.NotNil(t, hostA.TeamID) + require.Equal(t, teamA.ID, *hostA.TeamID) + + hostB, err := ds.Host(ctx, enrolledB.ID) + require.NoError(t, err) + require.NotNil(t, hostB.TeamID) + require.Equal(t, teamB.ID, *hostB.TeamID) +} + +// TestOpenframeHostByIDTeamFence verifies the OPENFRAME(mysql-multitenancy) fence on the by-id host +// getters (Host / HostLite / HostByIdentifier): a team-scoped process can read its own host but gets +// NotFound (→ 404) for another tenant's host, even though /hosts/{id} has no team param and the +// caller is effectively global-admin. Unpinned reads are unchanged. Runs only under MYSQL_TEST=1. +func TestOpenframeHostByIDTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "byid-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "byid-tenant-b"}) + require.NoError(t, err) + + mk := func(team *fleet.Team, key string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(key), + NodeKey: ptr.String("nk-" + key), + UUID: key, + Hostname: "host-" + key, + Platform: "darwin", + TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + + hostA := mk(teamA, "byid-A") + hostB := mk(teamB, "byid-B") + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("Host: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.Host(ctxA, hostA.ID) + require.NoError(t, err) + require.Equal(t, hostA.ID, got.ID) + + _, err = ds.Host(ctxA, hostB.ID) + require.True(t, fleet.IsNotFound(err), "foreign host by id must be NotFound, got %v", err) + }) + + t.Run("HostLite: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.HostLite(ctxA, hostA.ID) + require.NoError(t, err) + require.Equal(t, hostA.ID, got.ID) + + _, err = ds.HostLite(ctxA, hostB.ID) + require.True(t, fleet.IsNotFound(err), "foreign host lite by id must be NotFound, got %v", err) + }) + + t.Run("HostByIdentifier: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.HostByIdentifier(ctxA, "byid-A") + require.NoError(t, err) + require.Equal(t, hostA.ID, got.ID) + + _, err = ds.HostByIdentifier(ctxA, "byid-B") + require.True(t, fleet.IsNotFound(err), "foreign host by identifier must be NotFound, got %v", err) + }) + + t.Run("HostLiteByIdentifier/ByID: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.HostLiteByIdentifier(ctxA, "byid-A") + require.NoError(t, err) + require.Equal(t, hostA.ID, got.ID) + + _, err = ds.HostLiteByIdentifier(ctxA, "byid-B") + require.True(t, fleet.IsNotFound(err), "foreign host-lite by identifier must be NotFound, got %v", err) + + _, err = ds.HostLiteByID(ctxA, hostB.ID) + require.True(t, fleet.IsNotFound(err), "foreign host-lite by id must be NotFound, got %v", err) + }) + + t.Run("ListHostsLiteByIDs: foreign ids drop out", func(t *testing.T) { + hosts, err := ds.ListHostsLiteByIDs(ctxA, []uint{hostA.ID, hostB.ID}) + require.NoError(t, err) + require.Len(t, hosts, 1) + require.Equal(t, hostA.ID, hosts[0].ID) + }) + + t.Run("HostIDsByIdentifier: foreign identifiers resolve to nothing", func(t *testing.T) { + adminFilter := fleet.TeamFilter{User: test.UserAdmin} + ids, err := ds.HostIDsByIdentifier(ctxA, adminFilter, []string{"byid-A", "byid-B", "host-byid-B"}) + require.NoError(t, err) + require.Equal(t, []uint{hostA.ID}, ids) + }) + + t.Run("unpinned baseline: foreign host still readable", func(t *testing.T) { + got, err := ds.Host(ctx, hostB.ID) + require.NoError(t, err) + require.Equal(t, hostB.ID, got.ID) + + lite, err := ds.HostLiteByIdentifier(ctx, "byid-B") + require.NoError(t, err) + require.Equal(t, hostB.ID, lite.ID) + + hosts, err := ds.ListHostsLiteByIDs(ctx, []uint{hostA.ID, hostB.ID}) + require.NoError(t, err) + require.Len(t, hosts, 2) + + ids, err := ds.HostIDsByIdentifier(ctx, fleet.TeamFilter{User: test.UserAdmin}, []string{"byid-A", "byid-B"}) + require.NoError(t, err) + require.Len(t, ids, 2) + }) +} + +// TestOpenframeListHostsTeamFence verifies the OPENFRAME(mysql-multitenancy) fence in +// applyHostFilters: ListHosts / CountHosts scope to the process's pinned team even when the caller's +// TeamFilter is a global-admin (matches all teams) — the live SDK searchHosts path. Runs only under +// MYSQL_TEST=1. +func TestOpenframeListHostsTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // Global-admin filter: matches all teams, so only the OpenFrame fence restricts the result. + filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "lh-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "lh-tenant-b"}) + require.NoError(t, err) + + mk := func(team *fleet.Team, key string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), LabelUpdatedAt: time.Now(), PolicyUpdatedAt: time.Now(), SeenTime: time.Now(), + OsqueryHostID: ptr.String(key), NodeKey: ptr.String("nk-" + key), UUID: key, Hostname: "host-" + key, + Platform: "darwin", TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + hostA := mk(teamA, "lh-A") + hostB := mk(teamB, "lh-B") + + // Baseline (unpinned): global-admin sees both. + all, err := ds.ListHosts(ctx, filter, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, all, 2) + n, err := ds.CountHosts(ctx, filter, fleet.HostListOptions{}) + require.NoError(t, err) + require.Equal(t, 2, n) + + // Pinned to team A: only team A's host, despite the global-admin filter. + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + got, err := ds.ListHosts(ctxA, filter, fleet.HostListOptions{}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, hostA.ID, got[0].ID) + + n, err = ds.CountHosts(ctxA, filter, fleet.HostListOptions{}) + require.NoError(t, err) + require.Equal(t, 1, n) + + // Team B's host is invisible to the team-A-pinned process. + for _, h := range got { + require.NotEqual(t, hostB.ID, h.ID) + } +} + +// TestOpenframeDeleteHostsTeamFence verifies the OPENFRAME(mysql-multitenancy) fence in +// deleteHosts: a team-scoped request deleting a mix of its own and another tenant's host ids +// only deletes its own. Runs only under MYSQL_TEST=1. +func TestOpenframeDeleteHostsTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "del-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "del-tenant-b"}) + require.NoError(t, err) + + mk := func(team *fleet.Team, key string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(key), + NodeKey: ptr.String("nk-" + key), + UUID: key, + Hostname: "host-" + key, + Platform: "darwin", + TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + + hostA := mk(teamA, "del-A") + hostB := mk(teamB, "del-B") + + // Scope to team A, then try to delete both A (owned) and B (other tenant). + require.NoError(t, ds.DeleteHosts(fleet.NewOpenframeTeamContext(ctx, teamA.ID), []uint{hostA.ID, hostB.ID})) + + // A is deleted; B (another tenant) is untouched. + _, err = ds.Host(ctx, hostA.ID) + require.True(t, fleet.IsNotFound(err)) + + gotB, err := ds.Host(ctx, hostB.ID) + require.NoError(t, err) + require.Equal(t, hostB.ID, gotB.ID) +} diff --git a/server/datastore/mysql/labels.go b/server/datastore/mysql/labels.go index 4bfa8aadb57..1029d785fb2 100644 --- a/server/datastore/mysql/labels.go +++ b/server/datastore/mysql/labels.go @@ -546,7 +546,7 @@ func batchHostIds(hostIds []uint) [][]uint { func (ds *Datastore) GetLabelSpecs(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) { var specs []*fleet.LabelSpec // Get basic specs - query, params, err := applyLabelTeamFilter(`SELECT id, name, description, query, platform, + query, params, err := applyLabelTeamFilter(ctx, `SELECT id, name, description, query, platform, label_type, label_membership_type, criteria, team_id FROM labels l`, filter) if err != nil { @@ -577,7 +577,7 @@ func (ds *Datastore) GetLabelSpecs(ctx context.Context, filter fleet.TeamFilter) func (ds *Datastore) GetLabelSpec(ctx context.Context, filter fleet.TeamFilter, name string) (*fleet.LabelSpec, error) { var specs []*fleet.LabelSpec - query, params, err := applyLabelTeamFilter(` + query, params, err := applyLabelTeamFilter(ctx, ` SELECT l.id, l.name, l.description, l.query, l.platform, l.label_type, l.label_membership_type, l.criteria, l.team_id FROM labels l WHERE l.name = ?`, filter, name) @@ -627,6 +627,15 @@ func (ds *Datastore) getLabelHostIDs(ctx context.Context, label *fleet.LabelSpec // NewLabel creates a new fleet.Label func (ds *Datastore) NewLabel(ctx context.Context, label *fleet.Label, opts ...fleet.OptionalArg) (*fleet.Label, error) { + // >>> OPENFRAME(mysql-multitenancy): re-home a tenant's new custom label to its team so it is + // not created as a shared global (team_id NULL) label visible to every tenant. Built-in labels + // (created at setup, unpinned) keep team_id NULL. + if label.LabelType != fleet.LabelTypeBuiltIn && label.TeamID == nil { + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + label.TeamID = &teamID + } + } + // <<< OPENFRAME(mysql-multitenancy) query := ` INSERT INTO labels ( name, @@ -687,7 +696,7 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string, filter fleet. return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var labelID uint - query, params, err := applyLabelTeamFilter(`select l.id FROM labels l WHERE l.name = ?`, filter, name) + query, params, err := applyLabelTeamFilter(ctx, `select l.id FROM labels l WHERE l.name = ?`, filter, name) if err != nil { return ctxerr.Wrap(ctx, err, "getting label id to delete") } @@ -752,7 +761,7 @@ func deleteLabelsInTx(ctx context.Context, tx sqlx.ExtContext, labelIDs []uint) // LabelByName returns a fleet.Label identified by name if one exists and is accessible to the specified user. func (ds *Datastore) LabelByName(ctx context.Context, name string, teamFilter fleet.TeamFilter) (*fleet.Label, error) { - stmt, params, err := applyLabelTeamFilter("SELECT l.* FROM labels l WHERE l.name = ?", teamFilter, name) + stmt, params, err := applyLabelTeamFilter(ctx, "SELECT l.* FROM labels l WHERE l.name = ?", teamFilter, name) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building label select query") } @@ -794,7 +803,7 @@ func (ds *Datastore) labelDB(ctx context.Context, lid uint, teamFilter fleet.Tea WHERE l.id = ? `, ds.whereFilterHostsByTeams(teamFilter, "h")) - stmt, params, err := applyLabelTeamFilter(stmt, teamFilter, lid) + stmt, params, err := applyLabelTeamFilter(ctx, stmt, teamFilter, lid) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "building label select query") } @@ -852,7 +861,7 @@ func (ds *Datastore) ListLabels(ctx context.Context, filter fleet.TeamFilter, op ) } - query, params, err := applyLabelTeamFilter(query, filter) + query, params, err := applyLabelTeamFilter(ctx, query, filter) if err != nil { return nil, err } @@ -877,42 +886,52 @@ func (ds *Datastore) ListLabels(ctx context.Context, filter fleet.TeamFilter, op var errInaccessibleTeam = errors.New("The team ID you provided refers to a team that either does not exist or you do not have permission to access.") -// applyLabelTeamFilter requires the labels table to be aliased as "l" to work -func applyLabelTeamFilter(query string, filter fleet.TeamFilter, initialParams ...any) (string, []any, error) { - // using this rather than a "contains a WHERE" check because some queries have subqueries - // but don't have any parameters for those subqueries - whereOrAnd := " WHERE " - if len(initialParams) > 0 { - whereOrAnd = " AND " - } +// applyLabelTeamFilter requires the labels table to be aliased as "l" to work. It is the single +// chokepoint every label read/delete/search routes through — see the OPENFRAME block below. +func applyLabelTeamFilter(ctx context.Context, query string, filter fleet.TeamFilter, initialParams ...any) (string, []any, error) { + var conds []string + params := append([]any{}, initialParams...) - // apply sqlx.In if we had initial params, as they may include slices for where-ins other than the team one - maybeIn := func(query string) (string, []any, error) { + switch { + case filter.User == nil: // fall back to safe (global-only) filter if this happens (it shouldn't) + conds = append(conds, "l.team_id IS NULL") + case filter.TeamID != nil: + if *filter.TeamID == 0 { // global labels only; any user can see them + conds = append(conds, "l.team_id IS NULL") + } else if !filter.UserCanAccessSelectedTeam() { + return "", nil, fleet.NewUserMessageError(errInaccessibleTeam, 403) + } else { // global labels plus that team's labels + conds = append(conds, "(l.team_id IS NULL OR l.team_id = ?)") + params = append(params, *filter.TeamID) + } + case !filter.User.HasAnyGlobalRole() && filter.User.HasAnyTeamRole(): // filter to teams user can see + conds = append(conds, "(l.team_id IS NULL OR l.team_id IN (?))") + params = append(params, filter.User.TeamIDsWithAnyRole()) + } // else user has a global role: no team filtering + + // >>> OPENFRAME(mysql-multitenancy): apply the tenant scope here once, for every label + // read/delete/search. Built-in/global labels (team_id NULL) stay visible to every tenant; a + // pinned tenant additionally sees only its own custom labels. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + conds = append(conds, "(l.team_id IS NULL OR l.openframe_team_key = ?)") + params = append(params, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + + if len(conds) == 0 { + // No filtering (global role, unpinned). Preserve the original behavior: only expand via + // sqlx.In when there are params, since some queries carry param-less subqueries. if len(initialParams) > 0 { return sqlx.In(query, initialParams...) } return query, nil, nil } - if filter.User == nil { // fall back to safe (global-only) filter if this happens (it shouldn't) - return maybeIn(query + whereOrAnd + " l.team_id IS NULL") - } - - if filter.TeamID != nil { - if *filter.TeamID == 0 { // global labels only; any user can see them - return maybeIn(query + whereOrAnd + "l.team_id IS NULL") - } else if !filter.UserCanAccessSelectedTeam() { - return "", nil, fleet.NewUserMessageError(errInaccessibleTeam, 403) - } // else user can see the team labels they're asking for; return global labels plus that team's labels - - return sqlx.In(query+whereOrAnd+"(l.team_id IS NULL OR l.team_id = ?)", append(initialParams, *filter.TeamID)...) + whereOrAnd := " WHERE " + if len(initialParams) > 0 { + whereOrAnd = " AND " } - - if !filter.User.HasAnyGlobalRole() && filter.User.HasAnyTeamRole() { // filter to teams user can see - return sqlx.In(query+whereOrAnd+"(l.team_id IS NULL OR l.team_id IN (?))", append(initialParams, filter.User.TeamIDsWithAnyRole())...) - } // else user exists and has a global role, so we don't need to filter out any team labels - - return maybeIn(query) + return sqlx.In(query+whereOrAnd+strings.Join(conds, " AND "), params...) } func platformForHost(host *fleet.Host) string { @@ -1145,7 +1164,7 @@ var hostsInLabelAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ // ListHostsInLabel returns a list of fleet.Host that are associated // with fleet.Label referenced by Label ID func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) ([]*fleet.Host, error) { - labelCheckSql, labelCheckParams, err := applyLabelTeamFilter(`SELECT l.id FROM labels l WHERE id = ?`, filter, lid) + labelCheckSql, labelCheckParams, err := applyLabelTeamFilter(ctx, `SELECT l.id FROM labels l WHERE id = ?`, filter, lid) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to confirm label existence") } @@ -1427,7 +1446,7 @@ func (ds *Datastore) searchLabelsWithOmits(ctx context.Context, filter fleet.Tea `, ds.whereFilterHostsByTeams(filter, "h"), ) - sql, args, err := applyLabelTeamFilter(sqlStatement, filter, transformQuery(query), omit) + sql, args, err := applyLabelTeamFilter(ctx, sqlStatement, filter, transformQuery(query), omit) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query for labels with omits") } @@ -1511,7 +1530,7 @@ func (ds *Datastore) searchLabelsDefault(ctx context.Context, filter fleet.TeamF } var labels []*fleet.Label - sql, args, err := applyLabelTeamFilter(sql, filter, in) + sql, args, err := applyLabelTeamFilter(ctx, sql, filter, in) if err != nil { return nil, ctxerr.Wrap(ctx, err, "searching default labels") } @@ -1557,7 +1576,7 @@ func (ds *Datastore) SearchLabels(ctx context.Context, filter fleet.TeamFilter, `, ds.whereFilterHostsByTeams(filter, "h"), ) - sql, args, err := applyLabelTeamFilter(sql, filter, transformQuery(query)) + sql, args, err := applyLabelTeamFilter(ctx, sql, filter, transformQuery(query)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query for searching labels") } @@ -1582,7 +1601,7 @@ func (ds *Datastore) LabelIDsByName(ctx context.Context, names []string, filter return map[string]uint{}, nil } - sql, args, err := applyLabelTeamFilter(`SELECT l.id, l.name FROM labels l WHERE l.name IN (?)`, filter, names) + sql, args, err := applyLabelTeamFilter(ctx, `SELECT l.id, l.name FROM labels l WHERE l.name IN (?)`, filter, names) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to get label ids by name") } @@ -1605,7 +1624,7 @@ func (ds *Datastore) LabelsByName(ctx context.Context, names []string, filter fl return map[string]*fleet.Label{}, nil } - sqlStatement, args, err := applyLabelTeamFilter(`SELECT l.* FROM labels l WHERE l.name IN (?)`, filter, names) + sqlStatement, args, err := applyLabelTeamFilter(ctx, `SELECT l.* FROM labels l WHERE l.name IN (?)`, filter, names) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to get label ids by name") } @@ -1704,7 +1723,7 @@ func (ds *Datastore) LabelsSummary(ctx context.Context, filter fleet.TeamFilter) var labelsSummary []*fleet.LabelSummary query := "SELECT id, name, description, label_type, team_id FROM labels l" - query, params, err := applyLabelTeamFilter(query, filter) + query, params, err := applyLabelTeamFilter(ctx, query, filter) if err != nil { return nil, err } diff --git a/server/datastore/mysql/labels_openframe_test.go b/server/datastore/mysql/labels_openframe_test.go new file mode 100644 index 00000000000..4beb8aa00f7 --- /dev/null +++ b/server/datastore/mysql/labels_openframe_test.go @@ -0,0 +1,97 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +// TestOpenframeLabelTeamFence verifies the OPENFRAME(mysql-multitenancy) label scoping: a pinned +// tenant sees built-in (global) labels plus its own custom labels, never another tenant's custom +// labels — across list, by-id, by-name, and delete. New custom labels are re-homed to the pinned +// team. The datastore fences are the boundary; the caller's TeamFilter uses a global-admin user +// (what OpenFrame's api-only token maps to) to prove role is not the boundary. Runs under MYSQL_TEST=1. +func TestOpenframeLabelTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + require.NoError(t, ds.MigrateOpenframe(ctx)) // needs the openframe_team_key generated column + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "lbl-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "lbl-b"}) + require.NoError(t, err) + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + ctxB := fleet.NewOpenframeTeamContext(ctx, teamB.ID) + admin := fleet.TeamFilter{User: test.UserAdmin, IncludeObserver: true} + + // A built-in (global) label — created unpinned, team_id stays NULL, visible to everyone. + builtin, err := ds.NewLabel(ctx, &fleet.Label{Name: "All Hosts", Query: "SELECT 1", LabelType: fleet.LabelTypeBuiltIn}) + require.NoError(t, err) + require.Nil(t, builtin.TeamID, "built-in labels stay global") + + // Each tenant creates a custom label while pinned → re-homed to its own team. + labelA, err := ds.NewLabel(ctxA, &fleet.Label{Name: "custom-A", Query: "SELECT 1"}) + require.NoError(t, err) + require.NotNil(t, labelA.TeamID) + require.Equal(t, teamA.ID, *labelA.TeamID) + + labelB, err := ds.NewLabel(ctxB, &fleet.Label{Name: "custom-B", Query: "SELECT 1"}) + require.NoError(t, err) + require.Equal(t, teamB.ID, *labelB.TeamID) + + names := func(ls []*fleet.Label) map[string]bool { + m := map[string]bool{} + for _, l := range ls { + m[l.Name] = true + } + return m + } + + t.Run("ListLabels shows built-ins + own custom, not the other tenant's", func(t *testing.T) { + got, err := ds.ListLabels(ctxA, admin, fleet.ListOptions{}, false) + require.NoError(t, err) + n := names(got) + require.True(t, n["All Hosts"], "built-in label must be visible") + require.True(t, n["custom-A"], "own custom label must be visible") + require.False(t, n["custom-B"], "another tenant's custom label must NOT be visible") + }) + + t.Run("by-id: foreign custom label is NotFound, own + built-in resolve", func(t *testing.T) { + _, _, err := ds.Label(ctxA, labelB.ID, admin) + require.True(t, fleet.IsNotFound(err), "foreign label by id must be NotFound, got %v", err) + + _, _, err = ds.Label(ctxA, labelA.ID, admin) + require.NoError(t, err) + _, _, err = ds.Label(ctxA, builtin.ID, admin) + require.NoError(t, err, "built-in label must resolve for any tenant") + }) + + t.Run("by-name: same name in another tenant is not visible", func(t *testing.T) { + // Both tenants define a label with the same name; each sees only its own. + shА, err := ds.NewLabel(ctxA, &fleet.Label{Name: "shared-name", Query: "SELECT 1"}) + require.NoError(t, err) + shB, err := ds.NewLabel(ctxB, &fleet.Label{Name: "shared-name", Query: "SELECT 1"}) + require.NoError(t, err) + require.NotEqual(t, shА.ID, shB.ID) + + gotA, err := ds.LabelByName(ctxA, "shared-name", admin) + require.NoError(t, err) + require.Equal(t, shА.ID, gotA.ID, "must resolve to the caller tenant's label") + }) + + t.Run("delete: cannot delete another tenant's label", func(t *testing.T) { + // Tenant A tries to delete tenant B's label by name → NotFound; B's label survives. + err := ds.DeleteLabel(ctxA, "custom-B", admin) + require.True(t, fleet.IsNotFound(err), "deleting a foreign label must be NotFound, got %v", err) + _, _, err = ds.Label(ctxB, labelB.ID, admin) + require.NoError(t, err, "tenant B's label must survive tenant A's delete attempt") + + // Tenant B can delete its own. + require.NoError(t, ds.DeleteLabel(ctxB, "custom-B", admin)) + }) +} diff --git a/server/datastore/mysql/migrations/openframe/20260620000001_ScopeLabelUniqueNameToTeam.go b/server/datastore/mysql/migrations/openframe/20260620000001_ScopeLabelUniqueNameToTeam.go new file mode 100644 index 00000000000..30d5fbb425b --- /dev/null +++ b/server/datastore/mysql/migrations/openframe/20260620000001_ScopeLabelUniqueNameToTeam.go @@ -0,0 +1,85 @@ +package openframe + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260620000001, Down_20260620000001) +} + +// Up_20260620000001 changes label name uniqueness from a global UNIQUE(name) to a per-team +// unique so that, under shared-database multitenancy, different teams (tenants) +// may each define a label with the same name. Built-in / global labels keep team_id = NULL and +// remain shared. +// +// The unique is NOT a plain (team_id, name): MySQL treats NULL as distinct in unique keys, so +// that composite would silently stop enforcing name uniqueness among team_id = NULL rows — the +// only rows single-tenant / flag-off Fleet has — and would also break ApplyLabelSpecs' +// INSERT ... ON DUPLICATE KEY UPDATE upsert (duplicate built-in labels instead of updates). +// Instead, the unique is built over a VIRTUAL generated column openframe_team_key = +// IFNULL(team_id, 0), which collapses all NULL-team rows onto the sentinel 0 (team ids are +// auto-increment starting at 1, so 0 can never collide with a real team): +// - flag off / pre-backfill: every row has team_id = NULL ⇒ key 0 ⇒ UNIQUE(name) exactly as +// upstream, a bit for bit; +// - per tenant: at most one label per (team, name), which is the multitenancy invariant. +// +// Column order (name, openframe_team_key) keeps the index usable by the existing +// `WHERE name = ?` prefix lookups (LabelByName, LabelsByName). +// Upstream precedent for UNIQUE over a generated column: tables/20240905200001_AddPoliciesToNoTeam.go. +// The column is mapped by an ignored field on fleet.Label so `SELECT l.*` sqlx scans keep working. +// +// Idempotent. +// +// SEMANTIC-CONFLICT WATCHLIST (openframe/docs/upstream-sync-conflict-resolution.md): +// this ALTERs the upstream `labels` table from the OpenFrame pipeline. If a future +// upstream release changes the labels table's uniqueness or rebuilds the table, +// re-verify this migration after the sync. +func Up_20260620000001(tx *sql.Tx) error { + const ( + table = "labels" + oldIndex = "idx_label_unique_name" + newIndex = "idx_label_team_name" + genColumn = "openframe_team_key" + ) + + hasCol, err := columnExists(tx, table, genColumn) + if err != nil { + return fmt.Errorf("checking %s column: %w", genColumn, err) + } + if !hasCol { + if _, err := tx.Exec( + "ALTER TABLE labels ADD COLUMN openframe_team_key INT UNSIGNED GENERATED ALWAYS AS (IFNULL(team_id, 0)) VIRTUAL", + ); err != nil { + return fmt.Errorf("adding %s generated column: %w", genColumn, err) + } + } + + hasNew, err := indexExists(tx, table, newIndex) + if err != nil { + return fmt.Errorf("checking %s index: %w", newIndex, err) + } + if !hasNew { + // Safe to add: the existing UNIQUE(name) guarantees (name, IFNULL(team_id,0)) is + // already unique. + if _, err := tx.Exec("ALTER TABLE labels ADD UNIQUE KEY idx_label_team_name (name, openframe_team_key)"); err != nil { + return fmt.Errorf("adding %s unique index: %w", newIndex, err) + } + } + + hasOld, err := indexExists(tx, table, oldIndex) + if err != nil { + return fmt.Errorf("checking %s index: %w", oldIndex, err) + } + if hasOld { + if _, err := tx.Exec("ALTER TABLE labels DROP INDEX idx_label_unique_name"); err != nil { + return fmt.Errorf("dropping %s index: %w", oldIndex, err) + } + } + return nil +} + +func Down_20260620000001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go b/server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go new file mode 100644 index 00000000000..4b49e5c69f7 --- /dev/null +++ b/server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go @@ -0,0 +1,100 @@ +package openframe + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260626000001, Down_20260626000001) +} + +// Up_20260626000001 changes host osquery-identity uniqueness from a global +// UNIQUE(osquery_host_id) to a per-team unique so that, under shared-database +// multitenancy, the SAME physical device (same hardware UUID / +// osquery identifier) may exist in more than one tenant team. +// +// Why this is needed: the enrollment matcher fence (matchHostDuringEnrollment) scopes host matching +// to the request's tenant team. So when a device already enrolled in tenant A +// enrolls in tenant B, the matcher correctly finds no team-B match and falls to +// a plain INSERT — which, with a GLOBAL UNIQUE(osquery_host_id), fails with a +// duplicate-key error and blocks the legitimate cross-tenant enrollment (and can +// also abort the data merge). Scoping the unique per team makes "at most one +// host per (team, osquery_host_id)" the invariant — exactly what the fence +// enforces on the read side. +// +// The unique is NOT a plain (team_id, osquery_host_id): MySQL treats NULL as +// distinct in unique keys, so that composite would silently stop enforcing +// osquery-identity uniqueness among team_id = NULL ("No team") rows — the only +// rows single-tenant / flag-off Fleet has. Instead, the unique is built over a +// VIRTUAL generated column openframe_team_key = IFNULL(team_id, 0), which +// collapses all NULL-team rows onto the sentinel 0 (team ids are auto-increment +// starting at 1, so 0 can never collide with a real team): +// - flag off / pre-backfill: every row has team_id = NULL ⇒ key 0 ⇒ +// UNIQUE(osquery_host_id) exactly as upstream, a bit for bit; +// - per tenant: at most one host per (team, osquery_host_id). +// +// Column order (osquery_host_id, openframe_team_key) keeps the index usable by +// the existing `WHERE osquery_host_id = ?` prefix lookups (matchHostDuringEnrollment). +// osquery_host_id itself stays nullable; rows with a NULL osquery_host_id remain +// exempt from the unique (any NULL key part ⇒ no constraint), as upstream. +// Upstream precedent for UNIQUE over a generated column: tables/20240905200001_AddPoliciesToNoTeam.go. +// (hosts has no `SELECT *` sqlx scans, so no struct-mapping change is needed.) +// +// node_key / orbit_node_key are deliberately LEFT global-unique: they are random +// auth secrets (bearer tokens) resolved by `WHERE node_key = ?` with no team +// context, so global uniqueness is correct there. +// +// Idempotent. +// +// SEMANTIC-CONFLICT WATCHLIST (openframe/docs/upstream-sync-conflict-resolution.md): +// this ALTERs the upstream `hosts` table from the OpenFrame pipeline. If a future +// upstream release changes the hosts table's uniqueness (osquery_host_id) or +// rebuilds the table, re-verify this migration after the sync. +func Up_20260626000001(tx *sql.Tx) error { + const ( + table = "hosts" + oldIndex = "idx_osquery_host_id" + newIndex = "idx_hosts_team_osquery_host_id" + genColumn = "openframe_team_key" + ) + + hasCol, err := columnExists(tx, table, genColumn) + if err != nil { + return fmt.Errorf("checking %s column: %w", genColumn, err) + } + if !hasCol { + if _, err := tx.Exec( + "ALTER TABLE hosts ADD COLUMN openframe_team_key INT UNSIGNED GENERATED ALWAYS AS (IFNULL(team_id, 0)) VIRTUAL", + ); err != nil { + return fmt.Errorf("adding %s generated column: %w", genColumn, err) + } + } + + hasNew, err := indexExists(tx, table, newIndex) + if err != nil { + return fmt.Errorf("checking %s index: %w", newIndex, err) + } + if !hasNew { + // Safe to add: the existing global UNIQUE(osquery_host_id) guarantees the + // superset (osquery_host_id, IFNULL(team_id,0)) is already unique, so this cannot fail. + if _, err := tx.Exec("ALTER TABLE hosts ADD UNIQUE KEY idx_hosts_team_osquery_host_id (osquery_host_id, openframe_team_key)"); err != nil { + return fmt.Errorf("adding %s unique index: %w", newIndex, err) + } + } + + hasOld, err := indexExists(tx, table, oldIndex) + if err != nil { + return fmt.Errorf("checking %s index: %w", oldIndex, err) + } + if hasOld { + if _, err := tx.Exec("ALTER TABLE hosts DROP INDEX idx_osquery_host_id"); err != nil { + return fmt.Errorf("dropping %s index: %w", oldIndex, err) + } + } + return nil +} + +func Down_20260626000001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/openframe/20260629000001_AddTeamsOpenframeTenantUUID.go b/server/datastore/mysql/migrations/openframe/20260629000001_AddTeamsOpenframeTenantUUID.go new file mode 100644 index 00000000000..e62a30bbdbb --- /dev/null +++ b/server/datastore/mysql/migrations/openframe/20260629000001_AddTeamsOpenframeTenantUUID.go @@ -0,0 +1,58 @@ +package openframe + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260629000001, Down_20260629000001) +} + +// Up_20260629000001 adds the bridge column `teams.openframe_tenant_uuid` (+ a unique index) that +// maps a Flamingo tenant UUID to its Fleet team. Under shared-database multitenancy +// each OpenFrame process is pinned to its tenant by the Flamingo tenant UUID +// (FLEET_OPENFRAME_TENANT_UUID), which is resolved at startup to Fleet's integer `team_id` via this +// column (EnsureOpenframeTeamID). Fleet's `team_id` stays an int (referenced by FKs everywhere); the +// UUID stays the platform identity — this column is the bridge, so neither id format changes. +// +// The column is NULL for non-OpenFrame teams; the unique index permits many NULLs (MySQL treats +// NULL as distinct) and enforces one team per tenant UUID. +// +// Idempotent. +// +// SEMANTIC-CONFLICT WATCHLIST (openframe/docs/upstream-sync-conflict-resolution.md): +// this ALTERs the upstream `teams` table from the OpenFrame pipeline. If a future upstream release +// rebuilds the teams table, re-verify this migration after the sync. +func Up_20260629000001(tx *sql.Tx) error { + const ( + table = "teams" + column = "openframe_tenant_uuid" + index = "idx_teams_openframe_tenant_uuid" + ) + + hasCol, err := columnExists(tx, table, column) + if err != nil { + return fmt.Errorf("checking %s.%s column: %w", table, column, err) + } + if !hasCol { + if _, err := tx.Exec("ALTER TABLE teams ADD COLUMN openframe_tenant_uuid CHAR(36) NULL"); err != nil { + return fmt.Errorf("adding %s column: %w", column, err) + } + } + + hasIdx, err := indexExists(tx, table, index) + if err != nil { + return fmt.Errorf("checking %s index: %w", index, err) + } + if !hasIdx { + if _, err := tx.Exec("ALTER TABLE teams ADD UNIQUE KEY idx_teams_openframe_tenant_uuid (openframe_tenant_uuid)"); err != nil { + return fmt.Errorf("adding %s unique index: %w", index, err) + } + } + return nil +} + +func Down_20260629000001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/openframe/migration.go b/server/datastore/mysql/migrations/openframe/migration.go index 3d79ccdbbce..64980c0b889 100644 --- a/server/datastore/mysql/migrations/openframe/migration.go +++ b/server/datastore/mysql/migrations/openframe/migration.go @@ -1,6 +1,8 @@ package openframe import ( + "database/sql" + "github.com/fleetdm/fleet/v4/server/goose" ) @@ -8,3 +10,32 @@ import ( // from the upstream Fleet migration pipeline (migration_status_tables). // This avoids version conflicts when rebasing onto newer upstream releases. var MigrationClient = goose.New("migration_status_openframe", goose.MySqlDialect{}) + +// indexExists reports whether the named index exists on table in the current database. +// OpenFrame migrations use it to make index DDL idempotent, since MySQL has no +// DROP/CREATE INDEX IF [NOT] EXISTS. +func indexExists(tx *sql.Tx, table, index string) (bool, error) { + var count int + if err := tx.QueryRow( + `SELECT COUNT(1) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?`, + table, index, + ).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +// columnExists reports whether the named column exists on table in the current database. +// Used to make ADD COLUMN DDL idempotent (MySQL has no ADD COLUMN IF NOT EXISTS). +func columnExists(tx *sql.Tx, table, column string) (bool, error) { + var count int + if err := tx.QueryRow( + `SELECT COUNT(1) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, + table, column, + ).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} diff --git a/server/datastore/mysql/migrations_openframe_test.go b/server/datastore/mysql/migrations_openframe_test.go index e44dff4883a..09dc263c85f 100644 --- a/server/datastore/mysql/migrations_openframe_test.go +++ b/server/datastore/mysql/migrations_openframe_test.go @@ -13,7 +13,9 @@ package mysql import ( "context" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/stretchr/testify/require" ) @@ -40,3 +42,221 @@ func TestMigrateOpenframeIdempotent(t *testing.T) { require.Equal(t, 1, n, "expected table %s to exist after MigrateOpenframe", table) } } + +// TestMigrateOpenframeLabelsUniqueByTeam verifies 20260620000001: labels uniqueness moves +// from global UNIQUE(name) to a per-team unique over the generated column +// openframe_team_key = IFNULL(team_id, 0), so tenants can reuse label names while +// NULL-team rows (single-tenant / flag-off mode) keep the original UNIQUE(name) guarantee. +func TestMigrateOpenframeLabelsUniqueByTeam(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + indexCount := func(index string) int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE table_schema = DATABASE() AND table_name = 'labels' AND index_name = ?", + index)) + return n + } + colCount := func() int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'labels' AND column_name = 'openframe_team_key'")) + return n + } + + // Baseline from schema.sql: global UNIQUE(name) present, team-scoped one absent. + require.Positive(t, indexCount("idx_label_unique_name")) + require.Equal(t, 0, indexCount("idx_label_team_name")) + require.Equal(t, 0, colCount()) + + require.NoError(t, ds.MigrateOpenframe(ctx)) + + // After: generated column + team-scoped unique present, the global one dropped. + require.Equal(t, 0, indexCount("idx_label_unique_name")) + require.Positive(t, indexCount("idx_label_team_name")) + require.Equal(t, 1, colCount()) + + // Idempotent re-run. + require.NoError(t, ds.MigrateOpenframe(ctx)) + require.Equal(t, 0, indexCount("idx_label_unique_name")) + require.Positive(t, indexCount("idx_label_team_name")) + require.Equal(t, 1, colCount()) + + // Behavior — NULL-team rows keep the original global uniqueness (IFNULL collapses + // them onto sentinel 0): a duplicate name with no team must still be rejected. + _, err := ds.NewLabel(ctx, &fleet.Label{Name: "openframe-null-dup", Query: "SELECT 1"}) + require.NoError(t, err) + _, err = ds.NewLabel(ctx, &fleet.Label{Name: "openframe-null-dup", Query: "SELECT 1"}) + require.Error(t, err, "duplicate NULL-team label name must be rejected") + + // Behavior — different teams may reuse the same label name (the multitenancy point), + // and a team-scoped name does not collide with the NULL-team copy. + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-labels-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-labels-b"}) + require.NoError(t, err) + _, err = ds.NewLabel(ctx, &fleet.Label{Name: "openframe-per-team", Query: "SELECT 1"}) + require.NoError(t, err) + _, err = ds.NewLabel(ctx, &fleet.Label{Name: "openframe-per-team", Query: "SELECT 1", TeamID: &teamA.ID}) + require.NoError(t, err) + _, err = ds.NewLabel(ctx, &fleet.Label{Name: "openframe-per-team", Query: "SELECT 1", TeamID: &teamB.ID}) + require.NoError(t, err) + _, err = ds.NewLabel(ctx, &fleet.Label{Name: "openframe-per-team", Query: "SELECT 1", TeamID: &teamA.ID}) + require.Error(t, err, "duplicate name within the same team must be rejected") +} + +// TestMigrateOpenframeApplyLabelSpecsUpsert verifies that ApplyLabelSpecs' +// INSERT ... ON DUPLICATE KEY UPDATE still upserts (not duplicates) NULL-team labels after +// 20260620000001 — the regression the generated-column unique exists to prevent: a plain +// (team_id, name) composite stops matching NULL-team rows and every spec apply would insert +// a new duplicate row instead of updating. +func TestMigrateOpenframeApplyLabelSpecsUpsert(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + require.NoError(t, ds.MigrateOpenframe(ctx)) + + spec := &fleet.LabelSpec{Name: "openframe-upsert", Query: "SELECT 1", Description: "v1"} + require.NoError(t, ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})) + spec.Description = "v2" + require.NoError(t, ds.ApplyLabelSpecs(ctx, []*fleet.LabelSpec{spec})) + + var rows []struct { + Description string `db:"description"` + } + require.NoError(t, ds.writer(ctx).SelectContext(ctx, &rows, + "SELECT description FROM labels WHERE name = 'openframe-upsert'")) + require.Len(t, rows, 1, "re-applying the spec must update the existing row, not insert a duplicate") + require.Equal(t, "v2", rows[0].Description) +} + +// TestMigrateOpenframeHostIdentityUniqueByTeam verifies 20260626000001: host osquery-identity +// uniqueness moves from a global UNIQUE(osquery_host_id) to a per-team unique over the generated +// column openframe_team_key = IFNULL(team_id, 0), so the same device can enroll into more than +// one tenant team while NULL-team rows (single-tenant / flag-off mode) keep the original global +// uniqueness. node_key / orbit_node_key stay global-unique (auth secrets) and must be untouched. +func TestMigrateOpenframeHostIdentityUniqueByTeam(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + indexCount := func(index string) int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE table_schema = DATABASE() AND table_name = 'hosts' AND index_name = ?", + index)) + return n + } + colCount := func() int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'hosts' AND column_name = 'openframe_team_key'")) + return n + } + + // Baseline from schema.sql: global UNIQUE(osquery_host_id) present, team-scoped one absent. + require.Positive(t, indexCount("idx_osquery_host_id")) + require.Equal(t, 0, indexCount("idx_hosts_team_osquery_host_id")) + require.Equal(t, 0, colCount()) + + require.NoError(t, ds.MigrateOpenframe(ctx)) + + // After: generated column + team-scoped unique present, the global one dropped. + require.Equal(t, 0, indexCount("idx_osquery_host_id")) + require.Positive(t, indexCount("idx_hosts_team_osquery_host_id")) + require.Equal(t, 1, colCount()) + + // The auth-secret uniques are left global-unique (not scoped to team). + require.Positive(t, indexCount("idx_host_unique_nodekey")) + require.Positive(t, indexCount("idx_host_unique_orbitnodekey")) + + // Idempotent re-run. + require.NoError(t, ds.MigrateOpenframe(ctx)) + require.Equal(t, 0, indexCount("idx_osquery_host_id")) + require.Positive(t, indexCount("idx_hosts_team_osquery_host_id")) + + // Behavior — NULL-team rows keep the original global uniqueness (IFNULL collapses them + // onto sentinel 0), and different teams may hold the same osquery identity. + newHost := func(osqueryID, nodeKey string, teamID *uint) error { + _, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: &osqueryID, + NodeKey: &nodeKey, + UUID: "uuid-" + nodeKey, + Hostname: "host-" + nodeKey, + TeamID: teamID, + }) + return err + } + require.NoError(t, newHost("of-mig-dup", "nk-mig-1", nil)) + require.Error(t, newHost("of-mig-dup", "nk-mig-2", nil), + "duplicate NULL-team osquery_host_id must be rejected") + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-hosts-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-hosts-b"}) + require.NoError(t, err) + require.NoError(t, newHost("of-mig-shared", "nk-mig-3", &teamA.ID)) + require.NoError(t, newHost("of-mig-shared", "nk-mig-4", &teamB.ID), + "the same device identity must be allowed in a different team") + require.Error(t, newHost("of-mig-shared", "nk-mig-5", &teamA.ID), + "duplicate osquery_host_id within the same team must be rejected") +} + +// TestMigrateOpenframeTeamsTenantUUID verifies 20260629000001: teams gains the +// openframe_tenant_uuid bridge column + unique index, idempotently. +func TestMigrateOpenframeTeamsTenantUUID(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + colCount := func() int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'teams' AND column_name = 'openframe_tenant_uuid'")) + return n + } + idxCount := func() int { + var n int + require.NoError(t, ds.writer(ctx).GetContext(ctx, &n, + "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE table_schema = DATABASE() AND table_name = 'teams' AND index_name = 'idx_teams_openframe_tenant_uuid'")) + return n + } + + // schema.sql is upstream-only: the column/index are absent before the openframe migrations. + require.Equal(t, 0, colCount()) + require.Equal(t, 0, idxCount()) + + require.NoError(t, ds.MigrateOpenframe(ctx)) + require.Equal(t, 1, colCount()) + require.Positive(t, idxCount()) + + // Idempotent re-run. + require.NoError(t, ds.MigrateOpenframe(ctx)) + require.Equal(t, 1, colCount()) + require.Positive(t, idxCount()) +} + +// TestOpenframeMigrationLock verifies AcquireOpenframeMigrationLock: a second contender times +// out while the lock is held (GET_LOCK is exclusive across sessions) and succeeds after release. +func TestOpenframeMigrationLock(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + release, err := ds.AcquireOpenframeMigrationLock(ctx, time.Second) + require.NoError(t, err) + + // A concurrent run must NOT get the lock while it is held. + _, err = ds.AcquireOpenframeMigrationLock(ctx, 0) + require.Error(t, err) + require.Contains(t, err.Error(), "openframe_fleet_migrations") + + release() + + // After release the lock is free again. + release2, err := ds.AcquireOpenframeMigrationLock(ctx, time.Second) + require.NoError(t, err) + release2() +} diff --git a/server/datastore/mysql/openframe.go b/server/datastore/mysql/openframe.go new file mode 100644 index 00000000000..c5ad7901d1d --- /dev/null +++ b/server/datastore/mysql/openframe.go @@ -0,0 +1,221 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/fleetdm/fleet/v4/server" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +// OPENFRAME(mysql-multitenancy): helpers for shared-database row-level tenant isolation. +// Each per-tenant Fleet process is pinned to one team via +// FLEET_OPENFRAME_TEAM_ID (fleet.OpenframeTeamID); these helpers scope by-id datastore +// operations to that team so one tenant cannot read/mutate another tenant's rows in the +// shared database. + +// filterHostIDsByTeam returns the subset of hostIDs that belong to teamID. It is used to +// fence destructive/by-id host operations to the calling process's pinned team. The order +// of the result is not significant. +func filterHostIDsByTeam(ctx context.Context, q sqlx.QueryerContext, hostIDs []uint, teamID uint) ([]uint, error) { + if len(hostIDs) == 0 { + return hostIDs, nil + } + stmt, args, err := sqlx.In(`SELECT id FROM hosts WHERE id IN (?) AND team_id = ?`, hostIDs, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building team-scoped host id filter") + } + var owned []uint + if err := sqlx.SelectContext(ctx, q, &owned, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "filtering host ids by team") + } + return owned, nil +} + +// filterPolicyIDsByTeam returns the subset of policy ids that belong to teamID. Used to fence +// by-id policy deletes to the calling process's pinned team on a shared DB. +func filterPolicyIDsByTeam(ctx context.Context, q sqlx.QueryerContext, ids []uint, teamID uint) ([]uint, error) { + if len(ids) == 0 { + return ids, nil + } + stmt, args, err := sqlx.In(`SELECT id FROM policies WHERE id IN (?) AND team_id = ?`, ids, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building team-scoped policy id filter") + } + var owned []uint + if err := sqlx.SelectContext(ctx, q, &owned, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "filtering policy ids by team") + } + return owned, nil +} + +// filterQueryIDsByTeam returns the subset of query ids that belong to teamID. Used to fence by-id +// query deletes to the calling process's pinned team on a shared DB. +func filterQueryIDsByTeam(ctx context.Context, q sqlx.QueryerContext, ids []uint, teamID uint) ([]uint, error) { + if len(ids) == 0 { + return ids, nil + } + stmt, args, err := sqlx.In(`SELECT id FROM queries WHERE id IN (?) AND team_id = ?`, ids, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building team-scoped query id filter") + } + var owned []uint + if err := sqlx.SelectContext(ctx, q, &owned, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "filtering query ids by team") + } + return owned, nil +} + +// openframeForeignTeam reports whether teamID is NOT this process's pinned team (only when pinned). +// Used to reject explicit-team (URL fleet_id) control-plane requests for another tenant on a shared +// DB — the team param is caller-supplied and is not itself a tenant boundary. Returns false when +// unpinned (no scope) so upstream behavior is unchanged. +func openframeForeignTeam(ctx context.Context, teamID uint) bool { + pinned, ok := fleet.OpenframeTeamID(ctx) + return ok && pinned != teamID +} + +// openframeScopePolicyHosts fences a host-assignment operation to this process's pinned team: it +// verifies the parent policy belongs to the team (NotFound otherwise) and returns the subset of +// hostIDs in the team. When unpinned it returns hostIDs unchanged. Pass nil hostIDs to use it as a +// parent-ownership guard only (e.g. for list endpoints). +func (ds *Datastore) openframeScopePolicyHosts(ctx context.Context, policyID uint, hostIDs []uint) ([]uint, error) { + teamID, ok := fleet.OpenframeTeamID(ctx) + if !ok { + return hostIDs, nil + } + // Verify on the primary: these fences guard writes (Add/Remove/Replace host assignments), a + // read-after-write where a lagging replica would spuriously 404 a just-created policy. + var x int + err := sqlx.GetContext(ctx, ds.writer(ctx), &x, `SELECT 1 FROM policies WHERE id = ? AND team_id = ?`, policyID, teamID) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ctxerr.Wrap(ctx, notFound("Policy").WithID(policyID)) + case err != nil: + return nil, ctxerr.Wrap(ctx, err, "verify policy team for host assignment") + } + return filterHostIDsByTeam(ctx, ds.writer(ctx), hostIDs, teamID) +} + +// openframeScopeQueryHosts is the query analog of openframeScopePolicyHosts. +func (ds *Datastore) openframeScopeQueryHosts(ctx context.Context, queryID uint, hostIDs []uint) ([]uint, error) { + teamID, ok := fleet.OpenframeTeamID(ctx) + if !ok { + return hostIDs, nil + } + // Verify on the primary (read-after-write; see openframeScopePolicyHosts). + var x int + err := sqlx.GetContext(ctx, ds.writer(ctx), &x, `SELECT 1 FROM queries WHERE id = ? AND team_id = ?`, queryID, teamID) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ctxerr.Wrap(ctx, notFound("Query").WithID(queryID)) + case err != nil: + return nil, ctxerr.Wrap(ctx, err, "verify query team for host assignment") + } + return filterHostIDsByTeam(ctx, ds.writer(ctx), hostIDs, teamID) +} + +// EnsureOpenframeTeamID resolves a Flamingo tenant UUID to its Fleet team id, creating the team if +// it does not yet exist (idempotent and safe under concurrent process startup via the unique +// `openframe_tenant_uuid` index). It is the bridge between the platform's UUID tenant identity and +// Fleet's integer team_id: the process is pinned by UUID (FLEET_OPENFRAME_TENANT_UUID) and resolves +// to the int here at startup. +// +// A newly created team is seeded with one random team-scoped enroll secret (same default as the EE +// team-creation service), because agent enrollment is the tenant's entry point: without a secret a +// fresh tenant could never enroll a host (the pinned GET /spec/enroll_secret would return an empty +// set). The seed happens only on the create path, in the same transaction as the team INSERT — an +// existing team's secrets (operator-applied or backfilled) are never touched. +func (ds *Datastore) EnsureOpenframeTeamID(ctx context.Context, tenantUUID string) (uint, error) { + // Read on the primary: this runs at startup and must see a row another replica just created. + selectID := func() (uint, bool, error) { + var id uint + err := sqlx.GetContext(ctx, ds.writer(ctx), &id, + `SELECT id FROM teams WHERE openframe_tenant_uuid = ?`, tenantUUID) + switch { + case errors.Is(err, sql.ErrNoRows): + return 0, false, nil + case err != nil: + return 0, false, ctxerr.Wrap(ctx, err, "selecting openframe team by tenant uuid") + default: + return id, true, nil + } + } + + if id, ok, err := selectID(); err != nil || ok { + return id, err + } + + // Not found: create a minimal team plus its default enroll secret in one transaction, so a + // failure between the two cannot leave a permanently secret-less team (the create path would + // never run again for this UUID). Name is derived from the UUID so it satisfies the unique + // team-name constraint without colliding with operator-named teams. + var id uint + err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + res, err := tx.ExecContext(ctx, + `INSERT INTO teams (name, openframe_tenant_uuid) VALUES (?, ?)`, + "openframe-"+tenantUUID, tenantUUID) + if err != nil { + return err // likely a concurrent create (unique tenant_uuid / name); re-selected below + } + lastID, err := res.LastInsertId() + if err != nil { + return ctxerr.Wrap(ctx, err, "reading created openframe team id") + } + id = uint(lastID) //nolint:gosec // team ids are small positive ints + secret, err := server.GenerateRandomText(fleet.EnrollSecretDefaultLength) + if err != nil { + return ctxerr.Wrap(ctx, err, "generating openframe team enroll secret") + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO enroll_secrets (secret, team_id) VALUES (?, ?)`, secret, id); err != nil { + return ctxerr.Wrap(ctx, err, "seeding openframe team enroll secret") + } + return nil + }) + if err != nil { + // Likely a concurrent create by another replica (unique tenant_uuid / name): re-select. + if id, ok, selErr := selectID(); selErr == nil && ok { + return id, nil + } + return 0, ctxerr.Wrap(ctx, err, "creating openframe team for tenant uuid") + } + return id, nil +} + +// openframeMigrationLockName is the cluster-wide MySQL named lock serializing schema +// migrations on a shared database. Fleet's goose pipeline takes no advisory lock, so with N +// clusters' migration jobs (and replicas) pointed at one shared MySQL, concurrent +// `fleet prepare db` runs would race on DDL (the idempotency guards are check-then-ALTER, not +// atomic). +const openframeMigrationLockName = "openframe_fleet_migrations" + +// AcquireOpenframeMigrationLock takes the named MySQL lock guarding schema migrations, waiting +// up to timeout for a concurrent holder to finish. It returns a release func that must be +// called (deferred) when migrations complete. The lock is session-scoped: it is held on a +// dedicated connection and is automatically released by MySQL if the process dies, so a +// crashed migration job cannot wedge the lock. +func (ds *Datastore) AcquireOpenframeMigrationLock(ctx context.Context, timeout time.Duration) (release func(), err error) { + conn, err := ds.writer(ctx).Conn(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting a connection for the openframe migration lock") + } + var got sql.NullInt64 + if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", openframeMigrationLockName, int(timeout.Seconds())).Scan(&got); err != nil { + _ = conn.Close() + return nil, ctxerr.Wrap(ctx, err, "acquiring the openframe migration lock") + } + if !got.Valid || got.Int64 != 1 { + _ = conn.Close() + return nil, ctxerr.Errorf(ctx, "timed out after %s waiting for the %q MySQL lock — is another migration run in progress?", timeout, openframeMigrationLockName) + } + return func() { + // Best-effort explicit release; closing the connection releases the lock regardless. + _, _ = conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", openframeMigrationLockName) + _ = conn.Close() + }, nil +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 1fa09769d27..0afdbb52fa7 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -79,6 +79,13 @@ type policyCleanupArgs struct { } func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { + // >>> OPENFRAME(mysql-multitenancy): on a shared DB a "global" policy (team_id NULL) is visible + // to all tenants. Redirect creation to this process's pinned team so it stays tenant-private. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + return ds.NewTeamPolicy(ctx, teamID, authorID, args) + } + // <<< OPENFRAME(mysql-multitenancy) + var newPolicy *fleet.Policy if err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { @@ -234,6 +241,12 @@ func updatePolicyLabelsTx(ctx context.Context, tx sqlx.ExtContext, policy *fleet // >>> OPENFRAME(host-assignments): per-host policy assignment CRUD + loader backed by the policy_hosts table — openframe/docs/architecture-host-assignments.md func (ds *Datastore) AddPolicyHosts(ctx context.Context, policyID uint, hostIDs []uint) (uint, error) { + // OPENFRAME(mysql-multitenancy): verify the policy is in this process's team and drop foreign + // host ids (no-op when unpinned). + hostIDs, err := ds.openframeScopePolicyHosts(ctx, policyID, hostIDs) + if err != nil { + return 0, err + } if len(hostIDs) == 0 { return 0, nil } @@ -253,6 +266,12 @@ func (ds *Datastore) AddPolicyHosts(ctx context.Context, policyID uint, hostIDs } func (ds *Datastore) RemovePolicyHosts(ctx context.Context, policyID uint, hostIDs []uint) (uint, error) { + // OPENFRAME(mysql-multitenancy): verify the policy is in this process's team and drop foreign + // host ids (no-op when unpinned). + hostIDs, err := ds.openframeScopePolicyHosts(ctx, policyID, hostIDs) + if err != nil { + return 0, err + } if len(hostIDs) == 0 { return 0, nil } @@ -269,6 +288,12 @@ func (ds *Datastore) RemovePolicyHosts(ctx context.Context, policyID uint, hostI } func (ds *Datastore) ReplacePolicyHosts(ctx context.Context, policyID uint, hostIDs []uint) error { + // OPENFRAME(mysql-multitenancy): verify the policy is in this process's team and drop foreign + // host ids before replacing (no-op when unpinned). + hostIDs, err := ds.openframeScopePolicyHosts(ctx, policyID, hostIDs) + if err != nil { + return err + } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { if _, err := tx.ExecContext(ctx, `DELETE FROM policy_hosts WHERE policy_id = ?`, policyID); err != nil { return ctxerr.Wrap(ctx, err, "delete all policy hosts") @@ -296,6 +321,11 @@ var policyHostsAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ } func (ds *Datastore) ListPolicyHosts(ctx context.Context, policyID uint, opts fleet.ListOptions) ([]fleet.HostIdent, *fleet.PaginationMetadata, error) { + // OPENFRAME(mysql-multitenancy): a foreign policy's assigned hosts must not be listable; verify + // the policy is in this process's team (no-op when unpinned). + if _, err := ds.openframeScopePolicyHosts(ctx, policyID, nil); err != nil { + return nil, nil, err + } stmt := ` SELECT h.id, h.hostname FROM policy_hosts ph @@ -460,6 +490,16 @@ func (ds *Datastore) Policy(ctx context.Context, id uint) (*fleet.Policy, error) } func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) (*fleet.Policy, error) { + // >>> OPENFRAME(mysql-multitenancy): when no explicit team filter is given, scope by-id policy + // reads to this process's pinned team so a tenant cannot read another tenant's policy by id on a + // shared DB; a foreign (or global, pre-backfill) policy matches no rows → NotFound (404). Reuses + // the existing teamWhere plumbing; no-op when unpinned. + if teamID == nil { + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + teamID = &pinned + } + } + // <<< OPENFRAME(mysql-multitenancy) teamWhere := "TRUE" args := []interface{}{id} if teamID != nil { @@ -505,11 +545,18 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) } func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite, error) { + stmt := `SELECT id, name, description, resolution FROM policies WHERE id=?` + args := []interface{}{id} + // >>> OPENFRAME(mysql-multitenancy): scope by-id policy-lite reads to this process's team + // (foreign → NotFound). No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + stmt += " AND team_id = ?" + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + var policy fleet.PolicyLite - err := sqlx.GetContext( - ctx, ds.reader(ctx), &policy, - `SELECT id, name, description, resolution FROM policies WHERE id=?`, id, - ) + err := sqlx.GetContext(ctx, ds.reader(ctx), &policy, stmt, args...) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ctxerr.Wrap(ctx, notFound("Policy").WithID(id)) @@ -523,6 +570,15 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite // // Currently, SavePolicy does not allow updating the team of an existing policy. func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error { + // >>> OPENFRAME(mysql-multitenancy): verify (on the primary — read before write) the policy + // belongs to the pinned team; a foreign/global policy returns NotFound. No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + if _, err := policyDB(ctx, ds.writer(ctx), p.ID, &pinned); err != nil { + return ctxerr.Wrap(ctx, err, "verify policy team before save") + } + } + // <<< OPENFRAME(mysql-multitenancy) + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { return savePolicy(ctx, tx, ds.logger, p, shouldRemoveAllPolicyMemberships, removePolicyStats) }); err != nil { @@ -1061,7 +1117,14 @@ WHERE } func (ds *Datastore) ListGlobalPolicies(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { - return listPoliciesDB(ctx, ds.reader(ctx), nil, opts, "", nil) + // >>> OPENFRAME(mysql-multitenancy): list this tenant's policies instead of the shared global + // set (team_id IS NULL → team_id = pinned). No-op when unpinned. + var teamID *uint + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + teamID = &pinned + } + // <<< OPENFRAME(mysql-multitenancy) + return listPoliciesDB(ctx, ds.reader(ctx), teamID, opts, "", nil) } // returns the list of policies associated with the provided teamID, or the @@ -1177,6 +1240,19 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea // CountPolicies returns the total number of team policies. // If teamID is nil, it returns the total number of global policies. func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery string, automationType string) (int, error) { + // >>> OPENFRAME(mysql-multitenancy): a "global" count (teamID nil) on a shared DB would count + // all tenants' policies, so scope it to the pinned team; an explicit foreign team must count + // nothing. No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + switch { + case teamID == nil: + teamID = &pinned + case *teamID != pinned: + return 0, nil + } + } + // <<< OPENFRAME(mysql-multitenancy) + var ( query string args []interface{} @@ -1215,6 +1291,11 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery } func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string, automationType string) (int, error) { + // >>> OPENFRAME(mysql-multitenancy): don't count another tenant's policies (see ListMergedTeamPolicies). + if openframeForeignTeam(ctx, teamID) { + return 0, nil + } + // <<< OPENFRAME(mysql-multitenancy) var args []interface{} query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` @@ -1255,7 +1336,17 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl LEFT JOIN policy_stats ps ON p.id = ps.policy_id AND ((p.team_id IS NULL AND ps.inherited_team_id IS NULL) OR (p.team_id IS NOT NULL)) WHERE p.id IN (?)` - query, args, err := sqlx.In(sql, ids) + args := []interface{}{ids} + // >>> OPENFRAME(mysql-multitenancy): fence the batch by-id read to this process's pinned team — + // without it a tenant could hydrate another tenant's policies by id (the delete-validation paths + // call this before the fenced delete, leaking existence + content into internal logs). Foreign + // ids match no rows → NotFound below, same as a nonexistent id. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + sql += ` AND p.team_id = ?` + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + query, args, err := sqlx.In(sql, args...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building query to get policies by ID") } @@ -1285,6 +1376,23 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl } func (ds *Datastore) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]uint, error) { + // >>> OPENFRAME(mysql-multitenancy): fence by-id global-policy deletion to this process's pinned + // team — drop foreign ids before any cleanup/delete so a tenant cannot delete another tenant's + // policy on a shared DB. No-op when unpinned. + teamFilter := (*uint)(nil) + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + owned, err := filterPolicyIDsByTeam(ctx, ds.writer(ctx), ids, teamID) + if err != nil { + return nil, err + } + if len(owned) == 0 { + return []uint{}, nil + } + ids = owned + teamFilter = &teamID + } + // <<< OPENFRAME(mysql-multitenancy) + for _, id := range ids { if err := ds.deletePendingSoftwareInstallsForPolicy(ctx, nil, id); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete pending software installs for policy") @@ -1294,7 +1402,7 @@ func (ds *Datastore) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]ui } } - return deletePolicyDB(ctx, ds.writer(ctx), ids, nil) + return deletePolicyDB(ctx, ds.writer(ctx), ids, teamFilter) } func deletePolicyDB(ctx context.Context, q sqlx.ExtContext, ids []uint, teamID *uint) ([]uint, error) { @@ -1589,6 +1697,13 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI } func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationType string) (teamPolicies, inheritedPolicies []*fleet.Policy, err error) { + // >>> OPENFRAME(mysql-multitenancy): the URL fleet_id is caller-supplied, not a boundary; a + // pinned process must not list another tenant's team policies on a shared DB. No-op when + // unpinned. + if openframeForeignTeam(ctx, teamID) { + return nil, nil, nil + } + // <<< OPENFRAME(mysql-multitenancy) filterClause, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build automation filter clause") @@ -1607,6 +1722,12 @@ func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fle } func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, automationType string) ([]*fleet.Policy, error) { + // >>> OPENFRAME(mysql-multitenancy): merge_inherited twin of ListTeamPolicies serving the same + // endpoint; a pinned process must not list another tenant's policies. + if openframeForeignTeam(ctx, teamID) { + return nil, nil + } + // <<< OPENFRAME(mysql-multitenancy) var args []interface{} automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) @@ -1665,6 +1786,12 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op } func (ds *Datastore) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { + // >>> OPENFRAME(mysql-multitenancy): reject a delete targeting another tenant's team (the URL + // fleet_id is not a boundary). No-op when unpinned. + if openframeForeignTeam(ctx, teamID) { + return []uint{}, nil + } + // <<< OPENFRAME(mysql-multitenancy) for _, id := range ids { if err := ds.deletePendingSoftwareInstallsForPolicy(ctx, &teamID, id); err != nil { return nil, ctxerr.Wrap(ctx, err, "delete pending software installs for policy") @@ -1678,6 +1805,12 @@ func (ds *Datastore) DeleteTeamPolicies(ctx context.Context, teamID uint, ids [] } func (ds *Datastore) TeamPolicy(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) { + // >>> OPENFRAME(mysql-multitenancy): reject a read targeting another tenant's team (the URL + // fleet_id is not a boundary) → NotFound. No-op when unpinned. + if openframeForeignTeam(ctx, teamID) { + return nil, ctxerr.Wrap(ctx, notFound("Policy").WithID(policyID)) + } + // <<< OPENFRAME(mysql-multitenancy) return policyDB(ctx, ds.reader(ctx), policyID, &teamID) } @@ -1696,9 +1829,14 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamNameToID := make(map[string]*uint, 1) teamIDToPolicies := make(map[*uint][]*fleet.PolicySpec, 1) softwareInstallerIDs := make(map[*uint]map[uint]*uint) // teamID -> titleID -> softwareInstallerID - vppAppsTeamsIDs := make(map[*uint]map[uint]*uint) // teamID -> titleID -> vppAppsTeamsID - fmaTitleIDs := make(map[*uint]map[string]*uint) // teamID -> FMA slug -> titleID - vppTitleIDs := make(map[uint]struct{}) // set when a title is a VPP app rather than a software installer + + // >>> OPENFRAME(mysql-multitenancy): resolve the pin once so re-homed specs share a single map + // key (a per-iteration &pinned would fragment the *uint-keyed batching). + openframePinnedTeamID, openframePinned := fleet.OpenframeTeamID(ctx) + // <<< OPENFRAME(mysql-multitenancy) + vppAppsTeamsIDs := make(map[*uint]map[uint]*uint) // teamID -> titleID -> vppAppsTeamsID + fmaTitleIDs := make(map[*uint]map[string]*uint) // teamID -> FMA slug -> titleID + vppTitleIDs := make(map[uint]struct{}) // set when a title is a VPP app rather than a software installer // Get the team IDs for _, spec := range specs { @@ -1724,6 +1862,13 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs } teamNameToID[spec.Team] = teamID } + // >>> OPENFRAME(mysql-multitenancy): a tenant's GitOps apply may only affect its own team — + // re-home every spec to the pinned team (shared pointer, see above). No-op when unpinned. + if openframePinned { + teamID = &openframePinnedTeamID + teamNameToID[spec.Team] = teamID + } + // <<< OPENFRAME(mysql-multitenancy) teamIDToPolicies[teamID] = append(teamIDToPolicies[teamID], spec) } diff --git a/server/datastore/mysql/policies_queries_openframe_test.go b/server/datastore/mysql/policies_queries_openframe_test.go new file mode 100644 index 00000000000..990071d719c --- /dev/null +++ b/server/datastore/mysql/policies_queries_openframe_test.go @@ -0,0 +1,261 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +// TestOpenframePolicyQueryByIDTeamFence verifies the OPENFRAME(mysql-multitenancy) by-id read fences +// on policies (Policy / PolicyLite) and queries (Query): a team-scoped process can read its own +// policy/query by id but gets NotFound for another tenant's, even though those endpoints have no +// team param. No-op when unpinned. Runs only under MYSQL_TEST=1. +func TestOpenframePolicyQueryByIDTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "pq-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "pq-tenant-b"}) + require.NoError(t, err) + + polA, err := ds.NewTeamPolicy(ctx, teamA.ID, nil, fleet.PolicyPayload{Name: "polA", Query: "SELECT 1"}) + require.NoError(t, err) + polB, err := ds.NewTeamPolicy(ctx, teamB.ID, nil, fleet.PolicyPayload{Name: "polB", Query: "SELECT 1"}) + require.NoError(t, err) + + mkQuery := func(team *fleet.Team, name string) *fleet.Query { + q, err := ds.NewQuery(ctx, &fleet.Query{ + Name: name, + Query: "SELECT 1", + Saved: true, + TeamID: &team.ID, + Logging: fleet.LoggingSnapshot, + }) + require.NoError(t, err) + return q + } + qA := mkQuery(teamA, "qA") + qB := mkQuery(teamB, "qB") + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("Policy: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.Policy(ctxA, polA.ID) + require.NoError(t, err) + require.Equal(t, polA.ID, got.ID) + + _, err = ds.Policy(ctxA, polB.ID) + require.True(t, fleet.IsNotFound(err), "foreign policy by id must be NotFound, got %v", err) + }) + + t.Run("PolicyLite: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.PolicyLite(ctxA, polA.ID) + require.NoError(t, err) + require.Equal(t, polA.ID, got.ID) + + _, err = ds.PolicyLite(ctxA, polB.ID) + require.True(t, fleet.IsNotFound(err), "foreign policy-lite by id must be NotFound, got %v", err) + }) + + t.Run("Query: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.Query(ctxA, qA.ID) + require.NoError(t, err) + require.Equal(t, qA.ID, got.ID) + + _, err = ds.Query(ctxA, qB.ID) + require.True(t, fleet.IsNotFound(err), "foreign query by id must be NotFound, got %v", err) + }) + + t.Run("PoliciesByID: own ok, foreign NotFound", func(t *testing.T) { + got, err := ds.PoliciesByID(ctxA, []uint{polA.ID}) + require.NoError(t, err) + require.Equal(t, polA.ID, got[polA.ID].ID) + + _, err = ds.PoliciesByID(ctxA, []uint{polB.ID}) + require.True(t, fleet.IsNotFound(err), "foreign policy in batch by-id must be NotFound, got %v", err) + + // A mixed batch fails too — the foreign id is indistinguishable from a nonexistent one. + _, err = ds.PoliciesByID(ctxA, []uint{polA.ID, polB.ID}) + require.True(t, fleet.IsNotFound(err), "mixed batch with foreign id must be NotFound, got %v", err) + }) + + t.Run("unpinned baseline: foreign reads still succeed", func(t *testing.T) { + _, err := ds.Policy(ctx, polB.ID) + require.NoError(t, err) + _, err = ds.Query(ctx, qB.ID) + require.NoError(t, err) + _, err = ds.PoliciesByID(ctx, []uint{polA.ID, polB.ID}) + require.NoError(t, err) + }) +} + +// TestOpenframePolicyQueryCRUDTeamFence verifies the OPENFRAME(mysql-multitenancy) global→pinned +// redirect on the policy/query list, create, update, and delete paths: a pinned process creates +// objects in its own team, lists only its own, and cannot update/delete another tenant's. +// Runs only under MYSQL_TEST=1. +func TestOpenframePolicyQueryCRUDTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "crud-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "crud-b"}) + require.NoError(t, err) + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("policies create/list/update/delete", func(t *testing.T) { + // Create via the "global" entry point while pinned → lands in team A (redirect). + polA, err := ds.NewGlobalPolicy(ctxA, nil, fleet.PolicyPayload{Name: "cp-A", Query: "SELECT 1"}) + require.NoError(t, err) + require.NotNil(t, polA.TeamID) + require.Equal(t, teamA.ID, *polA.TeamID, "global create must redirect to the pinned team") + + // Another tenant's policy. + polB, err := ds.NewTeamPolicy(ctx, teamB.ID, nil, fleet.PolicyPayload{Name: "cp-B", Query: "SELECT 1"}) + require.NoError(t, err) + + // List via "global" while pinned → only team A's. + list, err := ds.ListGlobalPolicies(ctxA, fleet.ListOptions{}) + require.NoError(t, err) + ids := map[uint]bool{} + for _, p := range list { + ids[p.ID] = true + } + require.True(t, ids[polA.ID], "own policy must be listed") + require.False(t, ids[polB.ID], "another tenant's policy must not be listed") + + // Update another tenant's policy while pinned → blocked. + polB.Description = "hijacked" + err = ds.SavePolicy(ctxA, polB, false, false) + require.True(t, fleet.IsNotFound(err), "saving a foreign policy must be blocked, got %v", err) + + // Delete a mix → only own removed. + deleted, err := ds.DeleteGlobalPolicies(ctxA, []uint{polA.ID, polB.ID}) + require.NoError(t, err) + require.Equal(t, []uint{polA.ID}, deleted) + _, err = ds.Policy(ctx, polB.ID) // unpinned read: still exists + require.NoError(t, err) + }) + + t.Run("queries create/list/update/delete", func(t *testing.T) { + // Create with no team while pinned → lands in team A. + qA, err := ds.NewQuery(ctxA, &fleet.Query{Name: "cq-A", Query: "SELECT 1", Saved: true, Logging: fleet.LoggingSnapshot}) + require.NoError(t, err) + require.NotNil(t, qA.TeamID) + require.Equal(t, teamA.ID, *qA.TeamID, "create must pin to the process team") + + qB, err := ds.NewQuery(ctx, &fleet.Query{Name: "cq-B", Query: "SELECT 1", Saved: true, TeamID: &teamB.ID, Logging: fleet.LoggingSnapshot}) + require.NoError(t, err) + + // List while pinned → only team A's. + list, _, _, _, err := ds.ListQueries(ctxA, fleet.ListQueryOptions{}) + require.NoError(t, err) + ids := map[uint]bool{} + for _, q := range list { + ids[q.ID] = true + } + require.True(t, ids[qA.ID], "own query must be listed") + require.False(t, ids[qB.ID], "another tenant's query must not be listed") + + // Update another tenant's query while pinned → blocked. + qB.Description = "hijacked" + err = ds.SaveQuery(ctxA, qB, false, false) + require.True(t, fleet.IsNotFound(err), "saving a foreign query must be blocked, got %v", err) + + // Delete a mix → only own removed. + deleted, err := ds.DeleteQueries(ctxA, []uint{qA.ID, qB.ID}) + require.NoError(t, err) + require.Equal(t, uint(1), deleted) + _, err = ds.Query(ctx, qB.ID) // unpinned read: still exists + require.NoError(t, err) + }) +} + +// TestOpenframeExplicitTeamAndGitOpsFence verifies the explicit-team (URL fleet_id) rejections and +// the GitOps batch redirect: a pinned process cannot read/delete another tenant's team objects, and +// ApplyPolicySpecs/ApplyQueries re-home all specs/queries to the pinned team. Runs only under +// MYSQL_TEST=1. +func TestOpenframeExplicitTeamAndGitOpsFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + user := test.NewUser(t, ds, "Author", "author@example.com", true) + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "et-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "et-b"}) + require.NoError(t, err) + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + polB, err := ds.NewTeamPolicy(ctx, teamB.ID, &user.ID, fleet.PolicyPayload{Name: "etPolB", Query: "SELECT 1"}) + require.NoError(t, err) + qB, err := ds.NewQuery(ctx, &fleet.Query{Name: "etQB", Query: "SELECT 1", Saved: true, TeamID: &teamB.ID, Logging: fleet.LoggingSnapshot}) + require.NoError(t, err) + + t.Run("explicit-team paths reject another tenant", func(t *testing.T) { + // Read a foreign team's policy via the team route → NotFound. + _, err := ds.TeamPolicy(ctxA, teamB.ID, polB.ID) + require.True(t, fleet.IsNotFound(err), "TeamPolicy foreign must be NotFound, got %v", err) + + // List a foreign team's policies → empty (no leak) on both the plain and merge_inherited + // paths (they serve the same endpoint via the merge_inherited query param). + tp, _, err := ds.ListTeamPolicies(ctxA, teamB.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") + require.NoError(t, err) + require.Empty(t, tp) + + merged, err := ds.ListMergedTeamPolicies(ctxA, teamB.ID, fleet.ListOptions{}, "") + require.NoError(t, err) + require.Empty(t, merged, "merge_inherited must not leak a foreign tenant's policies") + + // Counts of a foreign team → 0 on both count paths. + cnt, err := ds.CountMergedTeamPolicies(ctxA, teamB.ID, "", "") + require.NoError(t, err) + require.Zero(t, cnt) + cnt, err = ds.CountPolicies(ctxA, &teamB.ID, "", "") + require.NoError(t, err) + require.Zero(t, cnt, "explicit foreign-team count must be 0") + + // Delete against a foreign team → nothing deleted; polB survives. + deleted, err := ds.DeleteTeamPolicies(ctxA, teamB.ID, []uint{polB.ID}) + require.NoError(t, err) + require.Empty(t, deleted) + _, err = ds.Policy(ctx, polB.ID) + require.NoError(t, err) + + // QueryByName against a foreign team → NotFound. + _, err = ds.QueryByName(ctxA, &teamB.ID, "etQB") + require.True(t, fleet.IsNotFound(err), "QueryByName foreign must be NotFound, got %v", err) + _ = qB + }) + + t.Run("GitOps ApplyPolicySpecs re-homes to pinned team", func(t *testing.T) { + // Multiple "No team" (global) specs applied while pinned → all land in team A. + require.NoError(t, ds.ApplyPolicySpecs(ctxA, user.ID, []*fleet.PolicySpec{ + {Name: "gitopsPol", Query: "SELECT 1", Team: "No team"}, + {Name: "gitopsPol2", Query: "SELECT 2", Team: "No team"}, + })) + var teamIDs []*uint + require.NoError(t, ds.writer(ctx).SelectContext(ctx, &teamIDs, + "SELECT team_id FROM policies WHERE name IN ('gitopsPol', 'gitopsPol2')")) + require.Len(t, teamIDs, 2) + for _, tid := range teamIDs { + require.NotNil(t, tid) + require.Equal(t, teamA.ID, *tid) + } + }) + + t.Run("GitOps ApplyQueries pins to pinned team", func(t *testing.T) { + require.NoError(t, ds.ApplyQueries(ctxA, user.ID, []*fleet.Query{ + {Name: "gitopsQ", Query: "SELECT 1", Saved: true, Logging: fleet.LoggingSnapshot}, + }, nil)) + var teamIDs []*uint + require.NoError(t, ds.writer(ctx).SelectContext(ctx, &teamIDs, + "SELECT team_id FROM queries WHERE name = 'gitopsQ'")) + require.Len(t, teamIDs, 1) + require.NotNil(t, teamIDs[0]) + require.Equal(t, teamA.ID, *teamIDs[0]) + }) +} diff --git a/server/datastore/mysql/queries.go b/server/datastore/mysql/queries.go index b639fab882b..c5cf38f24cd 100644 --- a/server/datastore/mysql/queries.go +++ b/server/datastore/mysql/queries.go @@ -35,6 +35,14 @@ const ( var querySearchColumns = []string{"q.name"} func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]struct{}) error { + // >>> OPENFRAME(mysql-multitenancy): a tenant's GitOps apply may only affect its own team — pin + // every applied query to this process's team. No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + for _, q := range queries { + q.TeamID = &pinned + } + } + // <<< OPENFRAME(mysql-multitenancy) if err := ds.applyQueriesInTx(ctx, authorID, queries); err != nil { return ctxerr.Wrap(ctx, err, "apply queries in tx") } @@ -201,6 +209,16 @@ func (ds *Datastore) QueryByName( teamID *uint, name string, ) (*fleet.Query, error) { + // >>> OPENFRAME(mysql-multitenancy): scope name lookup to this process's team — reject another + // tenant's team (the URL fleet_id is not a boundary) and redirect "global" to the pinned team. + // No-op when unpinned. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + if teamID != nil && *teamID != pinned { + return nil, ctxerr.Wrap(ctx, notFound("Query").WithName(name)) + } + teamID = &pinned + } + // <<< OPENFRAME(mysql-multitenancy) stmt := ` SELECT id, @@ -258,6 +276,13 @@ func (ds *Datastore) NewQuery( query.CreatedAt = now query.UpdatedAt = now + // >>> OPENFRAME(mysql-multitenancy): pin new queries to this process's team so they are not + // created as shared "global" queries (team_id NULL) on a shared DB. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + query.TeamID = &teamID + } + // <<< OPENFRAME(mysql-multitenancy) + queryStatement := ` INSERT INTO queries ( name, @@ -449,6 +474,15 @@ func (ds *Datastore) SaveQuery(ctx context.Context, q *fleet.Query, shouldDiscar return ctxerr.Wrap(ctx, err) } + // >>> OPENFRAME(mysql-multitenancy): verify (on the primary — read before write) the query + // belongs to the pinned team; a foreign/global query returns NotFound. No-op when unpinned. + if _, ok := fleet.OpenframeTeamID(ctx); ok { + if _, qErr := query(ctx, ds.writer(ctx), q.ID); qErr != nil { + return ctxerr.Wrap(ctx, qErr, "verify query team before save") + } + } + // <<< OPENFRAME(mysql-multitenancy) + updateSQL := ` UPDATE queries SET name = ?, @@ -527,6 +561,14 @@ func (ds *Datastore) deleteQueryResults(ctx context.Context, queryID uint) error } func (ds *Datastore) DeleteQuery(ctx context.Context, teamID *uint, name string) error { + // >>> OPENFRAME(mysql-multitenancy): a "global" delete-by-name (teamID nil) on a shared DB would + // match another tenant's query; scope it to this process's pinned team. No-op when unpinned. + if teamID == nil { + if pinned, ok := fleet.OpenframeTeamID(ctx); ok { + teamID = &pinned + } + } + // <<< OPENFRAME(mysql-multitenancy) selectStmt := "SELECT id FROM queries WHERE name = ?" args := []interface{}{name} whereClause := " AND team_id_char = ''" @@ -573,6 +615,20 @@ func (ds *Datastore) DeleteQuery(ctx context.Context, teamID *uint, name string) // DeleteQueries deletes the existing query objects with the provided IDs. The // number of deleted queries is returned along with any error. func (ds *Datastore) DeleteQueries(ctx context.Context, ids []uint) (uint, error) { + // >>> OPENFRAME(mysql-multitenancy): fence by-id query deletion to this process's team — drop + // foreign ids so a tenant cannot delete another tenant's query on a shared DB. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + owned, err := filterQueryIDsByTeam(ctx, ds.writer(ctx), ids, teamID) + if err != nil { + return 0, err + } + if len(owned) == 0 { + return 0, nil + } + ids = owned + } + // <<< OPENFRAME(mysql-multitenancy) + deleted, err := ds.deleteEntities(ctx, queriesTable, ids) if err != nil { return deleted, err @@ -621,6 +677,12 @@ func (ds *Datastore) deleteQueryStats(ctx context.Context, queryIDs []uint) { // >>> OPENFRAME(host-assignments): per-host query assignment CRUD backed by the query_hosts table — openframe/docs/architecture-host-assignments.md func (ds *Datastore) AddQueryHosts(ctx context.Context, queryID uint, hostIDs []uint) (uint, error) { + // OPENFRAME(mysql-multitenancy): verify the query is in this process's team and drop foreign + // host ids (no-op when unpinned). + hostIDs, err := ds.openframeScopeQueryHosts(ctx, queryID, hostIDs) + if err != nil { + return 0, err + } if len(hostIDs) == 0 { return 0, nil } @@ -640,6 +702,12 @@ func (ds *Datastore) AddQueryHosts(ctx context.Context, queryID uint, hostIDs [] } func (ds *Datastore) RemoveQueryHosts(ctx context.Context, queryID uint, hostIDs []uint) (uint, error) { + // OPENFRAME(mysql-multitenancy): verify the query is in this process's team and drop foreign + // host ids (no-op when unpinned). + hostIDs, err := ds.openframeScopeQueryHosts(ctx, queryID, hostIDs) + if err != nil { + return 0, err + } if len(hostIDs) == 0 { return 0, nil } @@ -656,6 +724,12 @@ func (ds *Datastore) RemoveQueryHosts(ctx context.Context, queryID uint, hostIDs } func (ds *Datastore) ReplaceQueryHosts(ctx context.Context, queryID uint, hostIDs []uint) error { + // OPENFRAME(mysql-multitenancy): verify the query is in this process's team and drop foreign + // host ids before replacing (no-op when unpinned). + hostIDs, err := ds.openframeScopeQueryHosts(ctx, queryID, hostIDs) + if err != nil { + return err + } return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { if _, err := tx.ExecContext(ctx, `DELETE FROM query_hosts WHERE query_id = ?`, queryID); err != nil { return ctxerr.Wrap(ctx, err, "delete all query hosts") @@ -683,6 +757,11 @@ var queryHostsAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ } func (ds *Datastore) ListQueryHosts(ctx context.Context, queryID uint, opts fleet.ListOptions) ([]fleet.HostIdent, *fleet.PaginationMetadata, error) { + // OPENFRAME(mysql-multitenancy): a foreign query's assigned hosts must not be listable; verify + // the query is in this process's team (no-op when unpinned). + if _, err := ds.openframeScopeQueryHosts(ctx, queryID, nil); err != nil { + return nil, nil, err + } stmt := ` SELECT h.id, h.hostname FROM query_hosts qh @@ -753,8 +832,17 @@ func query(ctx context.Context, db sqlx.QueryerContext, id uint) (*fleet.Query, ON (ag.id = q.id AND ag.global_stats = ? AND ag.type = ?) WHERE q.id = ? ` + args := []interface{}{false, fleet.AggregatedStatsTypeScheduledQuery, id} + // >>> OPENFRAME(mysql-multitenancy): scope by-id (scheduled-)query reads to this process's team + // so a tenant cannot read another tenant's query by id on a shared DB; foreign (or global, + // pre-backfill) → NotFound. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + sqlQuery += " AND q.team_id = ?" + args = append(args, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) query := &fleet.Query{} - if err := sqlx.GetContext(ctx, db, query, sqlQuery, false, fleet.AggregatedStatsTypeScheduledQuery, id); err != nil { + if err := sqlx.GetContext(ctx, db, query, sqlQuery, args...); err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("Report").WithID(id)) } @@ -784,6 +872,13 @@ func query(ctx context.Context, db sqlx.QueryerContext, id uint) (*fleet.Query, // determined by passed in fleet.ListOptions, count of total queries returned without limits, and // pagination metadata func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions) (queries []*fleet.Query, total int, inherited int, metadata *fleet.PaginationMetadata, err error) { + // >>> OPENFRAME(mysql-multitenancy): scope query listing to this process's team and exclude + // inherited globals (other tenants' global queries) on a shared DB. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + opt.TeamID = &teamID + opt.MergeInherited = false + } + // <<< OPENFRAME(mysql-multitenancy) getQueriesStmt := ` SELECT q.id, diff --git a/server/datastore/mysql/targets.go b/server/datastore/mysql/targets.go index 3de4c792897..67f42ec19d4 100644 --- a/server/datastore/mysql/targets.go +++ b/server/datastore/mysql/targets.go @@ -22,6 +22,18 @@ func (ds *Datastore) CountHostsInTargets(ctx context.Context, filter fleet.TeamF queryTargetLogicCondition, queryTargetArgs := targetSQLCondAndArgs(targets, "h") + // >>> OPENFRAME(mysql-multitenancy): fence target host resolution to this process's pinned team + // so a live query cannot target/count another tenant's hosts on a shared DB. The Redis key + // prefix already isolates live-query execution/results; this fences the MySQL target set too, + // keeping counts correct and foreign host ids out of campaign targets. + openframeTeamCond := "" + var openframeTeamArgs []interface{} + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + openframeTeamCond = " AND h.team_id = ?" + openframeTeamArgs = append(openframeTeamArgs, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + // As of Fleet 4.15, mia hosts are also included in the total for offline hosts sql := fmt.Sprintf(` SELECT @@ -32,12 +44,14 @@ func (ds *Datastore) CountHostsInTargets(ctx context.Context, filter fleet.TeamF COALESCE(SUM(CASE WHEN DATE_ADD(h.created_at, INTERVAL 1 DAY) >= ? THEN 1 ELSE 0 END), 0) new FROM hosts h LEFT JOIN host_seen_times hst ON (h.id=hst.host_id)`+hostMDMSeenTimeJoin+` - WHERE %s AND %s`, + WHERE %s AND %s%s`, fleet.OnlineIntervalBuffer, fleet.OnlineIntervalBuffer, - queryTargetLogicCondition, ds.whereFilterHostsByTeams(filter, "h"), + queryTargetLogicCondition, ds.whereFilterHostsByTeams(filter, "h"), openframeTeamCond, // OPENFRAME(mysql-multitenancy) ) - query, args, err := sqlx.In(sql, append([]interface{}{now, now, now, now}, queryTargetArgs...)...) + countArgs := append([]interface{}{now, now, now, now}, queryTargetArgs...) + countArgs = append(countArgs, openframeTeamArgs...) // OPENFRAME(mysql-multitenancy) + query, args, err := sqlx.In(sql, countArgs...) if err != nil { return fleet.TargetMetrics{}, ctxerr.Wrap(ctx, err, "sqlx.In CountHostsInTargets") } @@ -132,14 +146,25 @@ func (ds *Datastore) HostIDsInTargets(ctx context.Context, filter fleet.TeamFilt queryTargetLogicCondition, queryTargetArgs := targetSQLCondAndArgs(targets, "hosts") + // >>> OPENFRAME(mysql-multitenancy): fence target host resolution to this process's pinned team + // so a live query cannot distribute to another tenant's hosts on a shared DB. Injected into the + // WHERE clause (before ORDER BY). + openframeTeamCond := "" + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + openframeTeamCond = " AND hosts.team_id = ?" + queryTargetArgs = append(queryTargetArgs, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) + sql := fmt.Sprintf(` SELECT DISTINCT id FROM hosts - WHERE %s AND %s + WHERE %s AND %s%s ORDER BY id ASC `, queryTargetLogicCondition, ds.whereFilterHostsByTeams(filter, "hosts"), + openframeTeamCond, // OPENFRAME(mysql-multitenancy) ) query, args, err := sqlx.In(sql, queryTargetArgs...) diff --git a/server/datastore/mysql/targets_openframe_test.go b/server/datastore/mysql/targets_openframe_test.go new file mode 100644 index 00000000000..75c39206e70 --- /dev/null +++ b/server/datastore/mysql/targets_openframe_test.go @@ -0,0 +1,67 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +// TestOpenframeLiveQueryTargetTeamFence verifies the OPENFRAME(mysql-multitenancy) fence in +// HostIDsInTargets / CountHostsInTargets: a team-scoped process resolving live-query targets that +// include another tenant's host ids only sees/counts its own team's hosts — even though the caller +// is a global-admin (whose TeamFilter matches all teams) and /queries/run has no team_id param. +// Runs only under MYSQL_TEST=1. +func TestOpenframeLiveQueryTargetTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // Global-admin filter: matches all teams, so only the OpenFrame fence restricts the result. + filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "lq-tenant-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "lq-tenant-b"}) + require.NoError(t, err) + + mk := func(team *fleet.Team, key string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(key), + NodeKey: ptr.String("nk-" + key), + UUID: key, + Hostname: "host-" + key, + Platform: "darwin", + TeamID: &team.ID, + }) + require.NoError(t, err) + return h + } + + hostA := mk(teamA, "lq-A") + hostB := mk(teamB, "lq-B") + + targets := fleet.HostTargets{HostIDs: []uint{hostA.ID, hostB.ID}} + + // Baseline (no team scope): both hosts resolve — the cross-tenant exposure the fence closes. + ids, err := ds.HostIDsInTargets(ctx, filter, targets) + require.NoError(t, err) + require.ElementsMatch(t, []uint{hostA.ID, hostB.ID}, ids) + + // Team-A-scoped: only team A's host resolves; team B's is fenced out. + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + ids, err = ds.HostIDsInTargets(ctxA, filter, targets) + require.NoError(t, err) + require.Equal(t, []uint{hostA.ID}, ids) + + // And the count reflects only team A's host. + metrics, err := ds.CountHostsInTargets(ctxA, filter, targets, time.Now()) + require.NoError(t, err) + require.Equal(t, uint(1), metrics.TotalHosts) +} diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index 5fdbaf31883..a3cb66472d2 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -98,6 +98,14 @@ func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint, withExtras boo }, nil } + // >>> OPENFRAME(mysql-multitenancy): scope every team-by-id read (TeamLite/Team, and the + // service-layer TeamLite guard used by ListTeamPolicies) to the pinned tenant; a foreign team + // is NotFound. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok && tid != teamID { + return nil, ctxerr.Wrap(ctx, notFound("Fleet").WithID(tid)) + } + // <<< OPENFRAME(mysql-multitenancy) + stmt := ` SELECT ` + teamColumns + ` FROM teams WHERE id = ? @@ -421,6 +429,13 @@ WHERE // ListTeams lists all teams with limit, sort and offset passed in with // fleet.ListOptions func (ds *Datastore) ListTeams(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + whereClause := ds.whereFilterTeams(filter, "t") + // >>> OPENFRAME(mysql-multitenancy): a tenant may only list its own team on a shared DB. The + // pinned id is a trusted uint (not user input), inlined like the team filter above. No-op when unpinned. + if teamID, ok := fleet.OpenframeTeamID(ctx); ok { + whereClause = fmt.Sprintf("(%s) AND t.id = %d", whereClause, teamID) + } + // <<< OPENFRAME(mysql-multitenancy) query := fmt.Sprintf(` SELECT `+teamColumns+`, (SELECT count(*) FROM user_teams WHERE team_id = t.id) AS user_count, @@ -428,7 +443,7 @@ func (ds *Datastore) ListTeams(ctx context.Context, filter fleet.TeamFilter, opt FROM teams t WHERE %s `, - ds.whereFilterTeams(filter, "t"), + whereClause, ) // We must normalize the name for full Unicode support (Unicode equivalence). matchQuery := norm.NFC.String(opt.MatchQuery) diff --git a/server/datastore/mysql/teams_openframe_test.go b/server/datastore/mysql/teams_openframe_test.go new file mode 100644 index 00000000000..d158702740b --- /dev/null +++ b/server/datastore/mysql/teams_openframe_test.go @@ -0,0 +1,95 @@ +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +// TestOpenframeEnsureTeamID verifies the UUID→team_id bridge: EnsureOpenframeTeamID creates a team +// for a new tenant UUID and is idempotent (same UUID → same id; different UUID → different id). +// Runs only under MYSQL_TEST=1. +func TestOpenframeEnsureTeamID(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + // The bridge column lives in the openframe migration pipeline (not schema.sql). + require.NoError(t, ds.MigrateOpenframe(ctx)) + + const uuidA = "3f1a9b2c-0000-4d5e-8f00-00000000000a" + const uuidB = "3f1a9b2c-0000-4d5e-8f00-00000000000b" + + // First call creates the team. + idA, err := ds.EnsureOpenframeTeamID(ctx, uuidA) + require.NoError(t, err) + require.NotZero(t, idA) + + // Idempotent: same UUID resolves to the same id, no duplicate team. + idA2, err := ds.EnsureOpenframeTeamID(ctx, uuidA) + require.NoError(t, err) + require.Equal(t, idA, idA2) + + // A different tenant UUID gets its own team. + idB, err := ds.EnsureOpenframeTeamID(ctx, uuidB) + require.NoError(t, err) + require.NotZero(t, idB) + require.NotEqual(t, idA, idB) + + // The resolved id is a real team, and the bridge column is populated. + team, err := ds.TeamLite(ctx, idA) + require.NoError(t, err) + require.Equal(t, idA, team.ID) + + var storedUUID string + require.NoError(t, ds.writer(ctx).GetContext(ctx, &storedUUID, + "SELECT openframe_tenant_uuid FROM teams WHERE id = ?", idA)) + require.Equal(t, uuidA, storedUUID) + + // A newly created team is seeded with exactly one team-scoped enroll secret, so a fresh + // tenant can enroll agents without any operator step. + secretsA, err := ds.GetEnrollSecrets(ctx, &idA) + require.NoError(t, err) + require.Len(t, secretsA, 1) + require.NotEmpty(t, secretsA[0].Secret) + require.NotNil(t, secretsA[0].TeamID) + require.Equal(t, idA, *secretsA[0].TeamID) + + // Each tenant gets its own distinct secret. + secretsB, err := ds.GetEnrollSecrets(ctx, &idB) + require.NoError(t, err) + require.Len(t, secretsB, 1) + require.NotEqual(t, secretsA[0].Secret, secretsB[0].Secret) + + // The seeded secret enrolls into the right team: VerifyEnrollSecret accepts it under the + // owning team's pin and rejects it under another tenant's pin (the enrollment fence). + ctxA := fleet.NewOpenframeTeamContext(ctx, idA) + verified, err := ds.VerifyEnrollSecret(ctxA, secretsA[0].Secret) + require.NoError(t, err) + require.NotNil(t, verified.TeamID) + require.Equal(t, idA, *verified.TeamID) + + ctxB := fleet.NewOpenframeTeamContext(ctx, idB) + _, err = ds.VerifyEnrollSecret(ctxB, secretsA[0].Secret) + require.Error(t, err, "tenant A's seeded secret must not verify under tenant B's pin") + + // Resolving an existing team again must not add or replace secrets. + _, err = ds.EnsureOpenframeTeamID(ctx, uuidA) + require.NoError(t, err) + secretsAAgain, err := ds.GetEnrollSecrets(ctx, &idA) + require.NoError(t, err) + require.Len(t, secretsAAgain, 1) + require.Equal(t, secretsA[0].Secret, secretsAAgain[0].Secret) + + // A team with operator-applied (or backfilled) secrets keeps them: replace A's secret set, + // re-resolve, and confirm the applied set is untouched. + applied := []*fleet.EnrollSecret{{Secret: "openframe-test-applied-secret-A", TeamID: &idA}} + require.NoError(t, ds.ApplyEnrollSecrets(ctx, &idA, applied)) + _, err = ds.EnsureOpenframeTeamID(ctx, uuidA) + require.NoError(t, err) + secretsAApplied, err := ds.GetEnrollSecrets(ctx, &idA) + require.NoError(t, err) + require.Len(t, secretsAApplied, 1) + require.Equal(t, "openframe-test-applied-secret-A", secretsAApplied[0].Secret) +} diff --git a/server/datastore/mysql/teams_transfer_openframe_test.go b/server/datastore/mysql/teams_transfer_openframe_test.go new file mode 100644 index 00000000000..5dabcaa474b --- /dev/null +++ b/server/datastore/mysql/teams_transfer_openframe_test.go @@ -0,0 +1,101 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/stretchr/testify/require" +) + +// TestOpenframeTeamReadFence verifies the OPENFRAME(mysql-multitenancy) team scoping: a pinned +// tenant can read/list only its own team; a foreign team id is NotFound and never appears in the +// list. This also hardens the ListTeamPolicies path (its TeamLite guard). Runs under MYSQL_TEST=1. +func TestOpenframeTeamReadFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "tr-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "tr-b"}) + require.NoError(t, err) + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + // TeamLite: own resolves, foreign is NotFound. + got, err := ds.TeamLite(ctxA, teamA.ID) + require.NoError(t, err) + require.Equal(t, teamA.ID, got.ID) + + _, err = ds.TeamLite(ctxA, teamB.ID) + require.True(t, fleet.IsNotFound(err), "foreign team must be NotFound, got %v", err) + + // ListTeams: even a global-admin caller (OpenFrame's token) sees only its pinned team. + teams, err := ds.ListTeams(ctxA, fleet.TeamFilter{User: test.UserAdmin, IncludeObserver: true}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, teams, 1) + require.Equal(t, teamA.ID, teams[0].ID) +} + +// TestOpenframeAddHostsToTeamFence verifies that a shared-DB "transfer" (AddHostsToTeam) cannot move +// hosts across tenants: the target team must be the pinned team, and hosts the tenant does not own +// are dropped. Runs under MYSQL_TEST=1. +func TestOpenframeAddHostsToTeamFence(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "xfer-a"}) + require.NoError(t, err) + teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "xfer-b"}) + require.NoError(t, err) + + newHost := func(name string, teamID *uint) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: &name, + NodeKey: &name, + UUID: "uuid-" + name, + Hostname: name, + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + hostA := newHost("xfer-hostA", &teamA.ID) + hostB := newHost("xfer-hostB", &teamB.ID) + + teamOf := func(id uint) *uint { + var tid *uint + require.NoError(t, ds.writer(ctx).GetContext(ctx, &tid, "SELECT team_id FROM hosts WHERE id = ?", id)) + return tid + } + + ctxA := fleet.NewOpenframeTeamContext(ctx, teamA.ID) + + t.Run("cannot move own host into another tenant's team", func(t *testing.T) { + err := ds.AddHostsToTeam(ctxA, fleet.NewAddHostsToTeamParams(&teamB.ID, []uint{hostA.ID})) + require.True(t, fleet.IsNotFound(err), "foreign target team must be rejected, got %v", err) + require.Equal(t, teamA.ID, *teamOf(hostA.ID), "host A must stay in team A") + }) + + t.Run("cannot move another tenant's host (foreign source dropped)", func(t *testing.T) { + // Target is A's own team (valid), but the host belongs to B → dropped, B's host untouched. + require.NoError(t, ds.AddHostsToTeam(ctxA, fleet.NewAddHostsToTeamParams(&teamA.ID, []uint{hostB.ID}))) + require.Equal(t, teamB.ID, *teamOf(hostB.ID), "host B must stay in team B") + }) + + t.Run("cannot move to No team (nil target)", func(t *testing.T) { + err := ds.AddHostsToTeam(ctxA, fleet.NewAddHostsToTeamParams(nil, []uint{hostA.ID})) + require.True(t, fleet.IsNotFound(err), "nil (No team) target must be rejected, got %v", err) + require.Equal(t, teamA.ID, *teamOf(hostA.ID)) + }) + + t.Run("moving own host to own team is allowed (no-op)", func(t *testing.T) { + require.NoError(t, ds.AddHostsToTeam(ctxA, fleet.NewAddHostsToTeamParams(&teamA.ID, []uint{hostA.ID}))) + require.Equal(t, teamA.ID, *teamOf(hostA.ID)) + }) +} diff --git a/server/fleet/labels.go b/server/fleet/labels.go index 95f0c34fedc..98c9a9de2df 100644 --- a/server/fleet/labels.go +++ b/server/fleet/labels.go @@ -211,6 +211,11 @@ type Label struct { LabelMembershipType LabelMembershipType `json:"label_membership_type" db:"label_membership_type"` HostCount int `json:"host_count,omitempty" db:"host_count"` TeamID *uint `json:"team_id" renameto:"fleet_id" db:"team_id"` + // >>> OPENFRAME(mysql-multitenancy): generated column IFNULL(team_id,0) backing the + // per-team unique index on (name, openframe_team_key); mapped only so `SELECT l.*` + // sqlx scans don't fail on the extra column. Never set by code; not part of the API. + OpenframeTeamKey uint `json:"-" db:"openframe_team_key"` + // <<< OPENFRAME(mysql-multitenancy) } type LabelWithTeamName struct { diff --git a/server/fleet/openframe.go b/server/fleet/openframe.go index c54564488ac..386775955c6 100644 --- a/server/fleet/openframe.go +++ b/server/fleet/openframe.go @@ -1,9 +1,181 @@ package fleet -import "os" +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" +) // IsOpenframeMode returns true when FLEET_OPENFRAME_MODE=1 is set. // All hosts_include_any (policy_hosts / query_hosts) logic is gated behind this flag. func IsOpenframeMode() bool { return os.Getenv("FLEET_OPENFRAME_MODE") == "1" } + +// IsOpenframeMultitenancy reports whether OpenFrame shared-database multitenancy +// is enabled via FLEET_OPENFRAME_MULTI_TENANCY_ENABLED — the Fleet-side mapping of the platform +// property `openframe.fleet.multi-tenancy.enabled`. It is the master switch for every +// multi-tenancy behavior added by this feature: when off, Fleet behaves exactly as the fork did +// before this feature (all tenant fences are inert, even if a stray team-pin env var is set; +// pre-existing fork features — FLEET_OPENFRAME_MODE host assignments, the Redis key prefix, +// agent OpenFrame mode, query-results TTL cleanup — are independent of this flag and unchanged). +// +// Two modes exist when the flag is on (see IsOpenframeSharedMode): +// - pinned: FLEET_OPENFRAME_TENANT_UUID (preferred) or FLEET_OPENFRAME_TEAM_ID pins the whole +// process to one tenant (the transitional one-Fleet-per-tenant deployment); +// - shared: no process pin — one Fleet serves many tenants and every request is pinned +// individually (X-Tenant-Id middleware, host team, enroll secret team). +// +// This is deliberately separate from IsOpenframeMode and from the pre-migration phase, in which +// tenants still have their own databases and run with no team pin. +func IsOpenframeMultitenancy() bool { + return parseOpenframeEnabled(os.Getenv("FLEET_OPENFRAME_MULTI_TENANCY_ENABLED")) +} + +// parseOpenframeEnabled parses the master-flag value ("true"/"1"/etc per strconv.ParseBool, +// whitespace-tolerant; anything unparsable is off). Pure (no env) so it can be unit-tested +// directly. +func parseOpenframeEnabled(raw string) bool { + b, err := strconv.ParseBool(strings.TrimSpace(raw)) + return err == nil && b +} + +// openframeSharedMode caches the mode decision: multitenancy on with no process-level tenant pin +// means this process serves many tenants and must pin each request individually. Cached because +// it is consulted on per-request paths (endpoint middleware). +var openframeSharedMode = sync.OnceValue(func() bool { + _, hasUUID := OpenframeTenantUUID() + _, hasTeamID := parseOpenframeTeamID(os.Getenv("FLEET_OPENFRAME_TEAM_ID")) + return openframeSharedModeDecision(IsOpenframeMultitenancy(), hasUUID, hasTeamID) +}) + +// IsOpenframeSharedMode reports whether this process runs in shared multi-tenant mode: the +// multitenancy flag is on and no process-level pin (tenant UUID or team id) is configured. In +// shared mode a request that cannot be resolved to a tenant must be rejected (fail closed) — +// see the tenant middleware and the host/enroll pin helpers. +func IsOpenframeSharedMode() bool { + return openframeSharedMode() +} + +// openframeSharedModeDecision is the pure decision behind IsOpenframeSharedMode. Separated so it +// can be unit-tested without mutating process env / the cached value. +func openframeSharedModeDecision(multitenancyEnabled, hasUUID, hasTeamID bool) bool { + return multitenancyEnabled && !hasUUID && !hasTeamID +} + +type openframeTeamIDCtxKey struct{} + +// NewOpenframeTeamContext returns a context carrying the OpenFrame tenant team id. It lets a +// request (or test) scope datastore access to a team without relying on the process-global +// FLEET_OPENFRAME_TEAM_ID env var. In shared mode this is how every request gets its tenant +// (set by the X-Tenant-Id middleware / host-auth / enroll pin); it is also required for tests, +// since the MySQL test harness runs in parallel and mutating process env there is unsafe. +func NewOpenframeTeamContext(ctx context.Context, teamID uint) context.Context { + return context.WithValue(ctx, openframeTeamIDCtxKey{}, teamID) +} + +// openframeTeamIDFromEnv parses FLEET_OPENFRAME_TEAM_ID exactly once. The team pin is a +// process-level constant, so the read+parse is cached rather than repeated on every datastore +// call (OpenframeTeamID is on hot enrollment/query paths). The master multitenancy flag is part +// of the cached decision: with the flag off a stray FLEET_OPENFRAME_TEAM_ID must NOT pin the +// process (flag off ⇒ pre-feature fork behavior, a bit for bit). +var openframeTeamIDFromEnv = sync.OnceValues(func() (uint, bool) { + return openframeTeamIDFromEnvDecision(IsOpenframeMultitenancy(), os.Getenv("FLEET_OPENFRAME_TEAM_ID")) +}) + +// openframeTeamIDFromEnvDecision is the pure decision behind the cached env fallback. +// Separated so the "flag off ⇒ a stray FLEET_OPENFRAME_TEAM_ID must NOT pin" guarantee can be +// unit-tested without mutating process env / the cached value. +func openframeTeamIDFromEnvDecision(multitenancyEnabled bool, raw string) (uint, bool) { + if !multitenancyEnabled { + return 0, false + } + return parseOpenframeTeamID(raw) +} + +// parseOpenframeTeamID parses a team id from its string form, returning ok=false for blank, +// non-numeric, or non-positive values. Pure (no env) so it can be unit-tested directly. +func parseOpenframeTeamID(raw string) (uint, bool) { + if raw == "" { + return 0, false + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil || id == 0 { + return 0, false + } + return uint(id), true +} + +// OpenframeTenantUUID returns the Flamingo tenant UUID this process is pinned to +// (FLEET_OPENFRAME_TENANT_UUID), if set. This is the platform's stable, UUID-format tenant +// identity; it is resolved to Fleet's integer team_id at startup via EnsureOpenframeTeamID (the +// teams.openframe_tenant_uuid bridge). +func OpenframeTenantUUID() (string, bool) { + u := strings.TrimSpace(os.Getenv("FLEET_OPENFRAME_TENANT_UUID")) + if u == "" { + return "", false + } + return u, true +} + +// openframePinnedTeamID holds the team id resolved from the tenant UUID at startup +// (0 = not resolved). It takes precedence over the FLEET_OPENFRAME_TEAM_ID env fallback. +// It is only ever set from the flag-gated startup path in cmd/fleet/serve.go (and tests). +var openframePinnedTeamID atomic.Uint64 + +// SetOpenframeTeamID pins this process to the given Fleet team id. Called once at startup after the +// tenant UUID is resolved to its team (EnsureOpenframeTeamID). A zero id is ignored. +func SetOpenframeTeamID(teamID uint) { + if teamID != 0 { + openframePinnedTeamID.Store(uint64(teamID)) + } +} + +// OpenframeTeamID returns the tenant team this request/process is pinned to under OpenFrame +// shared-database multitenancy, in precedence order: the context value if present +// (per-request pin — shared mode middleware, or tests); else the team resolved from +// FLEET_OPENFRAME_TENANT_UUID at startup (SetOpenframeTeamID); else the FLEET_OPENFRAME_TEAM_ID +// env fallback (inert unless FLEET_OPENFRAME_MULTI_TENANCY_ENABLED is on). ok is false when none +// yields a valid (non-zero) team, in which case callers must not assume a tenant scope. +func OpenframeTeamID(ctx context.Context) (uint, bool) { + if ctx != nil { + if v, ok := ctx.Value(openframeTeamIDCtxKey{}).(uint); ok && v != 0 { + return v, true + } + } + if v := openframePinnedTeamID.Load(); v != 0 { + return uint(v), true + } + return openframeTeamIDFromEnv() +} + +// ValidateOpenframeMultitenancy validates the multitenancy configuration at startup. When +// FLEET_OPENFRAME_MULTI_TENANCY_ENABLED is off it is a no-op. When on, both a pinned process +// (FLEET_OPENFRAME_TENANT_UUID or FLEET_OPENFRAME_TEAM_ID set — one Fleet per tenant) and an +// unpinned process (shared mode — every request is pinned individually, fail closed) are valid; +// the only rejected configuration is a FLEET_OPENFRAME_TEAM_ID that is set but unparsable, since +// silently ignoring a typed pin would boot the process into the wrong mode. +func ValidateOpenframeMultitenancy() error { + return openframeMultitenancyConfigError(IsOpenframeMultitenancy(), os.Getenv("FLEET_OPENFRAME_TEAM_ID")) +} + +// openframeMultitenancyConfigError is the pure decision behind ValidateOpenframeMultitenancy. +// Separated so it can be unit-tested without mutating process env / the cached pin. +func openframeMultitenancyConfigError(multitenancyEnabled bool, teamIDRaw string) error { + if !multitenancyEnabled { + return nil + } + if teamIDRaw != "" { + if _, ok := parseOpenframeTeamID(teamIDRaw); !ok { + return fmt.Errorf( + "invalid FLEET_OPENFRAME_TEAM_ID %q: must be a positive integer team id (or unset — with FLEET_OPENFRAME_TENANT_UUID for a pinned process, or neither for shared per-request mode)", + teamIDRaw, + ) + } + } + return nil +} diff --git a/server/fleet/openframe_test.go b/server/fleet/openframe_test.go new file mode 100644 index 00000000000..d6bb014bd75 --- /dev/null +++ b/server/fleet/openframe_test.go @@ -0,0 +1,185 @@ +package fleet + +import ( + "context" + "testing" +) + +func TestParseOpenframeTeamID(t *testing.T) { + cases := []struct { + name string + raw string + wantID uint + wantOK bool + }{ + {name: "blank", raw: "", wantID: 0, wantOK: false}, + {name: "valid", raw: "5", wantID: 5, wantOK: true}, + {name: "zero is not a valid team", raw: "0", wantID: 0, wantOK: false}, + {name: "non-numeric", raw: "abc", wantID: 0, wantOK: false}, + {name: "negative", raw: "-1", wantID: 0, wantOK: false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotID, gotOK := parseOpenframeTeamID(c.raw) + if gotID != c.wantID || gotOK != c.wantOK { + t.Fatalf("parseOpenframeTeamID(%q) = (%d, %v), want (%d, %v)", c.raw, gotID, gotOK, c.wantID, c.wantOK) + } + }) + } +} + +func TestParseOpenframeEnabled(t *testing.T) { + cases := []struct { + raw string + want bool + }{ + {"", false}, + {"true", true}, + {"TRUE", true}, + {"True", true}, + {"1", true}, + {" true ", true}, + {"false", false}, + {"0", false}, + {"yes", false}, + {"enabled", false}, + } + for _, c := range cases { + t.Run(c.raw, func(t *testing.T) { + if got := parseOpenframeEnabled(c.raw); got != c.want { + t.Fatalf("parseOpenframeEnabled(%q) = %v, want %v", c.raw, got, c.want) + } + }) + } +} + +func TestOpenframeTeamIDFromEnvDecision(t *testing.T) { + // Flag off ⇒ the env pin is inert (upstream behavior, even with a stray team id set). + if _, ok := openframeTeamIDFromEnvDecision(false, "7"); ok { + t.Fatal("flag off: FLEET_OPENFRAME_TEAM_ID must not pin the process") + } + // Flag on ⇒ the env pin applies. + if id, ok := openframeTeamIDFromEnvDecision(true, "7"); !ok || id != 7 { + t.Fatalf("flag on: env pin should apply, got (%d,%v)", id, ok) + } + // Flag on with no/bad value ⇒ no pin (shared mode; bad values are caught at startup by + // ValidateOpenframeMultitenancy). + if _, ok := openframeTeamIDFromEnvDecision(true, ""); ok { + t.Fatal("flag on with empty team id must not pin") + } +} + +func TestOpenframeTeamIDContextOverridesEnv(t *testing.T) { + // The context value must win regardless of the env-derived (cached) pin — and it must work + // even with the multitenancy flag off, because tests and the (flag-gated) middleware are the + // only writers of the ctx value. + ctx := NewOpenframeTeamContext(context.Background(), 9) + id, ok := OpenframeTeamID(ctx) + if !ok || id != 9 { + t.Fatalf("context team should be used: got (%d, %v), want (9, true)", id, ok) + } +} + +func TestOpenframeSharedModeDecision(t *testing.T) { + cases := []struct { + name string + multitenancy, hasUUID, hasTeamID bool + want bool + }{ + {name: "off is never shared", multitenancy: false, want: false}, + {name: "off with uuid is never shared", multitenancy: false, hasUUID: true, want: false}, + {name: "on + uuid = pinned", multitenancy: true, hasUUID: true, want: false}, + {name: "on + team id = pinned", multitenancy: true, hasTeamID: true, want: false}, + {name: "on + no pin = shared", multitenancy: true, want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := openframeSharedModeDecision(c.multitenancy, c.hasUUID, c.hasTeamID); got != c.want { + t.Fatalf("openframeSharedModeDecision(%v,%v,%v) = %v, want %v", + c.multitenancy, c.hasUUID, c.hasTeamID, got, c.want) + } + }) + } +} + +func TestOpenframeMultitenancyConfigError(t *testing.T) { + cases := []struct { + name string + multitenancy bool + teamIDRaw string + err bool + }{ + {name: "off is always nil", multitenancy: false, err: false}, + {name: "off ignores a bad team id", multitenancy: false, teamIDRaw: "abc", err: false}, + {name: "on + no pin = shared mode, valid", multitenancy: true, err: false}, + {name: "on + valid team id = pinned, valid", multitenancy: true, teamIDRaw: "5", err: false}, + {name: "on + unparsable team id errors", multitenancy: true, teamIDRaw: "abc", err: true}, + {name: "on + zero team id errors", multitenancy: true, teamIDRaw: "0", err: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotErr := openframeMultitenancyConfigError(c.multitenancy, c.teamIDRaw) != nil + if gotErr != c.err { + t.Fatalf("openframeMultitenancyConfigError(%v,%q) err=%v, want %v", + c.multitenancy, c.teamIDRaw, gotErr, c.err) + } + }) + } +} + +func TestValidateOpenframeMultitenancy(t *testing.T) { + // Off → no-op (the pre-migration per-tenant-database phase, where there is no team pin). + t.Setenv("FLEET_OPENFRAME_MULTI_TENANCY_ENABLED", "") + t.Setenv("FLEET_OPENFRAME_TEAM_ID", "") + if err := ValidateOpenframeMultitenancy(); err != nil { + t.Fatalf("multitenancy off must be a no-op: got %v", err) + } + + // On with no pin → shared per-request mode, valid. + t.Setenv("FLEET_OPENFRAME_MULTI_TENANCY_ENABLED", "true") + if err := ValidateOpenframeMultitenancy(); err != nil { + t.Fatalf("multitenancy on with no pin is shared mode and must validate: got %v", err) + } + + // On with an unparsable team id → refuse to boot (silent fallback into the wrong mode). + t.Setenv("FLEET_OPENFRAME_TEAM_ID", "not-a-team") + if err := ValidateOpenframeMultitenancy(); err == nil { + t.Fatal("multitenancy on with an unparsable FLEET_OPENFRAME_TEAM_ID must fail") + } + + // On with a valid team id → pinned mode, valid. + t.Setenv("FLEET_OPENFRAME_TEAM_ID", "5") + if err := ValidateOpenframeMultitenancy(); err != nil { + t.Fatalf("multitenancy on with a valid team id must validate: got %v", err) + } +} + +func TestOpenframeTenantUUID(t *testing.T) { + t.Setenv("FLEET_OPENFRAME_TENANT_UUID", " ") + if _, ok := OpenframeTenantUUID(); ok { + t.Fatal("blank/whitespace UUID must be treated as unset") + } + t.Setenv("FLEET_OPENFRAME_TENANT_UUID", "3f1a9b2c-0000-4d5e-8f00-000000000001") + u, ok := OpenframeTenantUUID() + if !ok || u != "3f1a9b2c-0000-4d5e-8f00-000000000001" { + t.Fatalf("OpenframeTenantUUID() = (%q,%v), want the set UUID", u, ok) + } +} + +func TestSetOpenframeTeamIDPins(t *testing.T) { + // Resolved pin takes precedence over the env fallback and is returned by OpenframeTeamID. + // (The atomic pin is deliberately not gated on the flag inside OpenframeTeamID: its only + // production writer, cmd/fleet/serve.go, is itself flag-gated.) + openframePinnedTeamID.Store(0) + t.Cleanup(func() { openframePinnedTeamID.Store(0) }) + + SetOpenframeTeamID(0) // ignored + if v := openframePinnedTeamID.Load(); v != 0 { + t.Fatalf("zero pin must be ignored, got %d", v) + } + SetOpenframeTeamID(7) + id, ok := OpenframeTeamID(context.Background()) + if !ok || id != 7 { + t.Fatalf("resolved pin should be returned: got (%d,%v), want (7,true)", id, ok) + } +} diff --git a/server/service/endpoint_middleware.go b/server/service/endpoint_middleware.go index 46d7261ccc3..fd1163de828 100644 --- a/server/service/endpoint_middleware.go +++ b/server/service/endpoint_middleware.go @@ -108,6 +108,12 @@ func authenticatedDevice(svc fleet.Service, logger *slog.Logger, next endpoint.E hostProvider := &hostctx.HostAttributeProvider{Host: host} ctx = ctxerr.AddErrorContextProvider(ctx, hostProvider) + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + return nil, err + } + // <<< OPENFRAME(mysql-multitenancy) + instrumentHostLogger(ctx, host.ID) if ac, ok := authz_ctx.FromContext(ctx); ok { ac.SetAuthnMethod(authnMethod) @@ -150,6 +156,12 @@ func authenticatedHost(svc fleet.Service, logger *slog.Logger, next endpoint.End if !ok { return nil, ctxerr.New(ctx, "osquery pre-auth marker set without host in ctx") } + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + var err error + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + return nil, err + } + // <<< OPENFRAME(mysql-multitenancy) instrumentHostLogger(ctx, host.ID) if ac, ok := authz_ctx.FromContext(ctx); ok { ac.SetAuthnMethod(authz_ctx.AuthnHostToken) @@ -191,6 +203,12 @@ func authenticatedHost(svc fleet.Service, logger *slog.Logger, next endpoint.End hostProvider := &hostctx.HostAttributeProvider{Host: host} ctx = ctxerr.AddErrorContextProvider(ctx, hostProvider) + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + return nil, err + } + // <<< OPENFRAME(mysql-multitenancy) + instrumentHostLogger(ctx, host.ID) if ac, ok := authz_ctx.FromContext(ctx); ok { ac.SetAuthnMethod(authz_ctx.AuthnHostToken) @@ -237,6 +255,12 @@ func authenticatedOrbitHost( hostProvider := &hostctx.HostAttributeProvider{Host: host} ctx = ctxerr.AddErrorContextProvider(ctx, hostProvider) + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + return nil, err + } + // <<< OPENFRAME(mysql-multitenancy) + instrumentHostLogger(ctx, host.ID) if ac, ok := authz_ctx.FromContext(ctx); ok { ac.SetAuthnMethod(authz_ctx.AuthnOrbitToken) diff --git a/server/service/global_policies.go b/server/service/global_policies.go index c5b67c1d128..0f4aa674eb4 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -167,6 +167,15 @@ func (svc Service) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]uint } for _, policy := range policiesByID { if policy.PolicyData.TeamID != nil { + // >>> OPENFRAME(mysql-multitenancy): under a per-request tenant pin every policy the + // tenant owns carries its team id (creation re-homes "global" policies to the pinned + // team), so from the tenant's perspective an own-team policy IS a global policy — let to + // delete proceed. Foreign teams never reach here: the fenced PoliciesByID above already + // returned NotFound for them. Unpinned (flag off) keeps upstreams reject-any-team check. + if pinned, ok := fleet.OpenframeTeamID(ctx); ok && *policy.PolicyData.TeamID == pinned { + continue + } + // <<< OPENFRAME(mysql-multitenancy) return nil, authz.ForbiddenWithInternal( "attempting to delete policy that belongs to team", authz.UserFromContext(ctx), diff --git a/server/service/global_policies_openframe_test.go b/server/service/global_policies_openframe_test.go new file mode 100644 index 00000000000..a222f2f9894 --- /dev/null +++ b/server/service/global_policies_openframe_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +// TestOpenframeDeleteGlobalPoliciesPinnedTeam verifies the OPENFRAME(mysql-multitenancy) adjustment +// in DeleteGlobalPolicies: under a per-request tenant pin the tenant's own policies carry the pinned +// team id (creation re-homes them), so the upstream "belongs to a team → Forbidden" check must let +// own-team policies through. Foreign-team policies still reject, and unpinned behavior is unchanged. +func TestOpenframeDeleteGlobalPoliciesPinnedTeam(t *testing.T) { + const pinnedTeam = uint(7) + + newSvc := func(policyTeamID *uint) (fleet.Service, context.Context) { + ds := new(mock.Store) + ds.PoliciesByIDFunc = func(ctx context.Context, ids []uint) (map[uint]*fleet.Policy, error) { + policies := make(map[uint]*fleet.Policy, len(ids)) + for _, id := range ids { + policies[id] = &fleet.Policy{PolicyData: fleet.PolicyData{ID: id, TeamID: policyTeamID}} + } + return policies, nil + } + // Empty deleted set keeps the post-delete activity loop out of scope for this test. + ds.DeleteGlobalPoliciesFunc = func(ctx context.Context, ids []uint) ([]uint, error) { + return nil, nil + } + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + svc, ctx := newTestService(t, ds, nil, nil) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + return svc, ctx + } + + t.Run("pinned: own-team policy deletes without Forbidden", func(t *testing.T) { + svc, ctx := newSvc(ptr.Uint(pinnedTeam)) + ctx = fleet.NewOpenframeTeamContext(ctx, pinnedTeam) + + _, err := svc.DeleteGlobalPolicies(ctx, []uint{1}) + require.NoError(t, err) + }) + + t.Run("pinned: foreign-team policy still Forbidden", func(t *testing.T) { + svc, ctx := newSvc(ptr.Uint(pinnedTeam + 1)) + ctx = fleet.NewOpenframeTeamContext(ctx, pinnedTeam) + + _, err := svc.DeleteGlobalPolicies(ctx, []uint{1}) + require.Error(t, err, "foreign-team policy must remain Forbidden") + }) + + t.Run("unpinned: team policy Forbidden (upstream behavior unchanged)", func(t *testing.T) { + svc, ctx := newSvc(ptr.Uint(pinnedTeam)) + + _, err := svc.DeleteGlobalPolicies(ctx, []uint{1}) + require.Error(t, err, "unpinned team-policy delete must keep upstream's Forbidden") + }) + + t.Run("unpinned: global (nil-team) policy deletes fine", func(t *testing.T) { + svc, ctx := newSvc(nil) + + _, err := svc.DeleteGlobalPolicies(ctx, []uint{1}) + require.NoError(t, err) + }) +} diff --git a/server/service/openframe_middleware.go b/server/service/openframe_middleware.go new file mode 100644 index 00000000000..c334fde042d --- /dev/null +++ b/server/service/openframe_middleware.go @@ -0,0 +1,104 @@ +// OPENFRAME(mysql-multitenancy): per-request tenant pinning for the shared multi-tenant Fleet +// topology, active only in shared mode. +package service + +import ( + "context" + "log/slog" + "net/http" + "strings" + "sync" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/google/uuid" +) + +// openframeTenantHeader is the trusted tenant UUID the OpenFrame gateway injects (client-supplied +// copies are stripped upstream; fleet-service is reachable only via the gateway). +const openframeTenantHeader = "X-Tenant-Id" + +type openframeTeamEnsurer interface { + EnsureOpenframeTeamID(ctx context.Context, tenantUUID string) (uint, error) +} + +// WithOpenframeTenant pins each control-plane request to the team named by the X-Tenant-Id header. +// Outside shared mode it returns next unchanged (zero overhead); in shared mode a non-exempt +// request without a resolvable tenant is rejected (fail closed). +func WithOpenframeTenant(ds openframeTeamEnsurer, logger *slog.Logger, next http.Handler) http.Handler { + if !fleet.IsOpenframeSharedMode() { + return next + } + return openframeTenantHandler(ds, logger, next) +} + +// openframeTenantHandler is split out so it can be tested without toggling the cached shared-mode env. +func openframeTenantHandler(ds openframeTeamEnsurer, logger *slog.Logger, next http.Handler) http.Handler { + var teamIDByTenantUUID sync.Map // tenant UUID → team id uint (a tenant's team id never changes) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Agent/device/MDM planes carry no gateway header — their tenant comes from the + // authenticated host / enroll secret (openframePinHostTeam, the enrollment pins). + if openframeTenantExemptPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + tenantUUID := strings.TrimSpace(r.Header.Get(openframeTenantHeader)) + if tenantUUID == "" { + logger.WarnContext(ctx, "openframe shared mode: rejecting request without tenant header", + "path", r.URL.Path, "remote_addr", r.RemoteAddr) + encodeError(ctx, fleet.NewAuthRequiredError("missing tenant"), w) + return + } + + teamID, cached := teamIDByTenantUUID.Load(tenantUUID) + if !cached { + // Validate before resolving: EnsureOpenframeTeamID would mint a team for any string. + if _, err := uuid.Parse(tenantUUID); err != nil { + logger.WarnContext(ctx, "openframe shared mode: rejecting request with malformed tenant header", + "path", r.URL.Path, "remote_addr", r.RemoteAddr) + encodeError(ctx, fleet.NewAuthRequiredError("invalid tenant"), w) + return + } + id, err := ds.EnsureOpenframeTeamID(ctx, tenantUUID) + if err != nil { + logger.ErrorContext(ctx, "openframe shared mode: resolving tenant team", + "tenant_uuid", tenantUUID, "err", err) + encodeError(ctx, err, w) + return + } + teamIDByTenantUUID.Store(tenantUUID, id) + teamID = id + } + + next.ServeHTTP(w, r.WithContext(fleet.NewOpenframeTeamContext(ctx, teamID.(uint)))) + }) +} + +// openframeTenantExemptPath matches the agent/device/MDM-protocol planes, which derive their +// tenant from the authenticated principal rather than the gateway header. +func openframeTenantExemptPath(path string) bool { + for _, marker := range []string{"/osquery/", "/fleet/orbit/", "/fleet/device/", "/mdm/", "/fleet/ota_enrollment"} { + if strings.Contains(path, marker) { + return true + } + } + return false +} + +// openframePinHostTeam scopes ctx to the authenticated host's team in shared mode; a host with no +// team fails auth (fail closed) rather than running unscoped. No-op outside shared mode. +func openframePinHostTeam(ctx context.Context, host *fleet.Host) (context.Context, error) { + if !fleet.IsOpenframeSharedMode() { + return ctx, nil + } + return openframePinHostTeamShared(ctx, host) +} + +func openframePinHostTeamShared(ctx context.Context, host *fleet.Host) (context.Context, error) { + if host == nil || host.TeamID == nil || *host.TeamID == 0 { + return ctx, fleet.NewAuthFailedError("openframe shared mode: authenticated host has no team") + } + return fleet.NewOpenframeTeamContext(ctx, *host.TeamID), nil +} diff --git a/server/service/openframe_middleware_test.go b/server/service/openframe_middleware_test.go new file mode 100644 index 00000000000..9e1c9be371f --- /dev/null +++ b/server/service/openframe_middleware_test.go @@ -0,0 +1,177 @@ +// OPENFRAME(mysql-multitenancy): tests for the shared-mode per-request tenant pin. +// The shared-mode gates (WithOpenframeTenant / openframePinHostTeam) read a cached env +// decision, so the tests exercise the mode-independent bodies (openframeTenantHandler, +// openframePinHostTeamShared) directly — same pattern as the pure-function tests in +// server/fleet/openframe_test.go. +package service + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTeamEnsurer struct { + teamID uint + err error + calls int +} + +func (f *fakeTeamEnsurer) EnsureOpenframeTeamID(_ context.Context, _ string) (uint, error) { + f.calls++ + return f.teamID, f.err +} + +func testTenantHandler(t *testing.T, ensurer *fakeTeamEnsurer) http.Handler { + t.Helper() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + return openframeTenantHandler(ensurer, slog.New(slog.DiscardHandler), next) +} + +func TestOpenframeTenantHandlerPinsFromHeader(t *testing.T) { + ensurer := &fakeTeamEnsurer{teamID: 42} + var gotTeam uint + var gotOK bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotTeam, gotOK = fleet.OpenframeTeamID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + h := openframeTenantHandler(ensurer, slog.New(slog.DiscardHandler), next) + + const tenantUUID = "3f1a9b2c-0000-4d5e-8f00-000000000001" + for i := 0; i < 3; i++ { + req := httptest.NewRequest("GET", "/api/latest/fleet/hosts", nil) + req.Header.Set("X-Tenant-Id", tenantUUID) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + require.True(t, gotOK, "request ctx must carry the tenant team pin") + require.Equal(t, uint(42), gotTeam) + } + // uuid → team id never changes: resolved once, then served from the cache. + assert.Equal(t, 1, ensurer.calls) +} + +func TestOpenframeTenantHandlerFailClosed(t *testing.T) { + t.Run("missing header on a control-plane path is rejected", func(t *testing.T) { + ensurer := &fakeTeamEnsurer{teamID: 42} + h := testTenantHandler(t, ensurer) + req := httptest.NewRequest("GET", "/api/latest/fleet/hosts", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Zero(t, ensurer.calls) + }) + + t.Run("malformed tenant uuid is rejected and mints no team", func(t *testing.T) { + ensurer := &fakeTeamEnsurer{teamID: 42} + h := testTenantHandler(t, ensurer) + req := httptest.NewRequest("GET", "/api/latest/fleet/hosts", nil) + req.Header.Set("X-Tenant-Id", "not-a-uuid") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Zero(t, ensurer.calls, "a malformed uuid must never reach EnsureOpenframeTeamID") + }) + + t.Run("resolver error does not pass the request through", func(t *testing.T) { + ensurer := &fakeTeamEnsurer{err: context.DeadlineExceeded} + reached := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true }) + h := openframeTenantHandler(ensurer, slog.New(slog.DiscardHandler), next) + req := httptest.NewRequest("GET", "/api/latest/fleet/hosts", nil) + req.Header.Set("X-Tenant-Id", "3f1a9b2c-0000-4d5e-8f00-000000000001") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.NotEqual(t, http.StatusOK, rr.Code) + assert.False(t, reached, "a request whose tenant cannot be resolved must not run") + }) +} + +func TestOpenframeTenantHandlerAgentPathsExempt(t *testing.T) { + ensurer := &fakeTeamEnsurer{teamID: 42} + var pinnedOK bool + reached := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + _, pinnedOK = fleet.OpenframeTeamID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + h := openframeTenantHandler(ensurer, slog.New(slog.DiscardHandler), next) + + // No header, agent-plane path → passes through unpinned; the tenant is derived later + // from the authenticated host / enroll secret. + req := httptest.NewRequest("POST", "/api/v1/osquery/config", nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + require.True(t, reached) + assert.False(t, pinnedOK) + assert.Zero(t, ensurer.calls) +} + +func TestOpenframeTenantExemptPath(t *testing.T) { + exempt := []string{ + "/api/v1/osquery/enroll", + "/api/osquery/log", + "/api/osquery/carve/block", + "/api/fleet/orbit/enroll", + "/api/fleet/orbit/ping", + "/api/latest/fleet/orbit/config", + "/api/latest/fleet/device/token123/desktop", + "/api/fleet/device/ping", + "/api/mdm/apple/enroll", + "/api/mdm/microsoft/discovery", + "/api/latest/fleet/mdm/bootstrap", + "/api/latest/fleet/mdm/setup/eula/token123", + "/api/latest/fleet/ota_enrollment", + } + for _, p := range exempt { + assert.True(t, openframeTenantExemptPath(p), "expected exempt: %s", p) + } + + notExempt := []string{ + "/api/latest/fleet/hosts", + "/api/latest/fleet/hosts/1", + "/api/latest/fleet/config", + "/api/latest/fleet/software", + "/api/latest/fleet/login", + "/api/latest/fleet/queries", + } + for _, p := range notExempt { + assert.False(t, openframeTenantExemptPath(p), "expected NOT exempt: %s", p) + } +} + +func TestOpenframePinHostTeamShared(t *testing.T) { + ctx := context.Background() + + t.Run("host with a team pins the ctx", func(t *testing.T) { + teamID := uint(7) + got, err := openframePinHostTeamShared(ctx, &fleet.Host{TeamID: &teamID}) + require.NoError(t, err) + id, ok := fleet.OpenframeTeamID(got) + require.True(t, ok) + assert.Equal(t, uint(7), id) + }) + + t.Run("host without a team fails auth", func(t *testing.T) { + _, err := openframePinHostTeamShared(ctx, &fleet.Host{}) + require.Error(t, err) + var authFailed *fleet.AuthFailedError + assert.ErrorAs(t, err, &authFailed) + }) + + t.Run("nil host fails auth", func(t *testing.T) { + _, err := openframePinHostTeamShared(ctx, nil) + require.Error(t, err) + }) +} diff --git a/server/service/orbit.go b/server/service/orbit.go index 99fbf37d55c..a52aaed3ccd 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -188,6 +188,16 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf return "", fleet.OrbitError{Message: err.Error()} } + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin the request to the enroll secret's + // team (fail closed) so the enrollment fence scopes matching. + if fleet.IsOpenframeSharedMode() { + if secret.TeamID == nil || *secret.TeamID == 0 { + return "", fleet.NewAuthFailedError("openframe shared mode: enroll secret has no team") + } + ctx = fleet.NewOpenframeTeamContext(ctx, *secret.TeamID) + } + // <<< OPENFRAME(mysql-multitenancy) + identifier := hostInfo.OsqueryIdentifier if identifier == "" { identifier = hostInfo.HardwareUUID diff --git a/server/service/osquery.go b/server/service/osquery.go index e494918bcf9..4ad0ea7f693 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -112,6 +112,16 @@ func (svc *Service) EnrollOsquery(ctx context.Context, enrollSecret, hostIdentif return "", newOsqueryErrorWithInvalidNode("enroll failed: " + err.Error()) } + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin the request to the enroll secret's + // team (fail closed) so the enrollment fence scopes matching. + if fleet.IsOpenframeSharedMode() { + if secret.TeamID == nil || *secret.TeamID == 0 { + return "", newOsqueryErrorWithInvalidNode("enroll failed: enroll secret has no team") + } + ctx = fleet.NewOpenframeTeamContext(ctx, *secret.TeamID) + } + // <<< OPENFRAME(mysql-multitenancy) + identityCert, err := svc.ds.GetHostIdentityCertByName(ctx, hostIdentifier) if err != nil && !fleet.IsNotFound(err) { return "", fleet.OrbitError{Message: fmt.Sprintf("loading certificate: %s", err.Error())} diff --git a/server/service/osquery_header_auth.go b/server/service/osquery_header_auth.go index 0c0ea0604fc..9367501b57e 100644 --- a/server/service/osquery_header_auth.go +++ b/server/service/osquery_header_auth.go @@ -99,6 +99,14 @@ func osqueryHeaderPreAuth(svc fleet.Service, logger *slog.Logger) func(http.Hand // authenticatedHost passthrough after kithttp.ServerBefore runs. ctx = hostctx.NewContext(ctx, host) ctx = ctxerr.AddErrorContextProvider(ctx, &hostctx.HostAttributeProvider{Host: host}) + + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + encodeError(ctx, err, w) + return + } + // <<< OPENFRAME(mysql-multitenancy) + ctx = osqueryauth.NewPreAuthedContext(ctx) if debug { ctx = osqueryauth.NewDebugContext(ctx) @@ -167,6 +175,14 @@ func osqueryCarveBlockHeaderPreAuth(svc fleet.Service, logger *slog.Logger) func // carve-ownership check. ctx = hostctx.NewContext(ctx, host) ctx = ctxerr.AddErrorContextProvider(ctx, &hostctx.HostAttributeProvider{Host: host}) + + // >>> OPENFRAME(mysql-multitenancy): shared mode — pin to the host's team (fail closed). + if ctx, err = openframePinHostTeam(ctx, host); err != nil { + encodeError(ctx, err, w) + return + } + // <<< OPENFRAME(mysql-multitenancy) + next.ServeHTTP(w, r.WithContext(ctx)) }) } From ef57781c96ac98002ecf7d0b48f128c30c0ce68d Mon Sep 17 00:00:00 2001 From: Oleksandr Didukh Date: Tue, 21 Jul 2026 19:20:59 +0200 Subject: [PATCH 2/4] fix(helm): read FLEET_OPENFRAME_TENANT_UUID from a ConfigMap unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: remove the if/else-if in deployment.yaml that inlined the tenant UUID as an env value. The Deployment now always reads FLEET_OPENFRAME_TENANT_UUID via configMapKeyRef — either the operator's own ConfigMap (existingConfigMap) or the chart-managed "fleet-openframe-tenant" that configmap.yaml creates when no existingConfigMap is supplied (same pattern as the DB/cache config). Empty in shared mode / flag-off ⇒ Fleet treats "" as unset. Co-Authored-By: Claude Opus 4.8 --- charts/fleet/templates/configmap.yaml | 13 +++++++++++++ charts/fleet/templates/deployment.yaml | 7 +------ charts/fleet/values.yaml | 6 +++--- openframe/docs/helm-chart.md | 11 +++++++++-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/charts/fleet/templates/configmap.yaml b/charts/fleet/templates/configmap.yaml index 6249d49440f..997e43bf7da 100644 --- a/charts/fleet/templates/configmap.yaml +++ b/charts/fleet/templates/configmap.yaml @@ -22,3 +22,16 @@ data: {{ .Values.database.databaseKey }}: {{ .Values.database.database | quote }} {{ .Values.database.usernameKey }}: {{ .Values.database.username | quote }} {{- end }} +--- +{{- if not .Values.fleet.openframe.multiTenancy.existingConfigMap }} +# >>> OPENFRAME(mysql-multitenancy): chart-managed ConfigMap holding the tenant UUID. +apiVersion: v1 +kind: ConfigMap +metadata: + name: fleet-openframe-tenant + labels: + {{- include "fleet.labels" . | nindent 4 }} +data: + {{ default "FLEET_OPENFRAME_TENANT_UUID" .Values.fleet.openframe.multiTenancy.tenantUuidKey }}: {{ .Values.fleet.openframe.multiTenancy.tenantUuid | quote }} +{{- end }} +# <<< OPENFRAME(mysql-multitenancy) diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml index ee73f66f329..c05a3259dbb 100644 --- a/charts/fleet/templates/deployment.yaml +++ b/charts/fleet/templates/deployment.yaml @@ -215,16 +215,11 @@ spec: # "true" + no tenant UUID ⇒ shared per-request mode (one Fleet per cluster, fail closed). - name: FLEET_OPENFRAME_MULTI_TENANCY_ENABLED value: {{ .Values.fleet.openframe.multiTenancy.enabled | quote }} - {{- if .Values.fleet.openframe.multiTenancy.existingConfigMap }} - name: FLEET_OPENFRAME_TENANT_UUID valueFrom: configMapKeyRef: - name: {{ .Values.fleet.openframe.multiTenancy.existingConfigMap }} + name: {{ default "fleet-openframe-tenant" .Values.fleet.openframe.multiTenancy.existingConfigMap }} key: {{ default "FLEET_OPENFRAME_TENANT_UUID" .Values.fleet.openframe.multiTenancy.tenantUuidKey }} - {{- else if .Values.fleet.openframe.multiTenancy.tenantUuid }} - - name: FLEET_OPENFRAME_TENANT_UUID - value: {{ .Values.fleet.openframe.multiTenancy.tenantUuid | quote }} - {{- end }} {{- if .Values.fleet.openframe.multiTenancy.teamId }} - name: FLEET_OPENFRAME_TEAM_ID value: {{ .Values.fleet.openframe.multiTenancy.teamId | quote }} diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index c28db984824..d96096cde25 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -115,9 +115,9 @@ fleet: openframe: multiTenancy: enabled: false - tenantUuid: "" # pinned mode only; static value (ignored if existingConfigMap is set) - existingConfigMap: "" # optional: read the tenant UUID from a ConfigMap instead - tenantUuidKey: "" # key within existingConfigMap (default FLEET_OPENFRAME_TENANT_UUID) + tenantUuid: "" # pinned mode: tenant UUID, stored in the chart-managed ConfigMap + existingConfigMap: "" # optional: read the tenant UUID from your own ConfigMap instead + tenantUuidKey: "" # key holding the UUID in either ConfigMap (default FLEET_OPENFRAME_TENANT_UUID) teamId: "" # escape hatch: direct FLEET_OPENFRAME_TEAM_ID pin (prefer tenantUuid) # <<< OPENFRAME(mysql-multitenancy) mdm: diff --git a/openframe/docs/helm-chart.md b/openframe/docs/helm-chart.md index 2bd2739197c..5b476f68aee 100644 --- a/openframe/docs/helm-chart.md +++ b/openframe/docs/helm-chart.md @@ -55,8 +55,14 @@ fleet: Rendered env vars ([deployment.yaml](../../charts/fleet/templates/deployment.yaml)): `FLEET_OPENFRAME_MULTI_TENANCY_ENABLED` is always emitted (`"false"` by default — pre-feature -fork behavior); `FLEET_OPENFRAME_TENANT_UUID` only when a source is configured (pinned mode); -neither pin ⇒ shared per-request mode. The **flag is also emitted into +fork behavior). `FLEET_OPENFRAME_TENANT_UUID` is **always read via `configMapKeyRef`** — there is +no inline value or branching in the Deployment (same pattern as the DB/cache config) — from one of: +- `existingConfigMap` set → the operator's own ConfigMap; +- `existingConfigMap` unset → the chart-managed **`fleet-openframe-tenant`** ConfigMap that + [configmap.yaml](../../charts/fleet/templates/configmap.yaml) creates, holding `tenantUuid`. + +In shared per-request mode (`enabled: true`, no `tenantUuid`) and flag-off, the value is empty and +Fleet treats `""` as unset — so no pin. The **flag is also emitted into [job-migration.yaml](../../charts/fleet/templates/job-migration.yaml)** so `fleet prepare db` takes the `GET_LOCK` serialization on a shared MySQL. @@ -87,6 +93,7 @@ just references them. |---------|-------------------|--------------------------------------------------|-------------------------------------| | Database | `database.*` | `database.existingConfigMap`, `database.existingSecret` | `fleet-database` ConfigMap (host/port/db/user) + Secret (password) | | Cache (Redis) | `cache.*` | `cache.existingConfigMap` | `fleet-cache` ConfigMap (address, key prefix) | +| Tenant UUID (multi-tenancy) | `fleet.openframe.multiTenancy.*` | `fleet.openframe.multiTenancy.existingConfigMap` | `fleet-openframe-tenant` ConfigMap (`FLEET_OPENFRAME_TENANT_UUID` = `tenantUuid`, empty in shared mode) | | Admin setup | `fleet.setup.*` | `fleet.setup.adminPassword.existingSecret` | `fleet-setup` Secret (`FLEET_SETUP_ADMIN_PASSWORD`) | Keys within the referenced ConfigMap are themselves configurable From 4afdb5f32a2ff88286abbfc5c54caaba7b6a749b Mon Sep 17 00:00:00 2001 From: Oleksandr Didukh Date: Tue, 21 Jul 2026 19:42:47 +0200 Subject: [PATCH 3/4] fix(openframe): address CodeRabbit review on multitenancy - Validate FLEET_OPENFRAME_TENANT_UUID at startup: pinned mode now rejects a malformed tenant UUID (uuid.Parse) instead of letting EnsureOpenframeTeamID create a garbage "openframe-" team and seed a secret. Mirrors the shared-mode X-Tenant-Id check; keeps valid pinned / team-id / shared behavior. - Narrow the tenant-middleware MDM exemption from the broad "/mdm/" to "/api/mdm/" (device enroll/installer/Microsoft), so user-authenticated admin MDM APIs under /api/{v}/fleet/mdm/ (commands, enrollment_profile) still require X-Tenant-Id in shared mode. Residual device-facing DEP paths that collide by HTTP method are documented (MDM unused in OpenFrame). - Add the openframe/docs path to the OPENFRAME sentinels in the Helm configmap.yaml/deployment.yaml blocks (fork convention). Co-Authored-By: Claude Opus 4.8 --- charts/fleet/templates/configmap.yaml | 2 +- charts/fleet/templates/deployment.yaml | 2 +- server/fleet/openframe.go | 26 +++++++++++++++++---- server/fleet/openframe_test.go | 19 +++++++++------ server/service/openframe_middleware.go | 21 ++++++++++++++--- server/service/openframe_middleware_test.go | 13 +++++++++-- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/charts/fleet/templates/configmap.yaml b/charts/fleet/templates/configmap.yaml index 997e43bf7da..5b2d3afda82 100644 --- a/charts/fleet/templates/configmap.yaml +++ b/charts/fleet/templates/configmap.yaml @@ -24,7 +24,7 @@ data: {{- end }} --- {{- if not .Values.fleet.openframe.multiTenancy.existingConfigMap }} -# >>> OPENFRAME(mysql-multitenancy): chart-managed ConfigMap holding the tenant UUID. +# >>> OPENFRAME(mysql-multitenancy): chart-managed ConfigMap holding the tenant UUID — openframe/docs/helm-chart.md apiVersion: v1 kind: ConfigMap metadata: diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml index c05a3259dbb..6c4e02449f9 100644 --- a/charts/fleet/templates/deployment.yaml +++ b/charts/fleet/templates/deployment.yaml @@ -209,7 +209,7 @@ spec: - name: FLEET_OPENFRAME_MODE value: {{ .Values.fleet.setup.openframeMode | quote }} # <<< OPENFRAME(helm) - # >>> OPENFRAME(mysql-multitenancy): shared-DB multitenancy feature envs. + # >>> OPENFRAME(mysql-multitenancy): shared-DB multitenancy feature envs — openframe/docs/helm-chart.md # Master switch (maps openframe.fleet.multi-tenancy.enabled). "false" ⇒ pre-feature # fork behavior. "true" + tenant UUID ⇒ pinned mode (one Fleet per tenant); # "true" + no tenant UUID ⇒ shared per-request mode (one Fleet per cluster, fail closed). diff --git a/server/fleet/openframe.go b/server/fleet/openframe.go index 386775955c6..9f38dd86a12 100644 --- a/server/fleet/openframe.go +++ b/server/fleet/openframe.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "sync/atomic" + + "github.com/google/uuid" ) // IsOpenframeMode returns true when FLEET_OPENFRAME_MODE=1 is set. @@ -156,16 +158,22 @@ func OpenframeTeamID(ctx context.Context) (uint, bool) { // ValidateOpenframeMultitenancy validates the multitenancy configuration at startup. When // FLEET_OPENFRAME_MULTI_TENANCY_ENABLED is off it is a no-op. When on, both a pinned process // (FLEET_OPENFRAME_TENANT_UUID or FLEET_OPENFRAME_TEAM_ID set — one Fleet per tenant) and an -// unpinned process (shared mode — every request is pinned individually, fail closed) are valid; -// the only rejected configuration is a FLEET_OPENFRAME_TEAM_ID that is set but unparsable, since -// silently ignoring a typed pin would boot the process into the wrong mode. +// unpinned process (shared mode — every request is pinned individually, fail closed) are valid. +// A set-but-malformed pin is rejected — an unparsable FLEET_OPENFRAME_TEAM_ID (silently ignoring +// it would boot the wrong mode), or a non-UUID FLEET_OPENFRAME_TENANT_UUID (which would otherwise +// reach EnsureOpenframeTeamID and create a garbage `openframe-` team + seed a secret; the +// shared-mode X-Tenant-Id path already rejects non-UUIDs, so this keeps pinned mode symmetric). func ValidateOpenframeMultitenancy() error { - return openframeMultitenancyConfigError(IsOpenframeMultitenancy(), os.Getenv("FLEET_OPENFRAME_TEAM_ID")) + return openframeMultitenancyConfigError( + IsOpenframeMultitenancy(), + os.Getenv("FLEET_OPENFRAME_TEAM_ID"), + os.Getenv("FLEET_OPENFRAME_TENANT_UUID"), + ) } // openframeMultitenancyConfigError is the pure decision behind ValidateOpenframeMultitenancy. // Separated so it can be unit-tested without mutating process env / the cached pin. -func openframeMultitenancyConfigError(multitenancyEnabled bool, teamIDRaw string) error { +func openframeMultitenancyConfigError(multitenancyEnabled bool, teamIDRaw, tenantUUIDRaw string) error { if !multitenancyEnabled { return nil } @@ -177,5 +185,13 @@ func openframeMultitenancyConfigError(multitenancyEnabled bool, teamIDRaw string ) } } + if tenantUUID := strings.TrimSpace(tenantUUIDRaw); tenantUUID != "" { + if _, err := uuid.Parse(tenantUUID); err != nil { + return fmt.Errorf( + "invalid FLEET_OPENFRAME_TENANT_UUID %q: must be a valid UUID (or unset — with FLEET_OPENFRAME_TEAM_ID for a pinned process, or neither for shared per-request mode)", + tenantUUID, + ) + } + } return nil } diff --git a/server/fleet/openframe_test.go b/server/fleet/openframe_test.go index d6bb014bd75..bdf584c13b8 100644 --- a/server/fleet/openframe_test.go +++ b/server/fleet/openframe_test.go @@ -104,24 +104,29 @@ func TestOpenframeSharedModeDecision(t *testing.T) { func TestOpenframeMultitenancyConfigError(t *testing.T) { cases := []struct { - name string - multitenancy bool - teamIDRaw string - err bool + name string + multitenancy bool + teamIDRaw string + tenantUUIDRaw string + err bool }{ {name: "off is always nil", multitenancy: false, err: false}, {name: "off ignores a bad team id", multitenancy: false, teamIDRaw: "abc", err: false}, + {name: "off ignores a bad tenant uuid", multitenancy: false, tenantUUIDRaw: "nope", err: false}, {name: "on + no pin = shared mode, valid", multitenancy: true, err: false}, {name: "on + valid team id = pinned, valid", multitenancy: true, teamIDRaw: "5", err: false}, {name: "on + unparsable team id errors", multitenancy: true, teamIDRaw: "abc", err: true}, {name: "on + zero team id errors", multitenancy: true, teamIDRaw: "0", err: true}, + {name: "on + valid tenant uuid = pinned, valid", multitenancy: true, tenantUUIDRaw: "1877e27c-b3fa-488f-82b6-449b80c1cc97", err: false}, + {name: "on + valid tenant uuid with surrounding space, valid", multitenancy: true, tenantUUIDRaw: " 1877e27c-b3fa-488f-82b6-449b80c1cc97 ", err: false}, + {name: "on + malformed tenant uuid errors", multitenancy: true, tenantUUIDRaw: "openframe-junk", err: true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - gotErr := openframeMultitenancyConfigError(c.multitenancy, c.teamIDRaw) != nil + gotErr := openframeMultitenancyConfigError(c.multitenancy, c.teamIDRaw, c.tenantUUIDRaw) != nil if gotErr != c.err { - t.Fatalf("openframeMultitenancyConfigError(%v,%q) err=%v, want %v", - c.multitenancy, c.teamIDRaw, gotErr, c.err) + t.Fatalf("openframeMultitenancyConfigError(%v,%q,%q) err=%v, want %v", + c.multitenancy, c.teamIDRaw, c.tenantUUIDRaw, gotErr, c.err) } }) } diff --git a/server/service/openframe_middleware.go b/server/service/openframe_middleware.go index c334fde042d..0fa63de9cb1 100644 --- a/server/service/openframe_middleware.go +++ b/server/service/openframe_middleware.go @@ -76,10 +76,25 @@ func openframeTenantHandler(ds openframeTeamEnsurer, logger *slog.Logger, next h }) } -// openframeTenantExemptPath matches the agent/device/MDM-protocol planes, which derive their -// tenant from the authenticated principal rather than the gateway header. +// openframeTenantExemptPath matches the agent/device/MDM-enrollment planes, which derive their +// tenant from the authenticated principal (host node key / enroll secret / device cert) rather +// than the gateway X-Tenant-Id header. +// +// The MDM marker is "/api/mdm/" — the device enrollment/management endpoints (Apple +// /api/mdm/apple/{enroll,installer,account_driven_enroll}, Microsoft /api/mdm/microsoft…). It is +// deliberately NOT the broad "/mdm/": the user-authenticated admin MDM APIs live under +// /api/{v}/fleet/mdm/… (e.g. .../mdm/apple/commands, .../mdm/apple/enrollment_profile) and MUST +// still require X-Tenant-Id in shared mode. The raw device protocol (/mdm/apple/scep, /mdm/apple/mdm, +// SCEP proxy) is served on the root mux and never reaches this wrapper. +// +// Residual: a few device-facing DEP endpoints live under /api/{v}/fleet/mdm/ too (GET +// .../mdm/bootstrap download, GET .../mdm/setup/eula/{token}) and share their path with admin +// variants that differ only by HTTP method — so path-only matching cannot exempt them without also +// exempting the admin route. They are NOT exempted here (they'd need X-Tenant-Id). This is +// acceptable because OpenFrame does not use Apple/Windows MDM; if it ever does, make this exemption +// method-aware. See openframe/docs/agent-ingestion-isolation.md. func openframeTenantExemptPath(path string) bool { - for _, marker := range []string{"/osquery/", "/fleet/orbit/", "/fleet/device/", "/mdm/", "/fleet/ota_enrollment"} { + for _, marker := range []string{"/osquery/", "/fleet/orbit/", "/fleet/device/", "/api/mdm/", "/fleet/ota_enrollment"} { if strings.Contains(path, marker) { return true } diff --git a/server/service/openframe_middleware_test.go b/server/service/openframe_middleware_test.go index 9e1c9be371f..f023b2901d1 100644 --- a/server/service/openframe_middleware_test.go +++ b/server/service/openframe_middleware_test.go @@ -129,9 +129,9 @@ func TestOpenframeTenantExemptPath(t *testing.T) { "/api/latest/fleet/device/token123/desktop", "/api/fleet/device/ping", "/api/mdm/apple/enroll", + "/api/mdm/apple/installer", + "/api/mdm/apple/account_driven_enroll", "/api/mdm/microsoft/discovery", - "/api/latest/fleet/mdm/bootstrap", - "/api/latest/fleet/mdm/setup/eula/token123", "/api/latest/fleet/ota_enrollment", } for _, p := range exempt { @@ -145,6 +145,15 @@ func TestOpenframeTenantExemptPath(t *testing.T) { "/api/latest/fleet/software", "/api/latest/fleet/login", "/api/latest/fleet/queries", + // User-authenticated admin MDM APIs — must still require X-Tenant-Id in shared mode + // (these are under /api/{v}/fleet/mdm/, not /api/mdm/). + "/api/latest/fleet/mdm/apple/commands", + "/api/latest/fleet/mdm/apple/enrollment_profile", + "/api/v1/fleet/mdm/commands", + // Device-facing DEP paths that share their path with admin variants (method-only difference); + // not exempt by path alone. Acceptable — OpenFrame does not use Apple/Windows MDM. + "/api/latest/fleet/mdm/bootstrap", + "/api/latest/fleet/mdm/setup/eula/token123", } for _, p := range notExempt { assert.False(t, openframeTenantExemptPath(p), "expected NOT exempt: %s", p) From e5163b4efc00bcda300e9935c33c6fff89b171dc Mon Sep 17 00:00:00 2001 From: Oleksandr Didukh Date: Wed, 22 Jul 2026 10:49:39 +0200 Subject: [PATCH 4/4] fix(helm): Move defaults to the values --- charts/fleet/templates/configmap.yaml | 2 +- charts/fleet/templates/deployment.yaml | 2 +- charts/fleet/values.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/fleet/templates/configmap.yaml b/charts/fleet/templates/configmap.yaml index 5b2d3afda82..5291fb9144d 100644 --- a/charts/fleet/templates/configmap.yaml +++ b/charts/fleet/templates/configmap.yaml @@ -32,6 +32,6 @@ metadata: labels: {{- include "fleet.labels" . | nindent 4 }} data: - {{ default "FLEET_OPENFRAME_TENANT_UUID" .Values.fleet.openframe.multiTenancy.tenantUuidKey }}: {{ .Values.fleet.openframe.multiTenancy.tenantUuid | quote }} + {{ .Values.fleet.openframe.multiTenancy.tenantUuidKey }}: {{ .Values.fleet.openframe.multiTenancy.tenantUuid | quote }} {{- end }} # <<< OPENFRAME(mysql-multitenancy) diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml index 6c4e02449f9..c845f772c6f 100644 --- a/charts/fleet/templates/deployment.yaml +++ b/charts/fleet/templates/deployment.yaml @@ -219,7 +219,7 @@ spec: valueFrom: configMapKeyRef: name: {{ default "fleet-openframe-tenant" .Values.fleet.openframe.multiTenancy.existingConfigMap }} - key: {{ default "FLEET_OPENFRAME_TENANT_UUID" .Values.fleet.openframe.multiTenancy.tenantUuidKey }} + key: {{ .Values.fleet.openframe.multiTenancy.tenantUuidKey }} {{- if .Values.fleet.openframe.multiTenancy.teamId }} - name: FLEET_OPENFRAME_TEAM_ID value: {{ .Values.fleet.openframe.multiTenancy.teamId | quote }} diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml index d96096cde25..db4f50f1e50 100644 --- a/charts/fleet/values.yaml +++ b/charts/fleet/values.yaml @@ -117,7 +117,7 @@ fleet: enabled: false tenantUuid: "" # pinned mode: tenant UUID, stored in the chart-managed ConfigMap existingConfigMap: "" # optional: read the tenant UUID from your own ConfigMap instead - tenantUuidKey: "" # key holding the UUID in either ConfigMap (default FLEET_OPENFRAME_TENANT_UUID) + tenantUuidKey: "FLEET_OPENFRAME_TENANT_UUID" # key holding the UUID in either ConfigMap teamId: "" # escape hatch: direct FLEET_OPENFRAME_TEAM_ID pin (prefer tenantUuid) # <<< OPENFRAME(mysql-multitenancy) mdm: