From 951c97ec2b512d6c70b9bacee7d062344f6daf1b Mon Sep 17 00:00:00 2001 From: Kirill Gontar Date: Mon, 24 Aug 2026 12:26:13 +0200 Subject: [PATCH 1/5] done --- openframe/docs/README.md | 1 + openframe/docs/fork-file-manifest.md | 3 +- openframe/docs/managed-policies.md | 141 ++++++++++++++++++ openframe/scripts/verify.sh | 2 +- ...00001_AddPoliciesOpenframeManagedColumn.go | 47 ++++++ server/datastore/mysql/policies.go | 125 ++++++++++++++++ .../mysql/policies_openframe_managed_test.go | 139 +++++++++++++++++ server/fleet/api_policies.go | 3 + server/fleet/openframe.go | 22 +++ server/fleet/policies.go | 19 +++ server/service/global_policies.go | 3 + server/service/team_policies.go | 8 + 12 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 openframe/docs/managed-policies.md create mode 100644 server/datastore/mysql/migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go create mode 100644 server/datastore/mysql/policies_openframe_managed_test.go diff --git a/openframe/docs/README.md b/openframe/docs/README.md index 2d841b9a409..5766651445a 100644 --- a/openframe/docs/README.md +++ b/openframe/docs/README.md @@ -39,6 +39,7 @@ The agent has its own switch, `--openframe-mode` / `ORBIT_OPENFRAME_MODE`. |-----|--------| | [architecture-host-assignments.md](architecture-host-assignments.md) | Direct host → policy/query targeting (`policy_hosts` / `query_hosts` join tables), gated by `FLEET_OPENFRAME_MODE`. Design & internals. | | [api-host-assignments.md](api-host-assignments.md) | REST API for the above (add/remove/replace/list hosts). | +| [managed-policies.md](managed-policies.md) | `policies.openframe_managed` — platform-owned policies omitted from the policy list/count endpoints (and from GitOps deletion) while still running on hosts and reporting results. Gated by `FLEET_OPENFRAME_MODE`. | | [api-expose-osquery-host-id.md](api-expose-osquery-host-id.md) | Exposes `osquery_host_id` in the host JSON so the OpenFrame control plane can match agents. | | [query-results-ttl-cleanup.md](query-results-ttl-cleanup.md) | Time-based cleanup of `query_results` (keeps the Debezium CDC pipeline alive without unbounded growth). Gated by OpenFrame mode **and** a positive TTL. | | [redis-key-prefix.md](redis-key-prefix.md) | Per-tenant Redis key/channel prefix (`FLEET_REDIS_KEY_PREFIX`) so tenants can share one Redis. | diff --git a/openframe/docs/fork-file-manifest.md b/openframe/docs/fork-file-manifest.md index f177a25a04d..9d2fba58dbe 100644 --- a/openframe/docs/fork-file-manifest.md +++ b/openframe/docs/fork-file-manifest.md @@ -75,7 +75,8 @@ server/service/openframe/ # agent token-auth pipeline server/datastore/mysql/migrations/openframe/ # separate goose client ├── migration.go ├── 20260301000001_AddPolicyHostsJoinTable.go -└── 20260301000002_AddQueryHostsJoinTable.go +├── 20260301000002_AddQueryHostsJoinTable.go +└── 20260818000001_AddPoliciesOpenframeManagedColumn.go # policies.openframe_managed server/datastore/redis/keyprefix.go # per-tenant Redis prefix server/fleet/openframe.go # IsOpenframeMode() gate diff --git a/openframe/docs/managed-policies.md b/openframe/docs/managed-policies.md new file mode 100644 index 00000000000..6a14bca1a1e --- /dev/null +++ b/openframe/docs/managed-policies.md @@ -0,0 +1,141 @@ +# OpenFrame-Managed Policies + +## Overview + +An **OpenFrame-managed policy** is a normal Fleet policy carrying `policies.openframe_managed = 1`. +It is omitted from the policy **list** and **count** endpoints — the set the main UI renders and the +set GitOps reconciles — while it keeps running on hosts and keeps recording results exactly like any +other policy. + +The use case is platform-owned checks: OpenFrame needs its own compliance/telemetry policies on +every tenant without them cluttering the tenant operator's Policies page. + +The column is named `openframe_managed` rather than something generic like `hidden` or `internal` so +that it can never collide semantically with a field upstream Fleet may add later — the same +reasoning as `teams.openframe_tenant_uuid`. + +This is fork-only behavior, gated on `FLEET_OPENFRAME_MODE` (see +[architecture-host-assignments.md](architecture-host-assignments.md)) because the column is created +by the OpenFrame migration pipeline, which non-OpenFrame databases never run. + +## What the flag does — and what it deliberately does not + +| Surface | Managed policy | +|---|---| +| `GET /policies`, `GET /teams/{id}/policies` (incl. inherited + `merge_inherited`) | **omitted** | +| `GET /policies/count`, `GET /teams/{id}/policies/count` | **not counted** | +| GitOps deletion pass (`fleetctl gitops`) | **invisible → never deleted** | +| `GET /policies/{id}` (single policy) | returned in full | +| `PATCH /policies/{id}` | accepted — including `openframe_managed: false` | +| `POST /policies/delete` | accepted | +| `POST /spec/policies` with a matching name | **silently overwrites the policy** | +| Host details → `policies` array | returned | +| Host failing-policies count / Issues column | counted | +| Fleet Desktop ("My device") | counted | +| Activity feed (`created_policy` / `edited_policy`) | recorded | +| Automations (webhooks, Jira/Zendesk, install software, run script, calendar) | fire normally | +| osquery agent config on the host | delivered (the query text is readable on the endpoint) | + +**This flag is decluttering, not access control.** It filters listings only; write paths and by-id +reads never consult it. Verified against a running server: a user holding admin or maintainer can + +1. confirm a managed policy's name — `POST /policies` with that name returns + `Policy "" already exists`; +2. read it in full by id (ids are sequential and trivially enumerated); +3. unhide and rewrite it — `PATCH /policies/{id} {"openframe_managed": false, "query": "..."}`; +4. delete it — `POST /policies/delete {"ids":[N]}`; +5. **overwrite it silently** — `POST /spec/policies` matches on `(team, name)` via + `INSERT ... ON DUPLICATE KEY UPDATE`, so the query/description/platform are replaced while + `openframe_managed` stays `1`. The operator ends up owning a policy they cannot see, and the + platform ends up running a query it did not write. This one fires by accident on a mere name + collision, no malice required. + +If any of that matters, the fix is guards on the write paths (`modifyPolicy`, both delete paths, +`ApplyPolicySpecs`) returning 404 for managed policies, plus 404 on by-id reads. Full concealment +from a tenant admin is not reachable while they hold write access to the same name space — the +uniqueness key `(team_id, name)` always leaks existence — unless platform policy names get a +reserved prefix. + +### GitOps interaction + +`fleetctl gitops` deletes team policies that are not in the YAML. It builds that "existing" set from +`GetPolicies`, i.e. the list endpoint, so managed policies are invisible to it and survive a GitOps +apply untouched. The apply half is the hazard described above. + +## API + +Create (both `POST /policies` and `POST /teams/{id}/policies`): + +```json +{ "name": "openframe: disk encryption", "query": "SELECT 1 ...", "openframe_managed": true } +``` + +Modify takes `"openframe_managed": true|false`; omitting the field leaves the flag as-is. Every +policy payload returns `"openframe_managed"` so the platform can tell them apart. + +There is deliberately **no `include_managed` query parameter** on the standard endpoints: the +listing fails closed, and a user-supplied flag cannot widen it. The platform reads managed policies +by id, or through a dedicated OpenFrame endpoint. + +## Implementation + +### Schema + +`server/datastore/mysql/migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go` +adds `policies.openframe_managed TINYINT(1) NOT NULL DEFAULT 0`. It ALTERs an upstream table from +the OpenFrame pipeline — the same pattern as `teams.openframe_tenant_uuid`, and it is on the +[semantic-conflict watchlist](upstream-sync-conflict-resolution.md). + +### Why the column is not in `policyCols` + +The datastore test harness loads `server/datastore/mysql/schema.sql`, which is dumped from the +**upstream** `tables/` migrations only and therefore has no `openframe_managed` column (see +[migrations.md](migrations.md)). Referencing it unconditionally in the shared `policyCols` SELECT +would break every upstream policy test. So, mirroring `loadHostsForPolicies`: + +- **read** — `loadOpenframeManagedForPolicies` fills `Policy.OpenframeManaged` in a separate + `SELECT id, openframe_managed` after each listing; +- **write** — `setPolicyOpenframeManaged` issues its own `UPDATE policies SET openframe_managed = ?` + inside the existing create/save transaction, leaving the upstream `INSERT`/`UPDATE` statements + byte-identical (they are a standing rebase cost); +- **filter** — `openframeManagedExclusion(ctx, "p")` returns `AND p.openframe_managed = 0` in + OpenFrame mode and the empty string otherwise. + +All three consult `IsOpenframeModeCtx(ctx)` rather than `IsOpenframeMode()`: it honors a context +override when one is set and falls back to `FLEET_OPENFRAME_MODE` otherwise. Production never sets +the override; the MySQL tests need it because `CreateMySQLDS` marks every test parallel, which makes +`t.Setenv` panic and a process-wide `os.Setenv` unsafe for the tests running alongside — the same +reasoning that produced `NewOpenframeTeamContext`. + +All three live in `server/datastore/mysql/policies.go` under `OPENFRAME(managed-policies)` markers. + +### Touched files + +| File | Change | +|------|--------| +| `migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go` | new column | +| `server/fleet/policies.go` | `OpenframeManaged` on `PolicyData`, `PolicyPayload`, `NewTeamPolicyPayload`, `ModifyPolicyPayload` | +| `server/fleet/openframe.go` | `NewOpenframeModeContext` / `IsOpenframeModeCtx` — context override for the mode gate, so the parallel MySQL test harness can exercise it without `t.Setenv` | +| `server/fleet/api_policies.go` | `OpenframeManaged` on `GlobalPolicyRequest` | +| `server/service/global_policies.go` | maps the flag into the create payload | +| `server/service/team_policies.go` | maps the flag on team create and on modify | +| `server/datastore/mysql/policies.go` | helpers + exclusion in `listPoliciesDB`, `getInheritedPoliciesForTeam`, `ListMergedTeamPolicies`, `CountPolicies`, `CountMergedTeamPolicies` | +| `server/datastore/mysql/policies_openframe_managed_test.go` | MySQL coverage for all of the above | + +## Host assignment interaction (open item) + +In OpenFrame mode `PolicyQueriesForHost` requires a `policy_hosts` row for every policy +([architecture-host-assignments.md](architecture-host-assignments.md) claims a "no rows → all hosts" +fallback that the code does not implement). A managed policy is subject to the same rule: without +assignments it reaches no host, and hosts enrolling later are not backfilled. + +Making the flag bypass that requirement is a one-line change in both `policyQueriesForHostStmt` and +`ListPoliciesForHost`: + +```sql +AND (p.openframe_managed = 1 OR EXISTS ( + SELECT 1 FROM policy_hosts ph WHERE ph.policy_id = p.id AND ph.host_id = ? +)) +``` + +It is **not** part of this change — it alters what agents execute, so it wants its own review. diff --git a/openframe/scripts/verify.sh b/openframe/scripts/verify.sh index 69b991b7a8f..95bed218261 100755 --- a/openframe/scripts/verify.sh +++ b/openframe/scripts/verify.sh @@ -46,7 +46,7 @@ rm -f vet.err # 3. Marker presence: if a merge silently dropped fork code, its OPENFRAME markers # vanish too. A slug dropping to zero is a red flag worth a human look. step "OPENFRAME marker presence (dropped-fork-code detector)" -for slug in host-assignments redis-key-prefix redis-seed-nodes query-results-ttl osquery-host-id agent-openframe-mode agent-json-content-type migration-race; do +for slug in host-assignments managed-policies redis-key-prefix redis-seed-nodes query-results-ttl osquery-host-id agent-openframe-mode agent-json-content-type migration-race; do n=$(grep -rIl "OPENFRAME($slug" --include='*.go' --include='*.yaml' --include='*.tpl' . 2>/dev/null | wc -l | tr -d ' ') if [ "$n" -gt 0 ]; then ok "$slug — present in $n file(s)"; else bad "$slug — NO markers found (fork code may have been dropped in the merge)"; fi done diff --git a/server/datastore/mysql/migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go b/server/datastore/mysql/migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go new file mode 100644 index 00000000000..7da6a18d9a5 --- /dev/null +++ b/server/datastore/mysql/migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go @@ -0,0 +1,47 @@ +package openframe + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260818000001, Down_20260818000001) +} + +// Up_20260818000001 adds `policies.openframe_managed` — the flag that keeps a policy out of the policy list +// endpoints (the set the main UI renders) while it keeps running on hosts and keeps reporting +// results. See openframe/docs/managed-policies.md. +// +// It is also the escape hatch for host-assignment scoping: an OpenFrame-managed policy runs on every in-scope +// host without any policy_hosts rows, so a platform-owned check needs no per-host assignment +// backfill for hosts that enroll later. +// +// Idempotent. +// +// SEMANTIC-CONFLICT WATCHLIST (openframe/docs/upstream-sync-conflict-resolution.md): +// this ALTERs the upstream `policies` table from the OpenFrame pipeline. If a future upstream +// release rebuilds that table, re-verify this migration after the sync. +func Up_20260818000001(tx *sql.Tx) error { + const ( + table = "policies" + column = "openframe_managed" + ) + + hasCol, err := columnExists(tx, table, column) + if err != nil { + return fmt.Errorf("checking %s.%s column: %w", table, column, err) + } + if hasCol { + return nil + } + + if _, err := tx.Exec("ALTER TABLE policies ADD COLUMN openframe_managed TINYINT(1) NOT NULL DEFAULT 0"); err != nil { + return fmt.Errorf("adding %s.%s column: %w", table, column, err) + } + return nil +} + +func Down_20260818000001(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 5f1156933d2..c3005fb578c 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -154,6 +154,9 @@ func newGlobalPolicy(ctx context.Context, db sqlx.ExtContext, authorID *uint, ar if err := updatePolicyLabelsTx(ctx, db, dummyPolicy); err != nil { return nil, ctxerr.Wrap(ctx, err, "setting policy labels") } + if err := setPolicyOpenframeManaged(ctx, db, policyID, args.OpenframeManaged); err != nil { + return nil, err + } return policyDB(ctx, db, policyID, nil) } @@ -406,6 +409,71 @@ func loadHostsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies // <<< OPENFRAME(host-assignments) +// >>> OPENFRAME(managed-policies): platform-owned policies kept out of the policy list endpoints — openframe/docs/managed-policies.md +// +// Everything here is gated on IsOpenframeMode() because `policies.openframe_managed` is created by the +// OpenFrame migration pipeline, which the datastore test harness (schema.sql, dumped from upstream +// migrations only) does not run — see openframe/docs/migrations.md. + +// openframeManagedExclusion returns the WHERE fragment that keeps OpenFrame-managed policies out of a listing. +// Empty outside OpenFrame mode, where the column does not necessarily exist. +func openframeManagedExclusion(ctx context.Context, alias string) string { + if !fleet.IsOpenframeModeCtx(ctx) { + return "" + } + return fmt.Sprintf(" AND %s.openframe_managed = 0", alias) +} + +// setPolicyOpenframeManaged writes the openframe_managed flag as its own statement so the upstream INSERT/UPDATE +// statements stay byte-identical (they are a standing rebase cost — see +// openframe/docs/upstream-sync-conflict-resolution.md). +func setPolicyOpenframeManaged(ctx context.Context, db sqlx.ExtContext, policyID uint, internal bool) error { + if !fleet.IsOpenframeModeCtx(ctx) { + return nil + } + if _, err := db.ExecContext(ctx, `UPDATE policies SET openframe_managed = ? WHERE id = ?`, internal, policyID); err != nil { + return ctxerr.Wrap(ctx, err, "setting policy openframe_managed flag") + } + return nil +} + +// loadOpenframeManagedForPolicies fills Policy.OpenframeManaged after a listing, mirroring loadHostsForPolicies. Kept +// out of policyCols for the same reason: the column is absent in non-OpenFrame databases. +func loadOpenframeManagedForPolicies(ctx context.Context, db sqlx.QueryerContext, policies []*fleet.Policy) error { + if !fleet.IsOpenframeModeCtx(ctx) || len(policies) == 0 { + return nil + } + + policyIDs := make([]uint, 0, len(policies)) + policyMap := make(map[uint]*fleet.Policy, len(policies)) + for _, policy := range policies { + policyIDs = append(policyIDs, policy.ID) + policyMap[policy.ID] = policy + } + + stmt, args, err := sqlx.In(`SELECT id, openframe_managed FROM policies WHERE id IN (?)`, policyIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "building query to load policy openframe_managed flags") + } + + rows := []struct { + ID uint `db:"id"` + OpenframeManaged bool `db:"openframe_managed"` + }{} + + if err := sqlx.SelectContext(ctx, db, &rows, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "selecting policy openframe_managed flags") + } + + for _, row := range rows { + policyMap[row.ID].OpenframeManaged = row.OpenframeManaged + } + + return nil +} + +// <<< OPENFRAME(managed-policies) + func loadLabelsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies []*fleet.Policy) error { const sql = ` SELECT @@ -541,6 +609,12 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) } // <<< OPENFRAME(host-assignments) + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := loadOpenframeManagedForPolicies(ctx, q, []*fleet.Policy{&policy}); err != nil { + return nil, err + } + // <<< OPENFRAME(managed-policies) + return &policy, nil } @@ -631,6 +705,12 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p return ctxerr.Wrap(ctx, err, "updating policy labels") } + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := setPolicyOpenframeManaged(ctx, db, p.ID, p.OpenframeManaged); err != nil { + return err + } + // <<< OPENFRAME(managed-policies) + // Reset attempt numbers for script/software policy automations if err := resetPolicyAutomationAttempts(ctx, db, p.ID); err != nil { return ctxerr.Wrap(ctx, err, "resetting policy automation attempts") @@ -1184,6 +1264,10 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op args = append(args, filterArgs...) } + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + query += openframeManagedExclusion(ctx, "p") + // <<< OPENFRAME(managed-policies) + // We must normalize the name for full Unicode support (Unicode equivalence). match := norm.NFC.String(opts.MatchQuery) query, args = searchLike(query, args, match, policySearchColumns...) @@ -1210,6 +1294,12 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op } // <<< OPENFRAME(host-assignments) + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := loadOpenframeManagedForPolicies(ctx, q, policies); err != nil { + return nil, err + } + // <<< OPENFRAME(managed-policies) + return policies, nil } @@ -1232,6 +1322,10 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea WHERE p.team_id IS NULL ` + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + query += openframeManagedExclusion(ctx, "p") + // <<< OPENFRAME(managed-policies) + args = append(args, teamID) // We must normalize the name for full Unicode support (Unicode equivalence). @@ -1260,6 +1354,12 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea } // <<< OPENFRAME(host-assignments) + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := loadOpenframeManagedForPolicies(ctx, q, policies); err != nil { + return nil, err + } + // <<< OPENFRAME(managed-policies) + return policies, nil } @@ -1292,6 +1392,11 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery args = append(args, *teamID) } + // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts + // either — openframe/docs/managed-policies.md + query += openframeManagedExclusion(ctx, "p") + // <<< OPENFRAME(managed-policies) + if teamID != nil { automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, *teamID) if err != nil { @@ -1325,6 +1430,10 @@ func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, m var args []interface{} query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` + // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts + // either — openframe/docs/managed-policies.md + query += openframeManagedExclusion(ctx, "p") + // <<< OPENFRAME(managed-policies) args = append(args, teamID) automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) @@ -1719,6 +1828,12 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI return nil, ctxerr.Wrap(ctx, err, "setting policy labels") } + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := setPolicyOpenframeManaged(ctx, db, policyID, args.OpenframeManaged); err != nil { + return nil, err + } + // <<< OPENFRAME(managed-policies) + return policyDB(ctx, db, policyID, &teamID) } @@ -1777,6 +1892,10 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op %s `, automationFilter) + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + query += openframeManagedExclusion(ctx, "p") + // <<< OPENFRAME(managed-policies) + args = append(args, teamID, teamID) if len(filterArgs) > 0 { args = append(args, filterArgs...) @@ -1808,6 +1927,12 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op } // <<< OPENFRAME(host-assignments) + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if err := loadOpenframeManagedForPolicies(ctx, ds.reader(ctx), policies); err != nil { + return nil, err + } + // <<< OPENFRAME(managed-policies) + return policies, nil } diff --git a/server/datastore/mysql/policies_openframe_managed_test.go b/server/datastore/mysql/policies_openframe_managed_test.go new file mode 100644 index 00000000000..7a814cf990d --- /dev/null +++ b/server/datastore/mysql/policies_openframe_managed_test.go @@ -0,0 +1,139 @@ +// OPENFRAME(managed-policies): verifies that `policies.openframe_managed` keeps a policy out of the list and +// count paths while leaving by-id reads intact — openframe/docs/managed-policies.md +// +// IMPORTANT: the datastore test harness loads server/datastore/mysql/schema.sql, dumped from the +// UPSTREAM tables/ migrations only, so it has no `openframe_managed` column. Every test here calls +// ds.MigrateOpenframe(ctx) first (see migrations_openframe_test.go). +// +// OpenFrame mode is switched on through the context, never through FLEET_OPENFRAME_MODE: +// CreateMySQLDS marks the test parallel, which makes t.Setenv panic, and a process-wide +// os.Setenv would leak the mode into every test running alongside. +package mysql + +import ( + "context" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestOpenframeManagedPolicies(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + require.NoError(t, ds.MigrateOpenframe(ctx)) + ctx = fleet.NewOpenframeModeContext(ctx, true) + + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-managed"}) + require.NoError(t, err) + + visible, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "openframe-managed-visible", + Query: "SELECT 1", + }) + require.NoError(t, err) + require.False(t, visible.OpenframeManaged) + + managed, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "openframe-managed-platform", + Query: "SELECT 1", + OpenframeManaged: true, + }) + require.NoError(t, err) + require.True(t, managed.OpenframeManaged, "create must round-trip the openframe_managed flag") + + policyIDs := func(policies []*fleet.Policy) []uint { + ids := make([]uint, 0, len(policies)) + for _, p := range policies { + ids = append(ids, p.ID) + } + return ids + } + + t.Run("excluded from listings and counts", func(t *testing.T) { + teamPolicies, _, err := ds.ListTeamPolicies(ctx, team.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") + require.NoError(t, err) + require.Equal(t, []uint{visible.ID}, policyIDs(teamPolicies)) + + merged, err := ds.ListMergedTeamPolicies(ctx, team.ID, fleet.ListOptions{}, "") + require.NoError(t, err) + require.Equal(t, []uint{visible.ID}, policyIDs(merged)) + + count, err := ds.CountPolicies(ctx, &team.ID, "", "") + require.NoError(t, err) + require.Equal(t, 1, count, "OpenFrame-managed policies must not inflate the paging count") + + mergedCount, err := ds.CountMergedTeamPolicies(ctx, team.ID, "", "") + require.NoError(t, err) + require.Equal(t, 1, mergedCount) + }) + + t.Run("still readable by id", func(t *testing.T) { + got, err := ds.Policy(ctx, managed.ID) + require.NoError(t, err) + require.True(t, got.OpenframeManaged) + }) + + t.Run("unhiding brings it back", func(t *testing.T) { + got, err := ds.Policy(ctx, managed.ID) + require.NoError(t, err) + + got.OpenframeManaged = false + require.NoError(t, ds.SavePolicy(ctx, got, false, false)) + + teamPolicies, _, err := ds.ListTeamPolicies(ctx, team.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") + require.NoError(t, err) + require.ElementsMatch(t, []uint{visible.ID, managed.ID}, policyIDs(teamPolicies)) + + got.OpenframeManaged = true + require.NoError(t, ds.SavePolicy(ctx, got, false, false)) + + teamPolicies, _, err = ds.ListTeamPolicies(ctx, team.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") + require.NoError(t, err) + require.Equal(t, []uint{visible.ID}, policyIDs(teamPolicies)) + }) + + t.Run("inert when openframe mode is off", func(t *testing.T) { + offCtx := fleet.NewOpenframeModeContext(ctx, false) + + teamPolicies, _, err := ds.ListTeamPolicies(offCtx, team.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") + require.NoError(t, err) + require.ElementsMatch(t, []uint{visible.ID, managed.ID}, policyIDs(teamPolicies), + "the exclusion must not apply outside OpenFrame mode, where the column may not exist") + + count, err := ds.CountPolicies(offCtx, &team.ID, "", "") + require.NoError(t, err) + require.Equal(t, 2, count) + }) +} + +func TestOpenframeManagedPoliciesGlobal(t *testing.T) { + ds := CreateMySQLDS(t) + ctx := context.Background() + + require.NoError(t, ds.MigrateOpenframe(ctx)) + ctx = fleet.NewOpenframeModeContext(ctx, true) + + visible, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "openframe-managed-global-visible", + Query: "SELECT 1", + }) + require.NoError(t, err) + + _, err = ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "openframe-managed-global-platform", + Query: "SELECT 1", + OpenframeManaged: true, + }) + require.NoError(t, err) + + policies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, policies, 1) + require.Equal(t, visible.ID, policies[0].ID) + + count, err := ds.CountPolicies(ctx, nil, "", "") + require.NoError(t, err) + require.Equal(t, 1, count) +} diff --git a/server/fleet/api_policies.go b/server/fleet/api_policies.go index 35e44189af3..90ba68a9930 100644 --- a/server/fleet/api_policies.go +++ b/server/fleet/api_policies.go @@ -16,6 +16,9 @@ type GlobalPolicyRequest struct { LabelsIncludeAll []string `json:"labels_include_all" premium:"true"` LabelsExcludeAny []string `json:"labels_exclude_any" premium:"true"` LabelsExcludeAll []string `json:"labels_exclude_all" premium:"true"` + // >>> OPENFRAME(managed-policies): keep this policy out of the list endpoints — openframe/docs/managed-policies.md + OpenframeManaged bool `json:"openframe_managed"` + // <<< OPENFRAME(managed-policies) } type GlobalPolicyResponse struct { diff --git a/server/fleet/openframe.go b/server/fleet/openframe.go index 9f38dd86a12..ccfd072cc76 100644 --- a/server/fleet/openframe.go +++ b/server/fleet/openframe.go @@ -18,6 +18,28 @@ func IsOpenframeMode() bool { return os.Getenv("FLEET_OPENFRAME_MODE") == "1" } +type openframeModeCtxKey struct{} + +// NewOpenframeModeContext returns a context that forces OpenFrame mode on or off for the calls made +// with it, overriding FLEET_OPENFRAME_MODE. Same rationale as NewOpenframeTeamContext: the MySQL +// test harness runs its tests in parallel (ProcessOptions calls t.Parallel), so t.Setenv is illegal +// there and mutating process env would leak OpenFrame mode into every concurrently running test. +func NewOpenframeModeContext(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, openframeModeCtxKey{}, enabled) +} + +// IsOpenframeModeCtx reports OpenFrame mode for this call: the context override when one is set, +// otherwise the FLEET_OPENFRAME_MODE process env var. Production paths never set the override, so +// they behave exactly like IsOpenframeMode. +func IsOpenframeModeCtx(ctx context.Context) bool { + if ctx != nil { + if enabled, ok := ctx.Value(openframeModeCtxKey{}).(bool); ok { + return enabled + } + } + return IsOpenframeMode() +} + // 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 diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 32e5706b012..8868dd252bc 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -75,6 +75,9 @@ type PolicyPayload struct { // // Only applies to team policies. ContinuousAutomationsEnabled bool + // >>> OPENFRAME(managed-policies): platform-owned policy, kept out of the policy list endpoints — openframe/docs/managed-policies.md + OpenframeManaged bool + // <<< OPENFRAME(managed-policies) } // NewTeamPolicyPayload holds data for team policy creation. @@ -125,6 +128,10 @@ type NewTeamPolicyPayload struct { // ContinuousAutomationsEnabled indicates whether software/script automations // should run on every failing policy result, not just on pass→fail transitions. ContinuousAutomationsEnabled bool + + // >>> OPENFRAME(managed-policies): platform-owned policy, kept out of the policy list endpoints — openframe/docs/managed-policies.md + OpenframeManaged bool `json:"openframe_managed"` + // <<< OPENFRAME(managed-policies) } var ( @@ -325,6 +332,10 @@ type ModifyPolicyPayload struct { // Type is the policy type. It is 'dynamic' by default and 'patch' for patch policies. Type string `json:"-"` + + // >>> OPENFRAME(managed-policies): platform-owned policy, kept out of the policy list endpoints — openframe/docs/managed-policies.md + OpenframeManaged *bool `json:"openframe_managed"` + // <<< OPENFRAME(managed-policies) } // Verify verifies the policy payload is valid. @@ -431,6 +442,14 @@ type PolicyData struct { // Only applies to team policies. ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"` + // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy is omitted from the policy list and count + // endpoints; it still runs on hosts and still reports results — openframe/docs/managed-policies.md + // + // Loaded separately (loadOpenframeManagedForPolicies), not through policyCols, because the column only + // exists in OpenFrame databases. + OpenframeManaged bool `json:"openframe_managed" db:"openframe_managed"` + // <<< OPENFRAME(managed-policies) + UpdateCreateTimestamps } diff --git a/server/service/global_policies.go b/server/service/global_policies.go index 0f4aa674eb4..6507c873872 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -40,6 +40,9 @@ func globalPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Se LabelsExcludeAny: req.LabelsExcludeAny, LabelsExcludeAll: req.LabelsExcludeAll, Type: fleet.PolicyTypeDynamic, + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + OpenframeManaged: req.OpenframeManaged, + // <<< OPENFRAME(managed-policies) }) if err != nil { return fleet.GlobalPolicyResponse{Err: err}, nil diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 3177d3cda56..7b9d06f738a 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -307,6 +307,9 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea ContinuousAutomationsEnabled: p.ContinuousAutomationsEnabled, Type: policyType, PatchSoftwareTitleID: p.PatchSoftwareTitleID, + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + OpenframeManaged: p.OpenframeManaged, + // <<< OPENFRAME(managed-policies) }, nil } @@ -684,6 +687,11 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.ContinuousAutomationsEnabled != nil { policy.ContinuousAutomationsEnabled = *p.ContinuousAutomationsEnabled } + // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + if p.OpenframeManaged != nil { + policy.OpenframeManaged = *p.OpenframeManaged + } + // <<< OPENFRAME(managed-policies) if removeStats { policy.FailingHostCount = 0 policy.PassingHostCount = 0 From f7831dfbf01681d82bbdb59c615ba843646d86b8 Mon Sep 17 00:00:00 2001 From: Kirill Gontar Date: Mon, 24 Aug 2026 12:33:38 +0200 Subject: [PATCH 2/5] done --- openframe/docs/managed-policies.md | 47 ++++----- server/datastore/mysql/policies.go | 95 +++---------------- .../mysql/policies_openframe_managed_test.go | 26 ----- server/datastore/mysql/schema.sql | 1 + server/fleet/openframe.go | 22 ----- 5 files changed, 39 insertions(+), 152 deletions(-) diff --git a/openframe/docs/managed-policies.md b/openframe/docs/managed-policies.md index 6a14bca1a1e..199609790c1 100644 --- a/openframe/docs/managed-policies.md +++ b/openframe/docs/managed-policies.md @@ -14,9 +14,10 @@ The column is named `openframe_managed` rather than something generic like `hidd that it can never collide semantically with a field upstream Fleet may add later — the same reasoning as `teams.openframe_tenant_uuid`. -This is fork-only behavior, gated on `FLEET_OPENFRAME_MODE` (see -[architecture-host-assignments.md](architecture-host-assignments.md)) because the column is created -by the OpenFrame migration pipeline, which non-OpenFrame databases never run. +This is fork-only behavior, but it is **not** gated on `FLEET_OPENFRAME_MODE`: that flag gates +behavior such as host assignments, while the column is created by the OpenFrame migration pipeline, +which `prepare db` runs unconditionally. The filter is therefore always on — and inert until +something actually sets the flag. ## What the flag does — and what it deliberately does not @@ -86,28 +87,28 @@ adds `policies.openframe_managed TINYINT(1) NOT NULL DEFAULT 0`. It ALTERs an up the OpenFrame pipeline — the same pattern as `teams.openframe_tenant_uuid`, and it is on the [semantic-conflict watchlist](upstream-sync-conflict-resolution.md). -### Why the column is not in `policyCols` +### Where it lives -The datastore test harness loads `server/datastore/mysql/schema.sql`, which is dumped from the -**upstream** `tables/` migrations only and therefore has no `openframe_managed` column (see -[migrations.md](migrations.md)). Referencing it unconditionally in the shared `policyCols` SELECT -would break every upstream policy test. So, mirroring `loadHostsForPolicies`: +`policies.openframe_managed` is in `policyCols` like any other column, and `schema.sql` carries it +too. That last part is the point worth remembering: `cmd/fleet/prepare.go` runs `MigrateOpenframe` +**unconditionally**, so every real deployment has the column no matter what `FLEET_OPENFRAME_MODE` +says — the mode flag gates behavior (host assignments), never schema. `schema.sql` is only used to +build test databases, so it must reflect that same reality; without the column there, the test +harness would diverge from production and every policy test touching `policyCols` would fail on +`Unknown column`. -- **read** — `loadOpenframeManagedForPolicies` fills `Policy.OpenframeManaged` in a separate - `SELECT id, openframe_managed` after each listing; -- **write** — `setPolicyOpenframeManaged` issues its own `UPDATE policies SET openframe_managed = ?` - inside the existing create/save transaction, leaving the upstream `INSERT`/`UPDATE` statements - byte-identical (they are a standing rebase cost); -- **filter** — `openframeManagedExclusion(ctx, "p")` returns `AND p.openframe_managed = 0` in - OpenFrame mode and the empty string otherwise. +Two small helpers, both under `OPENFRAME(managed-policies)` markers in +`server/datastore/mysql/policies.go`: -All three consult `IsOpenframeModeCtx(ctx)` rather than `IsOpenframeMode()`: it honors a context -override when one is set and falls back to `FLEET_OPENFRAME_MODE` otherwise. Production never sets -the override; the MySQL tests need it because `CreateMySQLDS` marks every test parallel, which makes -`t.Setenv` panic and a process-wide `os.Setenv` unsafe for the tests running alongside — the same -reasoning that produced `NewOpenframeTeamContext`. +- `openframeManagedExclusion(alias)` — returns `AND .openframe_managed = 0`, appended to the + five listing/count queries. Unconditional. +- `setPolicyOpenframeManaged` — writes the flag as its own `UPDATE` inside the existing create/save + transaction, so the upstream `INSERT`/`UPDATE` statements stay byte-identical (they are a standing + rebase cost). One extra statement, and only on policy create/modify. -All three live in `server/datastore/mysql/policies.go` under `OPENFRAME(managed-policies)` markers. +SEMANTIC-CONFLICT WATCHLIST: `make dump-test-schema` regenerates `schema.sql` from the upstream +`tables/` migrations only and would drop the `openframe_managed` line. Re-add it after any such +regeneration — see [upstream-sync-conflict-resolution.md](upstream-sync-conflict-resolution.md). ### Touched files @@ -115,11 +116,11 @@ All three live in `server/datastore/mysql/policies.go` under `OPENFRAME(managed- |------|--------| | `migrations/openframe/20260818000001_AddPoliciesOpenframeManagedColumn.go` | new column | | `server/fleet/policies.go` | `OpenframeManaged` on `PolicyData`, `PolicyPayload`, `NewTeamPolicyPayload`, `ModifyPolicyPayload` | -| `server/fleet/openframe.go` | `NewOpenframeModeContext` / `IsOpenframeModeCtx` — context override for the mode gate, so the parallel MySQL test harness can exercise it without `t.Setenv` | | `server/fleet/api_policies.go` | `OpenframeManaged` on `GlobalPolicyRequest` | | `server/service/global_policies.go` | maps the flag into the create payload | | `server/service/team_policies.go` | maps the flag on team create and on modify | -| `server/datastore/mysql/policies.go` | helpers + exclusion in `listPoliciesDB`, `getInheritedPoliciesForTeam`, `ListMergedTeamPolicies`, `CountPolicies`, `CountMergedTeamPolicies` | +| `server/datastore/mysql/schema.sql` | the column, so test databases match production | +| `server/datastore/mysql/policies.go` | `policyCols` + two helpers + exclusion in `listPoliciesDB`, `getInheritedPoliciesForTeam`, `ListMergedTeamPolicies`, `CountPolicies`, `CountMergedTeamPolicies` | | `server/datastore/mysql/policies_openframe_managed_test.go` | MySQL coverage for all of the above | ## Host assignment interaction (open item) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index c3005fb578c..f4fda700871 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -40,7 +40,8 @@ const policyCols = ` p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled, p.software_installer_id, p.script_id, p.vpp_apps_teams_id, p.conditional_access_enabled, p.type, - p.patch_software_title_id, p.continuous_automations_enabled + p.patch_software_title_id, p.continuous_automations_enabled, + p.openframe_managed ` const ( @@ -410,68 +411,24 @@ func loadHostsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies // <<< OPENFRAME(host-assignments) // >>> OPENFRAME(managed-policies): platform-owned policies kept out of the policy list endpoints — openframe/docs/managed-policies.md -// -// Everything here is gated on IsOpenframeMode() because `policies.openframe_managed` is created by the -// OpenFrame migration pipeline, which the datastore test harness (schema.sql, dumped from upstream -// migrations only) does not run — see openframe/docs/migrations.md. -// openframeManagedExclusion returns the WHERE fragment that keeps OpenFrame-managed policies out of a listing. -// Empty outside OpenFrame mode, where the column does not necessarily exist. -func openframeManagedExclusion(ctx context.Context, alias string) string { - if !fleet.IsOpenframeModeCtx(ctx) { - return "" - } +// openframeManagedExclusion returns the WHERE fragment that keeps OpenFrame-managed policies out of +// a listing. Unconditional: `prepare db` always runs MigrateOpenframe, so the column exists in every +// deployment — FLEET_OPENFRAME_MODE gates behavior, not schema. +func openframeManagedExclusion(alias string) string { return fmt.Sprintf(" AND %s.openframe_managed = 0", alias) } -// setPolicyOpenframeManaged writes the openframe_managed flag as its own statement so the upstream INSERT/UPDATE +// setPolicyOpenframeManaged writes the flag as its own statement so the upstream INSERT/UPDATE // statements stay byte-identical (they are a standing rebase cost — see // openframe/docs/upstream-sync-conflict-resolution.md). -func setPolicyOpenframeManaged(ctx context.Context, db sqlx.ExtContext, policyID uint, internal bool) error { - if !fleet.IsOpenframeModeCtx(ctx) { - return nil - } - if _, err := db.ExecContext(ctx, `UPDATE policies SET openframe_managed = ? WHERE id = ?`, internal, policyID); err != nil { +func setPolicyOpenframeManaged(ctx context.Context, db sqlx.ExtContext, policyID uint, managed bool) error { + if _, err := db.ExecContext(ctx, `UPDATE policies SET openframe_managed = ? WHERE id = ?`, managed, policyID); err != nil { return ctxerr.Wrap(ctx, err, "setting policy openframe_managed flag") } return nil } -// loadOpenframeManagedForPolicies fills Policy.OpenframeManaged after a listing, mirroring loadHostsForPolicies. Kept -// out of policyCols for the same reason: the column is absent in non-OpenFrame databases. -func loadOpenframeManagedForPolicies(ctx context.Context, db sqlx.QueryerContext, policies []*fleet.Policy) error { - if !fleet.IsOpenframeModeCtx(ctx) || len(policies) == 0 { - return nil - } - - policyIDs := make([]uint, 0, len(policies)) - policyMap := make(map[uint]*fleet.Policy, len(policies)) - for _, policy := range policies { - policyIDs = append(policyIDs, policy.ID) - policyMap[policy.ID] = policy - } - - stmt, args, err := sqlx.In(`SELECT id, openframe_managed FROM policies WHERE id IN (?)`, policyIDs) - if err != nil { - return ctxerr.Wrap(ctx, err, "building query to load policy openframe_managed flags") - } - - rows := []struct { - ID uint `db:"id"` - OpenframeManaged bool `db:"openframe_managed"` - }{} - - if err := sqlx.SelectContext(ctx, db, &rows, stmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "selecting policy openframe_managed flags") - } - - for _, row := range rows { - policyMap[row.ID].OpenframeManaged = row.OpenframeManaged - } - - return nil -} - // <<< OPENFRAME(managed-policies) func loadLabelsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies []*fleet.Policy) error { @@ -609,12 +566,6 @@ func policyDB(ctx context.Context, q sqlx.QueryerContext, id uint, teamID *uint) } // <<< OPENFRAME(host-assignments) - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := loadOpenframeManagedForPolicies(ctx, q, []*fleet.Policy{&policy}); err != nil { - return nil, err - } - // <<< OPENFRAME(managed-policies) - return &policy, nil } @@ -1265,7 +1216,7 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op } // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion(ctx, "p") + query += openframeManagedExclusion("p") // <<< OPENFRAME(managed-policies) // We must normalize the name for full Unicode support (Unicode equivalence). @@ -1294,12 +1245,6 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op } // <<< OPENFRAME(host-assignments) - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := loadOpenframeManagedForPolicies(ctx, q, policies); err != nil { - return nil, err - } - // <<< OPENFRAME(managed-policies) - return policies, nil } @@ -1323,7 +1268,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea ` // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion(ctx, "p") + query += openframeManagedExclusion("p") // <<< OPENFRAME(managed-policies) args = append(args, teamID) @@ -1354,12 +1299,6 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea } // <<< OPENFRAME(host-assignments) - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := loadOpenframeManagedForPolicies(ctx, q, policies); err != nil { - return nil, err - } - // <<< OPENFRAME(managed-policies) - return policies, nil } @@ -1394,7 +1333,7 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts // either — openframe/docs/managed-policies.md - query += openframeManagedExclusion(ctx, "p") + query += openframeManagedExclusion("p") // <<< OPENFRAME(managed-policies) if teamID != nil { @@ -1432,7 +1371,7 @@ func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, m query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts // either — openframe/docs/managed-policies.md - query += openframeManagedExclusion(ctx, "p") + query += openframeManagedExclusion("p") // <<< OPENFRAME(managed-policies) args = append(args, teamID) @@ -1893,7 +1832,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op `, automationFilter) // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion(ctx, "p") + query += openframeManagedExclusion("p") // <<< OPENFRAME(managed-policies) args = append(args, teamID, teamID) @@ -1927,12 +1866,6 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op } // <<< OPENFRAME(host-assignments) - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := loadOpenframeManagedForPolicies(ctx, ds.reader(ctx), policies); err != nil { - return nil, err - } - // <<< OPENFRAME(managed-policies) - return policies, nil } diff --git a/server/datastore/mysql/policies_openframe_managed_test.go b/server/datastore/mysql/policies_openframe_managed_test.go index 7a814cf990d..79bb888d3e8 100644 --- a/server/datastore/mysql/policies_openframe_managed_test.go +++ b/server/datastore/mysql/policies_openframe_managed_test.go @@ -1,13 +1,5 @@ // OPENFRAME(managed-policies): verifies that `policies.openframe_managed` keeps a policy out of the list and // count paths while leaving by-id reads intact — openframe/docs/managed-policies.md -// -// IMPORTANT: the datastore test harness loads server/datastore/mysql/schema.sql, dumped from the -// UPSTREAM tables/ migrations only, so it has no `openframe_managed` column. Every test here calls -// ds.MigrateOpenframe(ctx) first (see migrations_openframe_test.go). -// -// OpenFrame mode is switched on through the context, never through FLEET_OPENFRAME_MODE: -// CreateMySQLDS marks the test parallel, which makes t.Setenv panic, and a process-wide -// os.Setenv would leak the mode into every test running alongside. package mysql import ( @@ -22,9 +14,6 @@ func TestOpenframeManagedPolicies(t *testing.T) { ds := CreateMySQLDS(t) ctx := context.Background() - require.NoError(t, ds.MigrateOpenframe(ctx)) - ctx = fleet.NewOpenframeModeContext(ctx, true) - team, err := ds.NewTeam(ctx, &fleet.Team{Name: "openframe-managed"}) require.NoError(t, err) @@ -94,27 +83,12 @@ func TestOpenframeManagedPolicies(t *testing.T) { require.Equal(t, []uint{visible.ID}, policyIDs(teamPolicies)) }) - t.Run("inert when openframe mode is off", func(t *testing.T) { - offCtx := fleet.NewOpenframeModeContext(ctx, false) - - teamPolicies, _, err := ds.ListTeamPolicies(offCtx, team.ID, fleet.ListOptions{}, fleet.ListOptions{}, "") - require.NoError(t, err) - require.ElementsMatch(t, []uint{visible.ID, managed.ID}, policyIDs(teamPolicies), - "the exclusion must not apply outside OpenFrame mode, where the column may not exist") - - count, err := ds.CountPolicies(offCtx, &team.ID, "", "") - require.NoError(t, err) - require.Equal(t, 2, count) - }) } func TestOpenframeManagedPoliciesGlobal(t *testing.T) { ds := CreateMySQLDS(t) ctx := context.Background() - require.NoError(t, ds.MigrateOpenframe(ctx)) - ctx = fleet.NewOpenframeModeContext(ctx, true) - visible, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ Name: "openframe-managed-global-visible", Query: "SELECT 1", diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index e2cf59e820a..6f9932171a7 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2466,6 +2466,7 @@ CREATE TABLE `policies` ( `patch_software_title_id` int unsigned DEFAULT NULL, `needs_full_membership_cleanup` tinyint(1) NOT NULL DEFAULT '0', `continuous_automations_enabled` tinyint(1) NOT NULL DEFAULT '0', + `openframe_managed` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), UNIQUE KEY `idx_team_id_patch_software_title_id` (`team_id`,`patch_software_title_id`), diff --git a/server/fleet/openframe.go b/server/fleet/openframe.go index ccfd072cc76..9f38dd86a12 100644 --- a/server/fleet/openframe.go +++ b/server/fleet/openframe.go @@ -18,28 +18,6 @@ func IsOpenframeMode() bool { return os.Getenv("FLEET_OPENFRAME_MODE") == "1" } -type openframeModeCtxKey struct{} - -// NewOpenframeModeContext returns a context that forces OpenFrame mode on or off for the calls made -// with it, overriding FLEET_OPENFRAME_MODE. Same rationale as NewOpenframeTeamContext: the MySQL -// test harness runs its tests in parallel (ProcessOptions calls t.Parallel), so t.Setenv is illegal -// there and mutating process env would leak OpenFrame mode into every concurrently running test. -func NewOpenframeModeContext(ctx context.Context, enabled bool) context.Context { - return context.WithValue(ctx, openframeModeCtxKey{}, enabled) -} - -// IsOpenframeModeCtx reports OpenFrame mode for this call: the context override when one is set, -// otherwise the FLEET_OPENFRAME_MODE process env var. Production paths never set the override, so -// they behave exactly like IsOpenframeMode. -func IsOpenframeModeCtx(ctx context.Context) bool { - if ctx != nil { - if enabled, ok := ctx.Value(openframeModeCtxKey{}).(bool); ok { - return enabled - } - } - return IsOpenframeMode() -} - // 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 From 9f3ab07d433294dc3edc9ccb36062f032317ccd5 Mon Sep 17 00:00:00 2001 From: Kirill Gontar Date: Mon, 24 Aug 2026 12:44:31 +0200 Subject: [PATCH 3/5] done --- openframe/docs/managed-policies.md | 15 ++++++------ server/datastore/mysql/policies.go | 38 ++++++------------------------ 2 files changed, 14 insertions(+), 39 deletions(-) diff --git a/openframe/docs/managed-policies.md b/openframe/docs/managed-policies.md index 199609790c1..c2cbf636bdd 100644 --- a/openframe/docs/managed-policies.md +++ b/openframe/docs/managed-policies.md @@ -97,14 +97,13 @@ build test databases, so it must reflect that same reality; without the column t harness would diverge from production and every policy test touching `policyCols` would fail on `Unknown column`. -Two small helpers, both under `OPENFRAME(managed-policies)` markers in -`server/datastore/mysql/policies.go`: +The flag is written by the same `INSERT`/`UPDATE` statements as every other policy field +(`newGlobalPolicy`, `newTeamPolicy`, `savePolicy`) — no separate write. `ApplyPolicySpecs` does not +name the column, so a spec apply leaves it untouched. -- `openframeManagedExclusion(alias)` — returns `AND .openframe_managed = 0`, appended to the - five listing/count queries. Unconditional. -- `setPolicyOpenframeManaged` — writes the flag as its own `UPDATE` inside the existing create/save - transaction, so the upstream `INSERT`/`UPDATE` statements stay byte-identical (they are a standing - rebase cost). One extra statement, and only on policy create/modify. +The only helper is `openframeManagedExclusion(alias)` in `server/datastore/mysql/policies.go`, under +`OPENFRAME(managed-policies)` markers: it returns `AND .openframe_managed = 0` and is appended +to the five listing/count queries. SEMANTIC-CONFLICT WATCHLIST: `make dump-test-schema` regenerates `schema.sql` from the upstream `tables/` migrations only and would drop the `openframe_managed` line. Re-add it after any such @@ -120,7 +119,7 @@ regeneration — see [upstream-sync-conflict-resolution.md](upstream-sync-confli | `server/service/global_policies.go` | maps the flag into the create payload | | `server/service/team_policies.go` | maps the flag on team create and on modify | | `server/datastore/mysql/schema.sql` | the column, so test databases match production | -| `server/datastore/mysql/policies.go` | `policyCols` + two helpers + exclusion in `listPoliciesDB`, `getInheritedPoliciesForTeam`, `ListMergedTeamPolicies`, `CountPolicies`, `CountMergedTeamPolicies` | +| `server/datastore/mysql/policies.go` | `policyCols`, the two INSERTs + the UPDATE, and the exclusion in `listPoliciesDB`, `getInheritedPoliciesForTeam`, `ListMergedTeamPolicies`, `CountPolicies`, `CountMergedTeamPolicies` | | `server/datastore/mysql/policies_openframe_managed_test.go` | MySQL coverage for all of the above | ## Host assignment interaction (open item) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index f4fda700871..6dc3a91465f 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -123,10 +123,10 @@ func newGlobalPolicy(ctx context.Context, db sqlx.ExtContext, authorID *uint, ar nameUnicode := norm.NFC.String(args.Name) res, err := db.ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, resolution, author_id, platforms, critical, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, resolution, author_id, platforms, critical, openframe_managed, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), - nameUnicode, args.Query, args.Description, args.Resolution, authorID, args.Platform, args.Critical, + nameUnicode, args.Query, args.Description, args.Resolution, authorID, args.Platform, args.Critical, args.OpenframeManaged, ) switch { case err == nil: @@ -155,9 +155,6 @@ func newGlobalPolicy(ctx context.Context, db sqlx.ExtContext, authorID *uint, ar if err := updatePolicyLabelsTx(ctx, db, dummyPolicy); err != nil { return nil, ctxerr.Wrap(ctx, err, "setting policy labels") } - if err := setPolicyOpenframeManaged(ctx, db, policyID, args.OpenframeManaged); err != nil { - return nil, err - } return policyDB(ctx, db, policyID, nil) } @@ -419,16 +416,6 @@ func openframeManagedExclusion(alias string) string { return fmt.Sprintf(" AND %s.openframe_managed = 0", alias) } -// setPolicyOpenframeManaged writes the flag as its own statement so the upstream INSERT/UPDATE -// statements stay byte-identical (they are a standing rebase cost — see -// openframe/docs/upstream-sync-conflict-resolution.md). -func setPolicyOpenframeManaged(ctx context.Context, db sqlx.ExtContext, policyID uint, managed bool) error { - if _, err := db.ExecContext(ctx, `UPDATE policies SET openframe_managed = ? WHERE id = ?`, managed, policyID); err != nil { - return ctxerr.Wrap(ctx, err, "setting policy openframe_managed flag") - } - return nil -} - // <<< OPENFRAME(managed-policies) func loadLabelsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies []*fleet.Policy) error { @@ -635,11 +622,12 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, script_id = ?, vpp_apps_teams_id = ?, conditional_access_enabled = ?, continuous_automations_enabled = ?, + openframe_managed = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := db.ExecContext( - ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ContinuousAutomationsEnabled, p.ID, + ctx, updateStmt, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.VPPAppsTeamsID, p.ConditionalAccessEnabled, p.ContinuousAutomationsEnabled, p.OpenframeManaged, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -656,12 +644,6 @@ func savePolicy(ctx context.Context, db sqlx.ExtContext, logger *slog.Logger, p return ctxerr.Wrap(ctx, err, "updating policy labels") } - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := setPolicyOpenframeManaged(ctx, db, p.ID, p.OpenframeManaged); err != nil { - return err - } - // <<< OPENFRAME(managed-policies) - // Reset attempt numbers for script/software policy automations if err := resetPolicyAutomationAttempts(ctx, db, p.ID); err != nil { return ctxerr.Wrap(ctx, err, "resetting policy automation attempts") @@ -1725,14 +1707,14 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI `INSERT INTO policies ( name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, - script_id, vpp_apps_teams_id, conditional_access_enabled, checksum, + script_id, vpp_apps_teams_id, conditional_access_enabled, openframe_managed, checksum, type, patch_software_title_id, continuous_automations_enabled - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, args.VPPAppsTeamsID, - args.ConditionalAccessEnabled, args.Type, args.PatchSoftwareTitleID, args.ContinuousAutomationsEnabled, + args.ConditionalAccessEnabled, args.OpenframeManaged, args.Type, args.PatchSoftwareTitleID, args.ContinuousAutomationsEnabled, ) switch { case err == nil: @@ -1767,12 +1749,6 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI return nil, ctxerr.Wrap(ctx, err, "setting policy labels") } - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - if err := setPolicyOpenframeManaged(ctx, db, policyID, args.OpenframeManaged); err != nil { - return nil, err - } - // <<< OPENFRAME(managed-policies) - return policyDB(ctx, db, policyID, &teamID) } From 6799c5a66e23309fa38ee935cc8f34aa943539b4 Mon Sep 17 00:00:00 2001 From: Kirill Gontar Date: Mon, 24 Aug 2026 12:56:57 +0200 Subject: [PATCH 4/5] done --- server/datastore/mysql/policies.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 6dc3a91465f..f7a53010247 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -409,12 +409,11 @@ func loadHostsForPolicies(ctx context.Context, db sqlx.QueryerContext, policies // >>> OPENFRAME(managed-policies): platform-owned policies kept out of the policy list endpoints — openframe/docs/managed-policies.md -// openframeManagedExclusion returns the WHERE fragment that keeps OpenFrame-managed policies out of -// a listing. Unconditional: `prepare db` always runs MigrateOpenframe, so the column exists in every -// deployment — FLEET_OPENFRAME_MODE gates behavior, not schema. -func openframeManagedExclusion(alias string) string { - return fmt.Sprintf(" AND %s.openframe_managed = 0", alias) -} +// openframeManagedExclusion is the WHERE fragment that keeps OpenFrame-managed policies out of a +// listing; every listing/count query below aliases the table as `p`. Unconditional: `prepare db` +// always runs MigrateOpenframe, so the column exists in every deployment — FLEET_OPENFRAME_MODE +// gates behavior, not schema. +const openframeManagedExclusion = ` AND p.openframe_managed = 0` // <<< OPENFRAME(managed-policies) @@ -1198,7 +1197,7 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op } // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion("p") + query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) // We must normalize the name for full Unicode support (Unicode equivalence). @@ -1250,7 +1249,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea ` // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion("p") + query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) args = append(args, teamID) @@ -1315,7 +1314,7 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts // either — openframe/docs/managed-policies.md - query += openframeManagedExclusion("p") + query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) if teamID != nil { @@ -1353,7 +1352,7 @@ func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, m query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts // either — openframe/docs/managed-policies.md - query += openframeManagedExclusion("p") + query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) args = append(args, teamID) @@ -1808,7 +1807,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op `, automationFilter) // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md - query += openframeManagedExclusion("p") + query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) args = append(args, teamID, teamID) From fbd595bc378d9b67ba9f238f54647f6b8679234b Mon Sep 17 00:00:00 2001 From: Kirill Gontar Date: Mon, 24 Aug 2026 13:05:05 +0200 Subject: [PATCH 5/5] done --- server/datastore/mysql/policies.go | 10 +++++----- server/fleet/policies.go | 4 ++-- server/service/global_policies.go | 2 +- server/service/team_policies.go | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index f7a53010247..416e379f6d3 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -1196,7 +1196,7 @@ func listPoliciesDB(ctx context.Context, q sqlx.QueryerContext, teamID *uint, op args = append(args, filterArgs...) } - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): drop platform-owned policies from this listing — openframe/docs/managed-policies.md query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) @@ -1248,7 +1248,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea WHERE p.team_id IS NULL ` - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): drop platform-owned policies from this listing — openframe/docs/managed-policies.md query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) @@ -1312,7 +1312,7 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery args = append(args, *teamID) } - // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts + // >>> OPENFRAME(managed-policies): keep platform-owned policies out of the paging/badge counts too — openframe/docs/managed-policies.md // either — openframe/docs/managed-policies.md query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) @@ -1350,7 +1350,7 @@ func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, m var args []interface{} query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` - // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy must not show up in the paging/badge counts + // >>> OPENFRAME(managed-policies): keep platform-owned policies out of the paging/badge counts too — openframe/docs/managed-policies.md // either — openframe/docs/managed-policies.md query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) @@ -1806,7 +1806,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op %s `, automationFilter) - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): drop platform-owned policies from this listing — openframe/docs/managed-policies.md query += openframeManagedExclusion // <<< OPENFRAME(managed-policies) diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 8868dd252bc..01a8a43cc24 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -442,8 +442,8 @@ type PolicyData struct { // Only applies to team policies. ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"` - // >>> OPENFRAME(managed-policies): an OpenFrame-managed policy is omitted from the policy list and count - // endpoints; it still runs on hosts and still reports results — openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): omitted from the policy list and count endpoints; still runs on + // hosts and still reports results — openframe/docs/managed-policies.md // // Loaded separately (loadOpenframeManagedForPolicies), not through policyCols, because the column only // exists in OpenFrame databases. diff --git a/server/service/global_policies.go b/server/service/global_policies.go index 6507c873872..9112cb42b6f 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -40,7 +40,7 @@ func globalPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Se LabelsExcludeAny: req.LabelsExcludeAny, LabelsExcludeAll: req.LabelsExcludeAll, Type: fleet.PolicyTypeDynamic, - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): let the platform mark the policy it owns — openframe/docs/managed-policies.md OpenframeManaged: req.OpenframeManaged, // <<< OPENFRAME(managed-policies) }) diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 7b9d06f738a..d5da90c1e14 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -307,7 +307,7 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea ContinuousAutomationsEnabled: p.ContinuousAutomationsEnabled, Type: policyType, PatchSoftwareTitleID: p.PatchSoftwareTitleID, - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): let the platform mark the policy it owns — openframe/docs/managed-policies.md OpenframeManaged: p.OpenframeManaged, // <<< OPENFRAME(managed-policies) }, nil @@ -687,7 +687,7 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.ContinuousAutomationsEnabled != nil { policy.ContinuousAutomationsEnabled = *p.ContinuousAutomationsEnabled } - // >>> OPENFRAME(managed-policies): openframe/docs/managed-policies.md + // >>> OPENFRAME(managed-policies): let the platform mark the policy it owns — openframe/docs/managed-policies.md if p.OpenframeManaged != nil { policy.OpenframeManaged = *p.OpenframeManaged }