Skip to content

Commit 3d2abfb

Browse files
feat(reports): fleet framework catalog endpoint + framework picker (B0) (#640)
Reports Phase B0 (docs/engineering/reports_design.md §12). The first Phase B slice, and it retroactively closes the A1 deferred framework lens. - GET /api/v1/reports/frameworks (host:read) returns the fleet framework catalog: distinct framework_refs keys across host_rule_state, each with a DISTINCT-rule count, ordered count desc then key asc ({frameworks:[{framework,rule_count}]}; empty array when nothing is scanned). report.Service.Frameworks via jsonb_object_keys. - Frontend: a framework-lens picker beside the group scope picker (deferred in A1 for lack of a catalog), populated from the new endpoint; the generate body now carries group_id and/or framework. SDD: api-reports v1.6.0 (C-12, AC-17 service + AC-18 endpoint), frontend-reports v1.5.0 (C-08, AC-09). gofmt/vet/build clean; go mod tidy no-op; specter 111 (api-reports 18/18, frontend-reports 9/9, 100%); report + server frameworks tests + full frontend suite (328) green.
1 parent 859f29f commit 3d2abfb

12 files changed

Lines changed: 918 additions & 508 deletions

File tree

api/openapi.yaml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,6 +2513,32 @@ paths:
25132513
application/json:
25142514
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
25152515

2516+
/api/v1/reports/frameworks:
2517+
get:
2518+
operationId: getReportFrameworks
2519+
summary: List the framework lenses available across the fleet
2520+
description: |
2521+
Returns the distinct framework_refs keys present anywhere in the
2522+
fleet (across host_rule_state), each with the count of distinct
2523+
rules mapped to it, most-populated first. Backs the report scope
2524+
picker's framework lens. RBAC: host:read. Spec api-reports.
2525+
responses:
2526+
'200':
2527+
description: The fleet framework catalog
2528+
content:
2529+
application/json:
2530+
schema: {$ref: '#/components/schemas/ReportFrameworksResponse'}
2531+
'401':
2532+
description: Caller is not authenticated
2533+
content:
2534+
application/json:
2535+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2536+
'403':
2537+
description: Caller lacks host:read permission
2538+
content:
2539+
application/json:
2540+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2541+
25162542
/api/v1/reports/signing-key:
25172543
get:
25182544
operationId: getReportSigningKey
@@ -5416,6 +5442,26 @@ components:
54165442
True when the server runs a per-boot development key (no durable
54175443
key configured); such signatures do not verify across restarts.
54185444
5445+
ReportFramework:
5446+
type: object
5447+
required: [framework, rule_count]
5448+
description: A framework lens present in the fleet, with its rule count.
5449+
properties:
5450+
framework:
5451+
type: string
5452+
description: The framework_refs key (e.g. cis_rhel9_v2.0.0).
5453+
rule_count:
5454+
type: integer
5455+
description: Distinct rules mapped to this framework across the fleet.
5456+
5457+
ReportFrameworksResponse:
5458+
type: object
5459+
required: [frameworks]
5460+
properties:
5461+
frameworks:
5462+
type: array
5463+
items: {$ref: '#/components/schemas/ReportFramework'}
5464+
54195465
ReportListResponse:
54205466
type: object
54215467
required: [reports]

frontend/src/api/schema.d.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,29 @@ export interface paths {
15781578
patch?: never;
15791579
trace?: never;
15801580
};
1581+
"/api/v1/reports/frameworks": {
1582+
parameters: {
1583+
query?: never;
1584+
header?: never;
1585+
path?: never;
1586+
cookie?: never;
1587+
};
1588+
/**
1589+
* List the framework lenses available across the fleet
1590+
* @description Returns the distinct framework_refs keys present anywhere in the
1591+
* fleet (across host_rule_state), each with the count of distinct
1592+
* rules mapped to it, most-populated first. Backs the report scope
1593+
* picker's framework lens. RBAC: host:read. Spec api-reports.
1594+
*/
1595+
get: operations["getReportFrameworks"];
1596+
put?: never;
1597+
post?: never;
1598+
delete?: never;
1599+
options?: never;
1600+
head?: never;
1601+
patch?: never;
1602+
trace?: never;
1603+
};
15811604
"/api/v1/reports/signing-key": {
15821605
parameters: {
15831606
query?: never;
@@ -3603,6 +3626,16 @@ export interface components {
36033626
*/
36043627
ephemeral: boolean;
36053628
};
3629+
/** @description A framework lens present in the fleet, with its rule count. */
3630+
ReportFramework: {
3631+
/** @description The framework_refs key (e.g. cis_rhel9_v2.0.0). */
3632+
framework: string;
3633+
/** @description Distinct rules mapped to this framework across the fleet. */
3634+
rule_count: number;
3635+
};
3636+
ReportFrameworksResponse: {
3637+
frameworks: components["schemas"]["ReportFramework"][];
3638+
};
36063639
ReportListResponse: {
36073640
reports: components["schemas"]["Report"][];
36083641
};
@@ -7602,6 +7635,44 @@ export interface operations {
76027635
};
76037636
};
76047637
};
7638+
getReportFrameworks: {
7639+
parameters: {
7640+
query?: never;
7641+
header?: never;
7642+
path?: never;
7643+
cookie?: never;
7644+
};
7645+
requestBody?: never;
7646+
responses: {
7647+
/** @description The fleet framework catalog */
7648+
200: {
7649+
headers: {
7650+
[name: string]: unknown;
7651+
};
7652+
content: {
7653+
"application/json": components["schemas"]["ReportFrameworksResponse"];
7654+
};
7655+
};
7656+
/** @description Caller is not authenticated */
7657+
401: {
7658+
headers: {
7659+
[name: string]: unknown;
7660+
};
7661+
content: {
7662+
"application/json": components["schemas"]["ErrorEnvelope"];
7663+
};
7664+
};
7665+
/** @description Caller lacks host:read permission */
7666+
403: {
7667+
headers: {
7668+
[name: string]: unknown;
7669+
};
7670+
content: {
7671+
"application/json": components["schemas"]["ErrorEnvelope"];
7672+
};
7673+
};
7674+
};
7675+
};
76057676
getReportSigningKey: {
76067677
parameters: {
76077678
query?: never;

frontend/src/pages/reports/ReportsPage.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ export function ReportsPage() {
8585

8686
const [tab, setTab] = useState<'library' | 'templates' | 'scheduled'>('library');
8787
const [selectedId, setSelectedId] = useState<string | null>(null);
88-
// Scope for the next Generate. '' = all hosts (the unscoped summary).
88+
// Scope for the next Generate. '' = all hosts / all frameworks.
8989
const [scopeGroupId, setScopeGroupId] = useState<string>('');
90+
const [scopeFramework, setScopeFramework] = useState<string>('');
9091

9192
const queryClient = useQueryClient();
9293
const canGenerate = useAuthStore((s) => s.hasPermission('host:write'));
@@ -116,9 +117,25 @@ export function ReportsPage() {
116117
});
117118
const groups = groupsQ.data?.groups ?? [];
118119

120+
// Frameworks populate the lens picker (the fleet framework catalog).
121+
// host:read; tolerate failure by falling back to the all-frameworks lens.
122+
const frameworksQ = useQuery({
123+
queryKey: ['report-frameworks'],
124+
queryFn: async () => {
125+
const { data, error, response } = await api.GET('/api/v1/reports/frameworks', {});
126+
if (error || !response.ok)
127+
throw new Error(apiErrorMessage(error, `Failed (${response.status})`));
128+
return data!;
129+
},
130+
enabled: canGenerate,
131+
});
132+
const frameworks = frameworksQ.data?.frameworks ?? [];
133+
119134
const generateMutation = useMutation({
120135
mutationFn: async () => {
121-
const body = scopeGroupId ? { group_id: scopeGroupId } : {};
136+
const body: { group_id?: string; framework?: string } = {};
137+
if (scopeGroupId) body.group_id = scopeGroupId;
138+
if (scopeFramework) body.framework = scopeFramework;
122139
const { data, error, response } = await api.POST('/api/v1/reports:generate', { body });
123140
if (error || !response.ok)
124141
throw new Error(apiErrorMessage(error, `Failed (${response.status})`));
@@ -182,6 +199,33 @@ export function ReportsPage() {
182199
))}
183200
</select>
184201
)}
202+
{canGenerate && frameworks.length > 0 && (
203+
<select
204+
aria-label="Framework lens"
205+
value={scopeFramework}
206+
onChange={(e) => setScopeFramework(e.target.value)}
207+
disabled={generateMutation.isPending}
208+
title="Scope the report to one framework lens, or all frameworks"
209+
style={{
210+
height: 34,
211+
padding: '0 10px',
212+
borderRadius: 'var(--ow-radius-sm, 6px)',
213+
border: '1px solid var(--ow-line)',
214+
background: 'var(--ow-bg-2)',
215+
color: 'var(--ow-fg-0)',
216+
fontFamily: 'inherit',
217+
fontSize: 13,
218+
cursor: generateMutation.isPending ? 'default' : 'pointer',
219+
}}
220+
>
221+
<option value="">All frameworks</option>
222+
{frameworks.map((f) => (
223+
<option key={f.framework} value={f.framework}>
224+
{f.framework} ({f.rule_count})
225+
</option>
226+
))}
227+
</select>
228+
)}
185229
<button
186230
type="button"
187231
onClick={() => generateMutation.mutate()}

frontend/tests/pages/reports.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('frontend-reports — reports library page', () => {
9393
// The groups query is a host:write affordance (enabled on canGenerate).
9494
expect(PAGE_SRC).toMatch(/enabled:\s*canGenerate/);
9595
// The generate body includes group_id only when a group is chosen.
96-
expect(PAGE_SRC).toMatch(/scopeGroupId\s*\?\s*\{\s*group_id:\s*scopeGroupId\s*\}\s*:\s*\{\}/);
96+
expect(PAGE_SRC).toMatch(/if \(scopeGroupId\) body\.group_id = scopeGroupId/);
9797
});
9898

9999
// @ac AC-06
@@ -146,6 +146,19 @@ describe('frontend-reports — reports library page', () => {
146146
expect(PAGE_SRC).toContain('verifyResult');
147147
});
148148

149+
// @ac AC-09
150+
test('frontend-reports/AC-09 — framework lens picker (fleet catalog)', () => {
151+
// A framework select bound to scopeFramework, defaulting to "All frameworks".
152+
expect(PAGE_SRC).toContain('scopeFramework');
153+
expect(PAGE_SRC).toMatch(/<select[\s\S]*?value=\{scopeFramework\}/);
154+
expect(PAGE_SRC).toContain('<option value="">All frameworks</option>');
155+
// Options come from a ['report-frameworks'] query against the catalog.
156+
expect(PAGE_SRC).toContain("queryKey: ['report-frameworks']");
157+
expect(PAGE_SRC).toContain("api.GET('/api/v1/reports/frameworks'");
158+
// The generate body sets framework when chosen (alongside group_id).
159+
expect(PAGE_SRC).toMatch(/if \(scopeFramework\) body\.framework = scopeFramework/);
160+
});
161+
149162
// @ac AC-04
150163
test('frontend-reports/AC-04 — generate is the only mutation, tokens, no em-dash', () => {
151164
// The only mutating call is the generate POST; no PUT/DELETE.

internal/report/service.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,33 @@ func (s *Service) Generate(ctx context.Context, generatedBy string, req Generate
168168
return rep, nil
169169
}
170170

171+
// Frameworks returns the distinct framework_refs keys present anywhere in
172+
// the fleet, each with the count of distinct rules mapped to it,
173+
// most-populated first. Backs the report scope picker's framework lens.
174+
func (s *Service) Frameworks(ctx context.Context) ([]FrameworkCount, error) {
175+
rows, err := s.pool.Query(ctx, `
176+
SELECT k AS framework, count(DISTINCT rule_id)::int AS rule_count
177+
FROM host_rule_state, jsonb_object_keys(framework_refs) AS k
178+
GROUP BY k
179+
ORDER BY rule_count DESC, k ASC`)
180+
if err != nil {
181+
return nil, fmt.Errorf("report: frameworks: %w", err)
182+
}
183+
defer rows.Close()
184+
out := []FrameworkCount{}
185+
for rows.Next() {
186+
var f FrameworkCount
187+
if err := rows.Scan(&f.Framework, &f.RuleCount); err != nil {
188+
return nil, fmt.Errorf("report: frameworks scan: %w", err)
189+
}
190+
out = append(out, f)
191+
}
192+
if err := rows.Err(); err != nil {
193+
return nil, fmt.Errorf("report: frameworks iterate: %w", err)
194+
}
195+
return out, nil
196+
}
197+
171198
// computeExecutive samples the fleet posture from host_rule_state and
172199
// the hosts table. Same shape as the Groups fleet rollup and
173200
// fleetrollup.TopFailingRules so the numbers agree across the app. When

internal/report/service_db_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,45 @@ func TestGenerate_FrameworkScoped(t *testing.T) {
536536
})
537537
}
538538

539+
// @ac AC-17
540+
// Frameworks returns the distinct framework_refs keys present in the
541+
// fleet, each counted by DISTINCT rule_id (not row), ordered by count
542+
// desc then key asc. Seeded: cis on r1 (two hosts) + r3, stig on r2, nist
543+
// on r3 -> cis=2 distinct rules, stig=1, nist=1.
544+
func TestFrameworks_FleetCatalog(t *testing.T) {
545+
t.Run("api-reports/AC-17", func(t *testing.T) {
546+
pool := freshPool(t)
547+
ctx := context.Background()
548+
svc := NewService(pool)
549+
owner := seedUser(t, pool)
550+
h1 := seedHost(t, pool, owner, false)
551+
h2 := seedHost(t, pool, owner, false)
552+
553+
seedRuleStateFW(t, pool, h1, "r1", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.1"]}`)
554+
seedRuleStateFW(t, pool, h2, "r1", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.1"]}`)
555+
seedRuleStateFW(t, pool, h1, "r2", "fail", "high", `{"stig_rhel9_v2r7": ["V-1"]}`)
556+
seedRuleStateFW(t, pool, h2, "r3", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.2"], "nist_800_53_r5": ["AC-1"]}`)
557+
558+
fws, err := svc.Frameworks(ctx)
559+
if err != nil {
560+
t.Fatalf("Frameworks: %v", err)
561+
}
562+
want := []FrameworkCount{
563+
{Framework: "cis_rhel9_v2.0.0", RuleCount: 2},
564+
{Framework: "nist_800_53_r5", RuleCount: 1},
565+
{Framework: "stig_rhel9_v2r7", RuleCount: 1},
566+
}
567+
if len(fws) != len(want) {
568+
t.Fatalf("frameworks = %+v, want %+v", fws, want)
569+
}
570+
for i, w := range want {
571+
if fws[i] != w {
572+
t.Errorf("frameworks[%d] = %+v, want %+v", i, fws[i], w)
573+
}
574+
}
575+
})
576+
}
577+
539578
// decodeContent unmarshals a report's frozen JSON content into the typed
540579
// executive shape, failing the test on malformed content.
541580
func decodeContent(t *testing.T, rep Report) ExecutiveContent {

internal/report/types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ type Coverage struct {
114114
HostsUnreachable int `json:"hosts_unreachable"`
115115
}
116116

117+
// FrameworkCount is one entry in the fleet framework catalog: a
118+
// framework_refs key present somewhere in the fleet and the number of
119+
// distinct rules mapped to it. Backs the report scope picker's framework
120+
// lens.
121+
type FrameworkCount struct {
122+
Framework string `json:"framework"`
123+
RuleCount int `json:"rule_count"`
124+
}
125+
117126
// TopFailingRule is one entry in the executive summary's top-failing
118127
// list: a rule id and how many hosts it fails on.
119128
type TopFailingRule struct {

0 commit comments

Comments
 (0)