feat(openframe): shared-DB multi-tenancy (tenant = Fleet team) - #76
Conversation
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
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (6)
WalkthroughAdds OpenFrame MySQL multitenancy configuration and startup handling, shared-mode request tenant pinning, serialized migrations, tenant-scoped schema uniqueness, and datastore fencing. App configuration, enrollment secrets, labels, hosts, policies, queries, targets, and teams now apply tenant boundaries. New unit and MySQL integration tests cover configuration, migrations, cache isolation, request middleware, and cross-tenant access behavior. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
server/datastore/mysql/app_configs.go (1)
42-80: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftKeep
app_config_jsonout of the tenant keyspace
app_config_jsonstill has a singleton row atid = 1, andteams.idalso starts at1. That means tenant1will read/write the legacy global config row, so config can bleed between the shared singleton and the first tenant. Add a migration or separate keyspace before switching pinned reads/writes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/app_configs.go` around lines 42 - 80, Prevent pinned app-config reads from using the legacy singleton keyspace: update appConfigDB and the corresponding app-config write path to store tenant configs under a separate, migrated keyspace rather than teams.id values. Add the required migration and ensure tenant 1 cannot resolve the legacy app_config_json row at id 1; preserve unpinned access to the singleton configuration.
🧹 Nitpick comments (8)
server/datastore/mysql/openframe.go (1)
21-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate per-table tenant-scoping boilerplate.
filterHostIDsByTeam/filterPolicyIDsByTeam/filterQueryIDsByTeamandopenframeScopePolicyHosts/openframeScopeQueryHostsrepeat the same shape, differing only by table name and error text. Consider a single generic helper parameterized by table name to reduce duplication as more entities get tenant-scoped.♻️ Example consolidation
func filterIDsByTeam(ctx context.Context, q sqlx.QueryerContext, table string, ids []uint, teamID uint) ([]uint, error) { if len(ids) == 0 { return ids, nil } // table must always be a trusted literal supplied by the caller, never user input. stmt, args, err := sqlx.In(fmt.Sprintf(`SELECT id FROM %s WHERE id IN (?) AND team_id = ?`, table), ids, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building team-scoped "+table+" id filter") } var owned []uint if err := sqlx.SelectContext(ctx, q, &owned, stmt, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "filtering "+table+" ids by team") } return owned, nil }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/openframe.go` around lines 21 - 120, Consolidate filterHostIDsByTeam, filterPolicyIDsByTeam, and filterQueryIDsByTeam into one trusted-table helper parameterized by table name, preserving empty-input behavior and contextual error wrapping. Update openframeScopePolicyHosts and openframeScopeQueryHosts to use the shared helper while retaining their distinct parent-team verification and not-found behavior.server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go (1)
54-96: 🧹 Nitpick | 🔵 TrivialLGTM!
For production rollout on a large
hoststable, worth confirming theADD COLUMN ... VIRTUAL+ADD UNIQUE KEYsteps run with an online (ALGORITHM=INPLACE, LOCK=NONE) DDL plan on the actual MySQL version in use, since a fallback to a table copy/lock would extend the migration well beyond the 15-minute lock wait already budgeted inprepare.go.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go` around lines 54 - 96, Update the migration’s generated-column and unique-index ALTER TABLE statements in Up_20260626000001 to explicitly request the supported online DDL plan using ALGORITHM=INPLACE and LOCK=NONE, and verify the syntax against the production MySQL version. Preserve the existing idempotency checks and error handling for both ADD operations.server/service/endpoint_middleware.go (2)
111-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSentinel comments omit a documentation link.
As per coding guidelines,
`**/*.go,yaml,tpl}`: Wrap every OpenFrame fork edit within a shared upstream file in `OPENFRAME(<slug>)` sentinel comments that explain the change and link to its documentation.These 4 blocks explain the change but don't link to any doc. Same gap recurs inserver/service/orbit.go,server/service/osquery.go, andserver/datastore/mysql/labels.go— see consolidated comment.Also applies to: 159-164, 206-211, 258-263
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/service/endpoint_middleware.go` around lines 111 - 116, Update every OPENFRAME(mysql-multitenancy) sentinel block in endpoint_middleware.go and the corresponding blocks in orbit.go, osquery.go, and labels.go to include the required documentation link alongside the existing change explanation. Apply the same link consistently to all referenced blocks without changing their implementation logic.Source: Coding guidelines
111-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winOPENFRAME sentinel comments across this PR don't link to documentation. As per coding guidelines,
`**/*.go,yaml,tpl}`: Wrap every OpenFrame fork edit within a shared upstream file in `OPENFRAME(<slug>)` sentinel comments that explain the change and link to its documentation.Every sentinel added in this cohort explains the change inline but has no link — one root cause repeated at each site below.
server/service/endpoint_middleware.go#L111-L116: add a documentation link to this sentinel (and the matching ones at 159-164, 206-211, 258-263).server/service/orbit.go#L191-L200: add a documentation link to this sentinel.server/service/osquery.go#L115-L124: add a documentation link to this sentinel.server/datastore/mysql/labels.go#L630-L638: add a documentation link to this sentinel (and the matching one at 889-934).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/service/endpoint_middleware.go` around lines 111 - 116, Update every listed OPENFRAME sentinel to include the canonical documentation link while preserving its inline explanation: server/service/endpoint_middleware.go ranges 111-116, 159-164, 206-211, and 258-263; server/service/orbit.go range 191-200; server/service/osquery.go range 115-124; and server/datastore/mysql/labels.go ranges 630-638 and 889-934. Keep the surrounding implementations, including openframePinHostTeam, unchanged.Source: Coding guidelines
server/datastore/mysql/labels.go (2)
921-934: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
WHERE/ANDchoice relies on an implicit invariant, not the query's actual shape.
whereOrAndis picked purely fromlen(initialParams) > 0, assuming a query with initial params always already has a WHERE clause and one without never does. This holds for all current call sites but isn't guaranteed by the function's contract — a future caller with a param-less pre-existing WHERE (or a params-bearing query with no WHERE) would get malformed SQL. Consider deriving this from whetherqueryactually containsWHERE, or making it an explicit parameter.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/labels.go` around lines 921 - 934, Update the query-building logic around the WHERE/AND selection to inspect the SQL shape of query rather than infer clause presence from len(initialParams). Ensure existing WHERE clauses append conditions with AND, queries without WHERE clauses add WHERE, and preserve the current parameter expansion behavior.
895-910: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick wingocritic: rewrite if/else-if/else to a switch.
Static analysis flags this chain (inside the
filter.TeamID != nilcase) as anifElseChain.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/labels.go` around lines 895 - 910, Rewrite the nested if/else-if/else chain within the filter.TeamID != nil case of the surrounding switch into a switch statement, preserving the existing behavior for TeamID 0, inaccessible teams, and accessible team filtering.Source: Linters/SAST tools
server/service/orbit.go (1)
191-200: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winEnroll-secret tenant-pinning logic is duplicated between two service files. Both blocks implement the same rule (fail closed if
secret.TeamIDis nil/0, else pinctxviafleet.NewOpenframeTeamContext) independently, risking future drift.
server/service/orbit.go#L191-L200: extract this block (and the one in osquery.go) into a shared helper, e.g.openframePinEnrollSecretTeam(ctx, secret)alongsideopenframePinHostTeaminopenframe_middleware.go, then call it here.server/service/osquery.go#L115-L124: call the same shared helper here instead of duplicating the check.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/service/orbit.go` around lines 191 - 200, Extract the duplicated enroll-secret tenant-pinning logic into a shared openframePinEnrollSecretTeam helper alongside openframePinHostTeam in openframe_middleware.go, preserving the nil/zero TeamID failure and context pinning behavior. Update server/service/orbit.go lines 191-200 and server/service/osquery.go lines 115-124 to call the helper instead of implementing the check inline.server/datastore/mysql/hosts.go (1)
664-665: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOPENFRAME sentinels omit the required documentation link.
The
mysql-multitenancysentinel blocks throughout this PR explain the change but don't link to its documentation, unlike thehost-assignmentsblocks in this same file (e.g. Lines 536, 1777 referenceopenframe/docs/architecture-host-assignments.md). Add the corresponding multitenancy doc reference to eachOPENFRAME(mysql-multitenancy)block. Flagging once here as this applies to every mysql-multitenancy sentinel across the changed files.As per coding guidelines: "Wrap every OpenFrame fork edit within a shared upstream file in
OPENFRAME(<slug>)sentinel comments that explain the change and link to its documentation."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/hosts.go` around lines 664 - 665, Update every OPENFRAME(mysql-multitenancy) sentinel block across the changed files, including the block near the host deletion logic, to include a reference link to the multitenancy documentation. Preserve each block’s existing explanation and match the documentation-link style used by the OPENFRAME(host-assignments) sentinels.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@server/fleet/openframe.go`:
- Around line 156-181: Extend ValidateOpenframeMultitenancy and its pure helper
openframeMultitenancyConfigError to read and validate
FLEET_OPENFRAME_TENANT_UUID when multitenancy is enabled. Reject any non-empty
malformed UUID before EnsureOpenframeTeamID can create a team, using the
existing UUID validation/parsing convention and an error that identifies the
invalid variable; preserve valid pinned, valid team-ID, and unpinned shared-mode
behavior.
In `@server/service/openframe_middleware.go`:
- Around line 79-88: Update openframeTenantExemptPath to remove the broad
"/mdm/" substring exemption and replace it with checks for only the intended
device/agent MDM route templates. Preserve exemptions for the other existing
markers, while ensuring user-authenticated control-plane MDM APIs such as apple
commands, enrollment profiles, and MDM commands still require X-Tenant-Id.
---
Outside diff comments:
In `@server/datastore/mysql/app_configs.go`:
- Around line 42-80: Prevent pinned app-config reads from using the legacy
singleton keyspace: update appConfigDB and the corresponding app-config write
path to store tenant configs under a separate, migrated keyspace rather than
teams.id values. Add the required migration and ensure tenant 1 cannot resolve
the legacy app_config_json row at id 1; preserve unpinned access to the
singleton configuration.
---
Nitpick comments:
In `@server/datastore/mysql/hosts.go`:
- Around line 664-665: Update every OPENFRAME(mysql-multitenancy) sentinel block
across the changed files, including the block near the host deletion logic, to
include a reference link to the multitenancy documentation. Preserve each
block’s existing explanation and match the documentation-link style used by the
OPENFRAME(host-assignments) sentinels.
In `@server/datastore/mysql/labels.go`:
- Around line 921-934: Update the query-building logic around the WHERE/AND
selection to inspect the SQL shape of query rather than infer clause presence
from len(initialParams). Ensure existing WHERE clauses append conditions with
AND, queries without WHERE clauses add WHERE, and preserve the current parameter
expansion behavior.
- Around line 895-910: Rewrite the nested if/else-if/else chain within the
filter.TeamID != nil case of the surrounding switch into a switch statement,
preserving the existing behavior for TeamID 0, inaccessible teams, and
accessible team filtering.
In
`@server/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.go`:
- Around line 54-96: Update the migration’s generated-column and unique-index
ALTER TABLE statements in Up_20260626000001 to explicitly request the supported
online DDL plan using ALGORITHM=INPLACE and LOCK=NONE, and verify the syntax
against the production MySQL version. Preserve the existing idempotency checks
and error handling for both ADD operations.
In `@server/datastore/mysql/openframe.go`:
- Around line 21-120: Consolidate filterHostIDsByTeam, filterPolicyIDsByTeam,
and filterQueryIDsByTeam into one trusted-table helper parameterized by table
name, preserving empty-input behavior and contextual error wrapping. Update
openframeScopePolicyHosts and openframeScopeQueryHosts to use the shared helper
while retaining their distinct parent-team verification and not-found behavior.
In `@server/service/endpoint_middleware.go`:
- Around line 111-116: Update every OPENFRAME(mysql-multitenancy) sentinel block
in endpoint_middleware.go and the corresponding blocks in orbit.go, osquery.go,
and labels.go to include the required documentation link alongside the existing
change explanation. Apply the same link consistently to all referenced blocks
without changing their implementation logic.
- Around line 111-116: Update every listed OPENFRAME sentinel to include the
canonical documentation link while preserving its inline explanation:
server/service/endpoint_middleware.go ranges 111-116, 159-164, 206-211, and
258-263; server/service/orbit.go range 191-200; server/service/osquery.go range
115-124; and server/datastore/mysql/labels.go ranges 630-638 and 889-934. Keep
the surrounding implementations, including openframePinHostTeam, unchanged.
In `@server/service/orbit.go`:
- Around line 191-200: Extract the duplicated enroll-secret tenant-pinning logic
into a shared openframePinEnrollSecretTeam helper alongside openframePinHostTeam
in openframe_middleware.go, preserving the nil/zero TeamID failure and context
pinning behavior. Update server/service/orbit.go lines 191-200 and
server/service/osquery.go lines 115-124 to call the helper instead of
implementing the check inline.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 91309fca-deab-4698-ba1a-954b88898427
⛔ Files ignored due to path filters (3)
CLAUDE.mdis excluded by!**/*.mdopenframe/docs/helm-chart.mdis excluded by!**/*.mdopenframe/docs/mysql-multitenancy-feature.mdis excluded by!**/*.md
📒 Files selected for processing (41)
charts/fleet/templates/deployment.yamlcharts/fleet/templates/job-migration.yamlcharts/fleet/values.yamlcmd/fleet/prepare.gocmd/fleet/serve.goopenframe/scripts/verify.shserver/datastore/cached_mysql/cached_mysql.goserver/datastore/cached_mysql/cached_mysql_test.goserver/datastore/mysql/app_configs.goserver/datastore/mysql/app_configs_openframe_test.goserver/datastore/mysql/enroll_secrets_openframe_test.goserver/datastore/mysql/host_assignments_openframe_test.goserver/datastore/mysql/hosts.goserver/datastore/mysql/hosts_openframe_test.goserver/datastore/mysql/labels.goserver/datastore/mysql/labels_openframe_test.goserver/datastore/mysql/migrations/openframe/20260620000001_ScopeLabelUniqueNameToTeam.goserver/datastore/mysql/migrations/openframe/20260626000001_ScopeHostIdentityUniqueToTeam.goserver/datastore/mysql/migrations/openframe/20260629000001_AddTeamsOpenframeTenantUUID.goserver/datastore/mysql/migrations/openframe/migration.goserver/datastore/mysql/migrations_openframe_test.goserver/datastore/mysql/openframe.goserver/datastore/mysql/policies.goserver/datastore/mysql/policies_queries_openframe_test.goserver/datastore/mysql/queries.goserver/datastore/mysql/targets.goserver/datastore/mysql/targets_openframe_test.goserver/datastore/mysql/teams.goserver/datastore/mysql/teams_openframe_test.goserver/datastore/mysql/teams_transfer_openframe_test.goserver/fleet/labels.goserver/fleet/openframe.goserver/fleet/openframe_test.goserver/service/endpoint_middleware.goserver/service/global_policies.goserver/service/global_policies_openframe_test.goserver/service/openframe_middleware.goserver/service/openframe_middleware_test.goserver/service/orbit.goserver/service/osquery.goserver/service/osquery_header_auth.go
…ionally 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 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@charts/fleet/templates/configmap.yaml`:
- Around line 27-37: Update the opening OPENFRAME(mysql-multitenancy) sentinel
in the ConfigMap template to include the repository’s actual documentation path,
while preserving its existing slug and rationale metadata.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 316df16f-d0ce-43ba-9195-82766a602f3b
⛔ Files ignored due to path filters (1)
openframe/docs/helm-chart.mdis excluded by!**/*.md
📒 Files selected for processing (3)
charts/fleet/templates/configmap.yamlcharts/fleet/templates/deployment.yamlcharts/fleet/values.yaml
🚧 Files skipped from review as they are similar to previous changes (1)
- charts/fleet/values.yaml
- 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-<junk>" 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 <noreply@anthropic.com>
Fleet MySQL multi-tenancy (OpenFrame)
Enables one Fleet server + one shared MySQL to serve many tenants, where tenant = Fleet team (
team_id), with row-level isolation enforced at the datastore. Gated byFLEET_OPENFRAME_MULTI_TENANCY_ENABLED(openframe.fleet.multi-tenancy.enabled). Flag off ⇒ pre-feature fork behavior (schema change aside — it is semantics-preserving; see below).Full reference:
openframe/docs/mysql-multitenancy-feature.md.What's included
OpenframeTeamID(ctx)pin chain;EnsureOpenframeTeamIDUUID→team_idbridge, which also auto-seeds a new team's enroll secret so a fresh tenant can enroll agents.teams.openframe_tenant_uuid; per-team unique identity/name onhosts/labelsvia anIFNULL(team_id,0)generated column.WithOpenframeTenant(X-Tenant-Id, agent paths exempt); agent host pins and enroll-secret pins.GET_LOCKmigration serialization on the shared DB; Helm flag/env wiring incharts/fleet.Backward compatibility
Flag off ⇒ identical behavior except the schema: the three migrations run unconditionally but are semantics-preserving (all rows
team_id NULL→ key 0 → original global uniqueness, byte-for-byte). Verified locally against a branch-native DB:prepare dbapplies all three; server boots healthy flag-off, flag-on-pinned (team + secret auto-created), and flag-on-shared; upstreamTestLabels/TestHostspass on the migrated schema with no OpenFrame env.Testing
MYSQL_TEST=1*_openframe_test.goacross every fence +EnsureOpenframeTeamID(incl. secret seeding); flag-parsing unit tests inserver/fleet/openframe_test.go; middleware tests inserver/service/openframe_middleware_test.go;make openframe-verify.Deferred (latent, non-applicable today)
Users/software/vuln read-views (UI not exposed past the gateway allowlist), unpinned custom-label creation, legacy 2017 packs stats join,
IsEnrollSecretAvailable.Deploy order
Deploy the fenced code (inert until pinned) → run migrations (
prepare db) → backfill each tenant'steam_idbefore flipping its flag (un-backfilled rows fail closed — hide data, never leak) → enable the flag per tenant. Greenfield (empty shared DB, agents re-enroll) skips the backfill at the cost of host history.Summary by CodeRabbit
New Features
Bug Fixes