diff --git a/openframe/docs/fork-file-manifest.md b/openframe/docs/fork-file-manifest.md index 1789b3d2612..b11033ba54e 100644 --- a/openframe/docs/fork-file-manifest.md +++ b/openframe/docs/fork-file-manifest.md @@ -130,6 +130,7 @@ and the heaviest standing rebase cost. | Area | Files | |------|-------| | Host assignments | `server/fleet/{policies,queries,hosts,datastore,service}.go`, `server/datastore/mysql/{policies,queries,hosts}.go`, `server/service/{global_policies,queries,handler,labels_util}.go`, `server/mock/{datastore,datastore_mock}.go`, `server/mock/service/service_mock.go`, `server/datastore/mysql/mysql.go`, `cmd/fleet/prepare.go` | +| Managed queries / policies | flag: `server/fleet/{queries,policies}.go`, `server/service/{queries,global_policies,team_policies}.go`, `server/datastore/mysql/{queries,policies}.go`, `schema.sql`; queries listing opt-in (`include_openframe_managed`): `server/fleet/{app,api_queries,service}.go`, `server/service/{global_schedule,team_schedule,queries_test}.go`, `server/mock/service/service_mock.go`; tests: `server/datastore/mysql/{queries,policies}_openframe_managed_test.go` — see [managed-queries.md](managed-queries.md), [managed-policies.md](managed-policies.md) | | osquery host id | `server/fleet/hosts.go` | | Query-results TTL cleanup | `server/config/config.go`, `server/fleet/{cron_schedules,datastore}.go`, `server/datastore/mysql/query_results.go`, `cmd/fleet/{cron,serve}.go` | | Redis key prefix | `server/datastore/redis/redis.go`, `server/config/config.go`, `cmd/fleet/serve.go` | @@ -230,7 +231,7 @@ server/service/openframe/openframe_authorization_manager.go server/service/openframe/openframe_token_refresher.go ``` -### Modified (46) +### Modified (52) ``` .github/pull_request_template.md @@ -264,6 +265,8 @@ server/datastore/mysql/policies.go server/datastore/mysql/queries.go server/datastore/mysql/query_results.go server/datastore/redis/redis.go +server/fleet/api_queries.go +server/fleet/app.go server/fleet/cron_schedules.go server/fleet/datastore.go server/fleet/hosts.go @@ -275,10 +278,12 @@ server/mock/datastore_mock.go server/mock/service/service_mock.go server/service/base_client.go server/service/global_policies.go +server/service/global_schedule.go server/service/handler.go server/service/labels_util.go server/service/orbit_client.go server/service/osquery_utils/queries.go server/service/queries.go +server/service/team_schedule.go server/vulnerabilities/nvd/cpe.go ``` diff --git a/openframe/docs/managed-queries.md b/openframe/docs/managed-queries.md index af81a7271b8..ef445f53b4a 100644 --- a/openframe/docs/managed-queries.md +++ b/openframe/docs/managed-queries.md @@ -56,7 +56,24 @@ POST /api/latest/fleet/queries `PATCH /api/latest/fleet/queries/{id}` takes `"openframe_managed": true|false`; omitting the field leaves the flag as-is. Every query payload returns `"openframe_managed"`. -There is no `include_managed` parameter, for the same fail-closed reason as on the policies side. +### Listing + +`GET /api/latest/fleet/queries` excludes managed queries by default. Pass +`?include_openframe_managed=1` to include them. The flag is **opt-in and defaults to excluded**, so +the decluttered operator-facing listing cannot be changed by an accidental or user-supplied value; +the OpenFrame platform's own services (query sync, host auto-assign) set it to enumerate what they +own. + +This stays **decluttering, not access control** — the flag decides whether the listing is tidy for a +normal operator, it is not a security boundary. Tenant isolation is enforced separately, per-tenant. +(An earlier draft of this doc planned *no* such parameter and had the platform read managed queries +by id, on a "fail-closed" argument. That argument was about the declutter being unbypassable by a +user-supplied flag, **not** about isolation — so an opt-in listing flag is an equivalent trade: the +default still excludes, and every by-id read and write path still ignores the flag. It only spares +the sync/reconcile path from id-guessing when it needs to diff the full managed set.) + +This differs from the policies side, which has no equivalent include flag — queries grew one because +the query synchronizer must list what it owns to reconcile it. ## Implementation @@ -102,8 +119,14 @@ written by the same `INSERT`/`UPDATE` as every other query field (`NewQuery`, `S | `server/datastore/mysql/schema.sql` | the column, so test databases match production | | `server/fleet/queries.go` | `OpenframeManaged` on `Query` and on `QueryPayload` | | `server/service/queries.go` | maps the flag on query create and on modify | -| `server/datastore/mysql/queries.go` | the `INSERT`, the `UPDATE`, both SELECT lists, and the listing exclusion | -| `server/datastore/mysql/queries_openframe_managed_test.go` | MySQL coverage | +| `server/datastore/mysql/queries.go` | the `INSERT`, the `UPDATE`, both SELECT lists, and the listing exclusion (skipped when `IncludeOpenframeManaged`) | +| `server/fleet/app.go` | `IncludeOpenframeManaged` on `ListQueryOptions` | +| `server/fleet/api_queries.go` | `IncludeOpenframeManaged` on `ListQueriesRequest` (query param `include_openframe_managed`) | +| `server/fleet/service.go` | trailing `includeOpenframeManaged bool` on the `ListQueries` interface | +| `server/service/queries.go` | `listQueriesEndpoint` forwards the request flag into `ListQueryOptions` | +| `server/mock/service/service_mock.go` | regenerated `ListQueries` mock for the new parameter | +| `server/service/global_schedule.go`, `server/service/team_schedule.go`, `server/service/queries_test.go` | internal `ListQueries` callers pass `false` (keep the default exclude) | +| `server/datastore/mysql/queries_openframe_managed_test.go` | MySQL coverage, incl. the `include_openframe_managed` opt-in | ## Host assignment interaction (open item) diff --git a/server/datastore/mysql/queries.go b/server/datastore/mysql/queries.go index 3358fe059ee..9946d067529 100644 --- a/server/datastore/mysql/queries.go +++ b/server/datastore/mysql/queries.go @@ -928,8 +928,11 @@ func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions args := []interface{}{false, fleet.AggregatedStatsTypeScheduledQuery} whereClauses := "WHERE saved = true" // >>> OPENFRAME(managed-queries): drop platform-owned queries from this listing and from the - // count derived from it — openframe/docs/managed-queries.md - whereClauses += openframeManagedQueryExclusion + // count derived from it — openframe/docs/managed-queries.md. Opt-in callers (query sync, + // host auto-assign) pass IncludeOpenframeManaged to enumerate what OpenFrame owns. + if !opt.IncludeOpenframeManaged { + whereClauses += openframeManagedQueryExclusion + } // <<< OPENFRAME(managed-queries) switch { diff --git a/server/datastore/mysql/queries_openframe_managed_test.go b/server/datastore/mysql/queries_openframe_managed_test.go index a70eb37d86b..93f0b524892 100644 --- a/server/datastore/mysql/queries_openframe_managed_test.go +++ b/server/datastore/mysql/queries_openframe_managed_test.go @@ -48,6 +48,14 @@ func TestOpenframeManagedQueries(t *testing.T) { require.Equal(t, 1, total, "managed queries must not inflate the count") }) + t.Run("included when opted in", func(t *testing.T) { + opts := fleet.ListQueryOptions{IncludeOpenframeManaged: true} + queries, total, _, _, err := ds.ListQueries(ctx, opts) + require.NoError(t, err) + require.ElementsMatch(t, []string{visible.Name, managed.Name}, queryNames(queries)) + require.Equal(t, 2, total, "opt-in must surface managed queries in the count too") + }) + t.Run("still readable by id", func(t *testing.T) { got, err := ds.Query(ctx, managed.ID) require.NoError(t, err) diff --git a/server/fleet/api_queries.go b/server/fleet/api_queries.go index a6fe12408f4..cb6901566bc 100644 --- a/server/fleet/api_queries.go +++ b/server/fleet/api_queries.go @@ -31,6 +31,12 @@ type ListQueriesRequest struct { MergeInherited bool `query:"merge_inherited,optional"` // only return queries targeted to run on this platform Platform string `query:"platform,optional"` + // >>> OPENFRAME(managed-queries): opt-in to list platform-owned queries — openframe/docs/managed-queries.md + // IncludeOpenframeManaged, when true, keeps OpenFrame-managed queries in the listing instead of + // hiding them. Defaults false so the operator-facing UI stays decluttered; OpenFrame's own + // services (query sync, host auto-assign) opt in to enumerate what they own. + IncludeOpenframeManaged bool `query:"include_openframe_managed,optional"` + // <<< OPENFRAME(managed-queries) } type ListQueriesResponse struct { diff --git a/server/fleet/app.go b/server/fleet/app.go index 4e8358b3fac..44374370b48 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1584,6 +1584,11 @@ type ListQueryOptions struct { // Return queries that are scheduled to run on this platform. One of "macos", // "windows", or "linux" Platform *string + // >>> OPENFRAME(managed-queries): opt-in to include platform-owned queries — openframe/docs/managed-queries.md + // IncludeOpenframeManaged, when true, disables the openframe_managed exclusion so the listing + // returns platform-owned queries too. Default false keeps them hidden. + IncludeOpenframeManaged bool + // <<< OPENFRAME(managed-queries) } // ListHostReportsOptions defines options for listing reports (queries) associated with a host. diff --git a/server/fleet/service.go b/server/fleet/service.go index efa1f12d185..27d3c97e513 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -341,7 +341,8 @@ type Service interface { // and only non-scheduled queries will be returned if `*scheduled == false`. // If mergeInherited is true and a teamID is provided, then queries from the global team will be // included in the results. The inherited count is only meaningful when mergeInherited is true. - ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string) ([]*Query, int, int, *PaginationMetadata, error) + // OPENFRAME(managed-queries): trailing includeOpenframeManaged param opts in to platform-owned queries — openframe/docs/managed-queries.md + ListQueries(ctx context.Context, opt ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string, includeOpenframeManaged bool) ([]*Query, int, int, *PaginationMetadata, error) GetQuery(ctx context.Context, id uint) (*Query, error) // GetQueryReportResults returns all the stored results of a query for hosts the requestor has access to. // Returns a boolean indicating whether the report is clipped. diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 619661e76e6..646b1486fb9 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -184,7 +184,7 @@ type GetQuerySpecsFunc func(ctx context.Context, teamID *uint) ([]*fleet.QuerySp type GetQuerySpecFunc func(ctx context.Context, teamID *uint, name string) (*fleet.QuerySpec, error) -type ListQueriesFunc func(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) +type ListQueriesFunc func(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string, includeOpenframeManaged bool) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) type GetQueryFunc func(ctx context.Context, id uint) (*fleet.Query, error) @@ -2938,11 +2938,11 @@ func (s *Service) GetQuerySpec(ctx context.Context, teamID *uint, name string) ( return s.GetQuerySpecFunc(ctx, teamID, name) } -func (s *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { +func (s *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, platform *string, includeOpenframeManaged bool) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListQueriesFuncInvoked = true s.mu.Unlock() - return s.ListQueriesFunc(ctx, opt, teamID, scheduled, mergeInherited, platform) + return s.ListQueriesFunc(ctx, opt, teamID, scheduled, mergeInherited, platform, includeOpenframeManaged) } func (s *Service) GetQuery(ctx context.Context, id uint) (*fleet.Query, error) { diff --git a/server/service/global_schedule.go b/server/service/global_schedule.go index ffaeb7fe84c..86babdc900d 100644 --- a/server/service/global_schedule.go +++ b/server/service/global_schedule.go @@ -37,7 +37,7 @@ func getGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc fle } func (svc *Service) GetGlobalScheduledQueries(ctx context.Context, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) { - queries, _, _, _, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true), false, nil) // teamID == nil means global + queries, _, _, _, err := svc.ListQueries(ctx, opts, nil, ptr.Bool(true), false, nil, false) // teamID == nil means global if err != nil { return nil, err } diff --git a/server/service/queries.go b/server/service/queries.go index dced8556133..70c9d5baf35 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -58,7 +58,7 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser urlPlatform = &req.Platform } - queries, count, inheritedCount, meta, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited, urlPlatform) + queries, count, inheritedCount, meta, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited, urlPlatform, req.IncludeOpenframeManaged) if err != nil { return fleet.ListQueriesResponse{Err: err}, nil } @@ -76,7 +76,7 @@ func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Ser }, nil } -func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, urlPlatform *string) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { +func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, urlPlatform *string, includeOpenframeManaged bool) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) { // Check the user is allowed to list queries on the given team. if err := svc.authz.Authorize(ctx, &fleet.Query{ TeamID: teamID, @@ -106,11 +106,12 @@ func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, team } queries, count, inheritedCount, meta, err := svc.ds.ListQueries(ctx, fleet.ListQueryOptions{ - ListOptions: opt, - TeamID: teamID, - IsScheduled: scheduled, - MergeInherited: mergeInherited, - Platform: dbPlatform, + ListOptions: opt, + TeamID: teamID, + IsScheduled: scheduled, + MergeInherited: mergeInherited, + Platform: dbPlatform, + IncludeOpenframeManaged: includeOpenframeManaged, }) if err != nil { return nil, 0, 0, nil, err @@ -889,7 +890,7 @@ func getQuerySpecsEndpoint(ctx context.Context, request interface{}, svc fleet.S } func (svc *Service) GetQuerySpecs(ctx context.Context, teamID *uint) ([]*fleet.QuerySpec, error) { - queries, _, _, _, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false, nil) + queries, _, _, _, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false, nil, false) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting queries") } diff --git a/server/service/queries_test.go b/server/service/queries_test.go index 4a0687ca1fa..dd03a73d2b9 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -825,7 +825,7 @@ func TestQueryAuth(t *testing.T) { _, err = svc.QueryReportIsClipped(ctx, tt.qid, fleet.DefaultMaxQueryReportRows) checkAuthErr(t, tt.shouldFailRead, err) - _, _, _, _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false, nil) + _, _, _, _, err = svc.ListQueries(ctx, fleet.ListOptions{}, query.TeamID, nil, false, nil, false) checkAuthErr(t, tt.shouldFailRead, err) teamName := "" diff --git a/server/service/team_schedule.go b/server/service/team_schedule.go index f8b03827d23..f453ebf366e 100644 --- a/server/service/team_schedule.go +++ b/server/service/team_schedule.go @@ -47,7 +47,7 @@ func (svc Service) GetTeamScheduledQueries(ctx context.Context, teamID uint, opt if teamID != 0 { teamID_ = &teamID } - queries, _, _, _, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true), false, nil) + queries, _, _, _, err := svc.ListQueries(ctx, opts, teamID_, ptr.Bool(true), false, nil, false) if err != nil { return nil, err }