Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,32 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/reports/frameworks:
get:
operationId: getReportFrameworks
summary: List the framework lenses available across the fleet
description: |
Returns the distinct framework_refs keys present anywhere in the
fleet (across host_rule_state), each with the count of distinct
rules mapped to it, most-populated first. Backs the report scope
picker's framework lens. RBAC: host:read. Spec api-reports.
responses:
'200':
description: The fleet framework catalog
content:
application/json:
schema: {$ref: '#/components/schemas/ReportFrameworksResponse'}
'401':
description: Caller is not authenticated
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
'403':
description: Caller lacks host:read permission
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/reports/signing-key:
get:
operationId: getReportSigningKey
Expand Down Expand Up @@ -5416,6 +5442,26 @@ components:
True when the server runs a per-boot development key (no durable
key configured); such signatures do not verify across restarts.

ReportFramework:
type: object
required: [framework, rule_count]
description: A framework lens present in the fleet, with its rule count.
properties:
framework:
type: string
description: The framework_refs key (e.g. cis_rhel9_v2.0.0).
rule_count:
type: integer
description: Distinct rules mapped to this framework across the fleet.

ReportFrameworksResponse:
type: object
required: [frameworks]
properties:
frameworks:
type: array
items: {$ref: '#/components/schemas/ReportFramework'}

ReportListResponse:
type: object
required: [reports]
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/reports/frameworks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the framework lenses available across the fleet
* @description Returns the distinct framework_refs keys present anywhere in the
* fleet (across host_rule_state), each with the count of distinct
* rules mapped to it, most-populated first. Backs the report scope
* picker's framework lens. RBAC: host:read. Spec api-reports.
*/
get: operations["getReportFrameworks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reports/signing-key": {
parameters: {
query?: never;
Expand Down Expand Up @@ -3603,6 +3626,16 @@ export interface components {
*/
ephemeral: boolean;
};
/** @description A framework lens present in the fleet, with its rule count. */
ReportFramework: {
/** @description The framework_refs key (e.g. cis_rhel9_v2.0.0). */
framework: string;
/** @description Distinct rules mapped to this framework across the fleet. */
rule_count: number;
};
ReportFrameworksResponse: {
frameworks: components["schemas"]["ReportFramework"][];
};
ReportListResponse: {
reports: components["schemas"]["Report"][];
};
Expand Down Expand Up @@ -7602,6 +7635,44 @@ export interface operations {
};
};
};
getReportFrameworks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The fleet framework catalog */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReportFrameworksResponse"];
};
};
/** @description Caller is not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Caller lacks host:read permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
getReportSigningKey: {
parameters: {
query?: never;
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/pages/reports/ReportsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ export function ReportsPage() {

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

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

// Frameworks populate the lens picker (the fleet framework catalog).
// host:read; tolerate failure by falling back to the all-frameworks lens.
const frameworksQ = useQuery({
queryKey: ['report-frameworks'],
queryFn: async () => {
const { data, error, response } = await api.GET('/api/v1/reports/frameworks', {});
if (error || !response.ok)
throw new Error(apiErrorMessage(error, `Failed (${response.status})`));
return data!;
},
enabled: canGenerate,
});
const frameworks = frameworksQ.data?.frameworks ?? [];

const generateMutation = useMutation({
mutationFn: async () => {
const body = scopeGroupId ? { group_id: scopeGroupId } : {};
const body: { group_id?: string; framework?: string } = {};
if (scopeGroupId) body.group_id = scopeGroupId;
if (scopeFramework) body.framework = scopeFramework;
const { data, error, response } = await api.POST('/api/v1/reports:generate', { body });
if (error || !response.ok)
throw new Error(apiErrorMessage(error, `Failed (${response.status})`));
Expand Down Expand Up @@ -182,6 +199,33 @@ export function ReportsPage() {
))}
</select>
)}
{canGenerate && frameworks.length > 0 && (
<select
aria-label="Framework lens"
value={scopeFramework}
onChange={(e) => setScopeFramework(e.target.value)}
disabled={generateMutation.isPending}
title="Scope the report to one framework lens, or all frameworks"
style={{
height: 34,
padding: '0 10px',
borderRadius: 'var(--ow-radius-sm, 6px)',
border: '1px solid var(--ow-line)',
background: 'var(--ow-bg-2)',
color: 'var(--ow-fg-0)',
fontFamily: 'inherit',
fontSize: 13,
cursor: generateMutation.isPending ? 'default' : 'pointer',
}}
>
<option value="">All frameworks</option>
{frameworks.map((f) => (
<option key={f.framework} value={f.framework}>
{f.framework} ({f.rule_count})
</option>
))}
</select>
)}
<button
type="button"
onClick={() => generateMutation.mutate()}
Expand Down
15 changes: 14 additions & 1 deletion frontend/tests/pages/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('frontend-reports — reports library page', () => {
// The groups query is a host:write affordance (enabled on canGenerate).
expect(PAGE_SRC).toMatch(/enabled:\s*canGenerate/);
// The generate body includes group_id only when a group is chosen.
expect(PAGE_SRC).toMatch(/scopeGroupId\s*\?\s*\{\s*group_id:\s*scopeGroupId\s*\}\s*:\s*\{\}/);
expect(PAGE_SRC).toMatch(/if \(scopeGroupId\) body\.group_id = scopeGroupId/);
});

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

// @ac AC-09
test('frontend-reports/AC-09 — framework lens picker (fleet catalog)', () => {
// A framework select bound to scopeFramework, defaulting to "All frameworks".
expect(PAGE_SRC).toContain('scopeFramework');
expect(PAGE_SRC).toMatch(/<select[\s\S]*?value=\{scopeFramework\}/);
expect(PAGE_SRC).toContain('<option value="">All frameworks</option>');
// Options come from a ['report-frameworks'] query against the catalog.
expect(PAGE_SRC).toContain("queryKey: ['report-frameworks']");
expect(PAGE_SRC).toContain("api.GET('/api/v1/reports/frameworks'");
// The generate body sets framework when chosen (alongside group_id).
expect(PAGE_SRC).toMatch(/if \(scopeFramework\) body\.framework = scopeFramework/);
});

// @ac AC-04
test('frontend-reports/AC-04 — generate is the only mutation, tokens, no em-dash', () => {
// The only mutating call is the generate POST; no PUT/DELETE.
Expand Down
27 changes: 27 additions & 0 deletions internal/report/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,33 @@ func (s *Service) Generate(ctx context.Context, generatedBy string, req Generate
return rep, nil
}

// Frameworks returns the distinct framework_refs keys present anywhere in
// the fleet, each with the count of distinct rules mapped to it,
// most-populated first. Backs the report scope picker's framework lens.
func (s *Service) Frameworks(ctx context.Context) ([]FrameworkCount, error) {
rows, err := s.pool.Query(ctx, `
SELECT k AS framework, count(DISTINCT rule_id)::int AS rule_count
FROM host_rule_state, jsonb_object_keys(framework_refs) AS k
GROUP BY k
ORDER BY rule_count DESC, k ASC`)
if err != nil {
return nil, fmt.Errorf("report: frameworks: %w", err)
}
defer rows.Close()
out := []FrameworkCount{}
for rows.Next() {
var f FrameworkCount
if err := rows.Scan(&f.Framework, &f.RuleCount); err != nil {
return nil, fmt.Errorf("report: frameworks scan: %w", err)
}
out = append(out, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("report: frameworks iterate: %w", err)
}
return out, nil
}

// computeExecutive samples the fleet posture from host_rule_state and
// the hosts table. Same shape as the Groups fleet rollup and
// fleetrollup.TopFailingRules so the numbers agree across the app. When
Expand Down
39 changes: 39 additions & 0 deletions internal/report/service_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,45 @@ func TestGenerate_FrameworkScoped(t *testing.T) {
})
}

// @ac AC-17
// Frameworks returns the distinct framework_refs keys present in the
// fleet, each counted by DISTINCT rule_id (not row), ordered by count
// desc then key asc. Seeded: cis on r1 (two hosts) + r3, stig on r2, nist
// on r3 -> cis=2 distinct rules, stig=1, nist=1.
func TestFrameworks_FleetCatalog(t *testing.T) {
t.Run("api-reports/AC-17", func(t *testing.T) {
pool := freshPool(t)
ctx := context.Background()
svc := NewService(pool)
owner := seedUser(t, pool)
h1 := seedHost(t, pool, owner, false)
h2 := seedHost(t, pool, owner, false)

seedRuleStateFW(t, pool, h1, "r1", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.1"]}`)
seedRuleStateFW(t, pool, h2, "r1", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.1"]}`)
seedRuleStateFW(t, pool, h1, "r2", "fail", "high", `{"stig_rhel9_v2r7": ["V-1"]}`)
seedRuleStateFW(t, pool, h2, "r3", "pass", "low", `{"cis_rhel9_v2.0.0": ["1.2"], "nist_800_53_r5": ["AC-1"]}`)

fws, err := svc.Frameworks(ctx)
if err != nil {
t.Fatalf("Frameworks: %v", err)
}
want := []FrameworkCount{
{Framework: "cis_rhel9_v2.0.0", RuleCount: 2},
{Framework: "nist_800_53_r5", RuleCount: 1},
{Framework: "stig_rhel9_v2r7", RuleCount: 1},
}
if len(fws) != len(want) {
t.Fatalf("frameworks = %+v, want %+v", fws, want)
}
for i, w := range want {
if fws[i] != w {
t.Errorf("frameworks[%d] = %+v, want %+v", i, fws[i], w)
}
}
})
}

// decodeContent unmarshals a report's frozen JSON content into the typed
// executive shape, failing the test on malformed content.
func decodeContent(t *testing.T, rep Report) ExecutiveContent {
Expand Down
9 changes: 9 additions & 0 deletions internal/report/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ type Coverage struct {
HostsUnreachable int `json:"hosts_unreachable"`
}

// FrameworkCount is one entry in the fleet framework catalog: a
// framework_refs key present somewhere in the fleet and the number of
// distinct rules mapped to it. Backs the report scope picker's framework
// lens.
type FrameworkCount struct {
Framework string `json:"framework"`
RuleCount int `json:"rule_count"`
}

// TopFailingRule is one entry in the executive summary's top-failing
// list: a rule id and how many hosts it fails on.
type TopFailingRule struct {
Expand Down
Loading
Loading