Skip to content

Commit 42b1a4f

Browse files
feat(reports): bounded executive PDF face + export endpoint (A3b) (#634)
Reports A3b backend (docs/engineering/reports_design.md). Adds the PDF face and the export surface; the report_faces table from A3a gets its first writer. - GET /api/v1/reports/{id}/export?format=pdf|json streams a rendered face as a downloadable attachment (host:read). format=pdf renders a BOUNDED one-page A4 executive document (header + posture block + coverage caveat (only when stale/unreachable) + top-failing list) - O(1) pages regardless of fleet size, never per-host/per-rule evidence. format=json returns the canonical frozen content. Unknown id -> 404 reports.not_found; unknown format -> 400 reports.invalid_format. - The PDF renders lazily on first request and caches in report_faces keyed by (snapshot_id, face) with status/size/blob_sha256; a repeat export re-streams the cached bytes. - Renderer uses the pure-Go go-pdf/fpdf core fonts (no font files, no headless browser - airgap-safe) + a cp1252 UTF-8 translator so the middle-dot separators render. Validated with pdfinfo/pdftotext: valid 1-page PDF, clean text. Dependency governance: go-pdf/fpdf added to the depguard allowlist (.golangci.yml) with rationale; its sole transitive dep (boombuler/barcode) is indirect (pinned, not allowlisted per C-02); system-supply-chain bumped 1.0.0 -> 1.1.0 (process note). go mod tidy is a no-op; govulncheck clean. (Regen collateral: the new format enum disambiguated the shared api.Json const to api.GetAuditEventsExportParamsFormatJson; updated the one audit-export reference.) SDD: api-reports v1.4.0 (C-10 export, AC-12 render, AC-13 faces+cache). gofmt/vet/build clean; specter 111 (api-reports 13/13, supply-chain 11/11, 100%); report Go suite + audit-export tests green. Follow-up A3b-2: the frontend Download button on the report detail.
1 parent 4faff75 commit 42b1a4f

12 files changed

Lines changed: 1172 additions & 500 deletions

File tree

.golangci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ linters-settings:
6565
- github.com/gliderlabs/ssh # in-process SSH server for ssh tests
6666
- github.com/Hanalyx/kensa # Kensa compliance engine API (pinned for version tracking; see internal/kensa/import.go)
6767
- github.com/go-chi/chi/v5 # HTTP router
68+
- github.com/go-pdf/fpdf # pure-Go PDF: bounded executive report face (airgap-safe core fonts)
6869
- github.com/golang-jwt/jwt/v5 # RS256 JWT
6970
- github.com/google/uuid # UUID v4 / v7
7071
- github.com/jackc/pgx/v5 # PostgreSQL driver

api/openapi.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2547,6 +2547,58 @@ paths:
25472547
application/json:
25482548
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
25492549

2550+
/api/v1/reports/{id}/export:
2551+
get:
2552+
operationId: getReportExport
2553+
summary: Download a rendered face of a report (PDF or JSON)
2554+
description: |
2555+
Streams a rendered face of the report as a downloadable
2556+
attachment. format=pdf (default) renders the bounded one-page
2557+
executive PDF (a fixed posture block, the coverage caveat, and the
2558+
top-failing list - never the full per-host/per-rule evidence);
2559+
format=json returns the canonical frozen posture content. The PDF
2560+
is rendered on first request and cached in report_faces, so a
2561+
repeat download re-streams the stored bytes. RBAC: host:read. Spec
2562+
api-reports.
2563+
parameters:
2564+
- name: id
2565+
in: path
2566+
required: true
2567+
schema: {type: string, format: uuid}
2568+
- name: format
2569+
in: query
2570+
required: false
2571+
description: The face to render. Defaults to pdf.
2572+
schema: {type: string, enum: [pdf, json], default: pdf}
2573+
responses:
2574+
'200':
2575+
description: The rendered face, as a downloadable attachment
2576+
content:
2577+
application/pdf:
2578+
schema: {type: string, format: binary}
2579+
application/json:
2580+
schema: {type: object, additionalProperties: true}
2581+
'400':
2582+
description: Unknown format
2583+
content:
2584+
application/json:
2585+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2586+
'401':
2587+
description: Caller is not authenticated
2588+
content:
2589+
application/json:
2590+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2591+
'403':
2592+
description: Caller lacks host:read permission
2593+
content:
2594+
application/json:
2595+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2596+
'404':
2597+
description: Report not found
2598+
content:
2599+
application/json:
2600+
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
2601+
25502602
/api/v1/scans:
25512603
get:
25522604
operationId: getScans

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/getkin/kin-openapi v0.139.0
99
github.com/gliderlabs/ssh v0.3.8
1010
github.com/go-chi/chi/v5 v5.3.0
11+
github.com/go-pdf/fpdf v0.9.0
1112
github.com/golang-jwt/jwt/v5 v5.3.1
1213
github.com/google/uuid v1.6.0
1314
github.com/jackc/pgx/v5 v5.9.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1
3232
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
3333
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
3434
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
35+
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
36+
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
3537
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
3638
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
3739
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=

internal/report/export.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package report
2+
3+
// Report faces: downloadable renderings of a snapshot. The JSON face is
4+
// the canonical snapshot content served verbatim; the PDF face is the
5+
// bounded one-page executive document (pdf.go), rendered lazily on first
6+
// request and cached in report_faces keyed by (snapshot_id, face) so a
7+
// repeat download re-streams the stored bytes instead of re-rendering.
8+
//
9+
// Spec: api-reports v1.4.0.
10+
11+
import (
12+
"context"
13+
"crypto/sha256"
14+
"encoding/hex"
15+
"encoding/json"
16+
"errors"
17+
"fmt"
18+
19+
"github.com/google/uuid"
20+
"github.com/jackc/pgx/v5"
21+
)
22+
23+
// ErrInvalidFace is returned by Export for an unknown face. Handlers map
24+
// it to 400.
25+
var ErrInvalidFace = errors.New("report: invalid face")
26+
27+
// Face identifiers and their media types.
28+
const (
29+
FaceJSON = "json"
30+
FacePDF = "pdf"
31+
)
32+
33+
// Export returns a rendered face of the report (its bytes + media type),
34+
// or ErrNotFound for an unknown id / ErrInvalidFace for an unknown face.
35+
// The JSON face is the canonical content; the PDF face is rendered and
36+
// cached in report_faces on first request.
37+
func (s *Service) Export(ctx context.Context, id uuid.UUID, face string) ([]byte, string, error) {
38+
rep, err := s.Get(ctx, id)
39+
if err != nil {
40+
return nil, "", err // ErrNotFound propagates
41+
}
42+
switch face {
43+
case FaceJSON:
44+
// The canonical snapshot content IS the json face; no caching.
45+
return rep.Content, "application/json", nil
46+
case FacePDF:
47+
return s.exportPDF(ctx, rep)
48+
default:
49+
return nil, "", ErrInvalidFace
50+
}
51+
}
52+
53+
// exportPDF returns the cached PDF face if present, else renders the
54+
// executive PDF from the frozen content, caches it in report_faces, and
55+
// returns it.
56+
func (s *Service) exportPDF(ctx context.Context, rep Report) ([]byte, string, error) {
57+
const mediaType = "application/pdf"
58+
59+
// Cache hit?
60+
var cached []byte
61+
err := s.pool.QueryRow(ctx,
62+
`SELECT content FROM report_faces WHERE snapshot_id = $1 AND face = $2 AND status = 'ready'`,
63+
rep.ID, FacePDF).Scan(&cached)
64+
if err == nil && len(cached) > 0 {
65+
return cached, mediaType, nil
66+
}
67+
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
68+
return nil, "", fmt.Errorf("report: face lookup: %w", err)
69+
}
70+
71+
// Render from the frozen content.
72+
var c ExecutiveContent
73+
if err := json.Unmarshal(rep.Content, &c); err != nil {
74+
return nil, "", fmt.Errorf("report: decode content for pdf: %w", err)
75+
}
76+
pdfBytes, err := renderExecutivePDF(rep, c)
77+
if err != nil {
78+
return nil, "", err
79+
}
80+
sum := sha256.Sum256(pdfBytes)
81+
blobSHA := hex.EncodeToString(sum[:])
82+
83+
// Cache it (idempotent: a concurrent render just overwrites with the
84+
// same deterministic bytes).
85+
_, err = s.pool.Exec(ctx, `
86+
INSERT INTO report_faces (snapshot_id, face, media_type, content, size_bytes, blob_sha256, status)
87+
VALUES ($1, $2, $3, $4, $5, $6, 'ready')
88+
ON CONFLICT (snapshot_id, face)
89+
DO UPDATE SET content = EXCLUDED.content, media_type = EXCLUDED.media_type,
90+
size_bytes = EXCLUDED.size_bytes, blob_sha256 = EXCLUDED.blob_sha256,
91+
status = 'ready'`,
92+
rep.ID, FacePDF, mediaType, pdfBytes, len(pdfBytes), blobSHA)
93+
if err != nil {
94+
// A cache write failure should not fail the download - the bytes
95+
// are already rendered.
96+
return pdfBytes, mediaType, nil
97+
}
98+
return pdfBytes, mediaType, nil
99+
}
100+
101+
// ExportFilename builds a download filename for a report face, e.g.
102+
// "openwatch-executive-all-hosts-2026-06-21.pdf".
103+
func ExportFilename(rep Report, face string) string {
104+
ext := face
105+
slug := slugify(rep.ScopeLabel)
106+
return fmt.Sprintf("openwatch-executive-%s-%s.%s", slug, rep.DataAsOf.Format("2006-01-02"), ext)
107+
}
108+
109+
// slugify lowercases and replaces non-alphanumeric runs with single
110+
// hyphens for a filesystem-safe filename fragment.
111+
func slugify(s string) string {
112+
out := make([]byte, 0, len(s))
113+
prevHyphen := false
114+
for i := 0; i < len(s); i++ {
115+
c := s[i]
116+
switch {
117+
case c >= 'A' && c <= 'Z':
118+
out = append(out, c+32)
119+
prevHyphen = false
120+
case (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'):
121+
out = append(out, c)
122+
prevHyphen = false
123+
default:
124+
if !prevHyphen {
125+
out = append(out, '-')
126+
prevHyphen = true
127+
}
128+
}
129+
}
130+
res := string(out)
131+
// trim leading/trailing hyphens
132+
for len(res) > 0 && res[0] == '-' {
133+
res = res[1:]
134+
}
135+
for len(res) > 0 && res[len(res)-1] == '-' {
136+
res = res[:len(res)-1]
137+
}
138+
if res == "" {
139+
res = "report"
140+
}
141+
return res
142+
}

internal/report/export_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// @spec api-reports
2+
//
3+
// Report faces (export). The pure render test needs no DB; the
4+
// export/caching path is OPENWATCH_TEST_DSN-gated:
5+
//
6+
// AC-12 TestRenderExecutivePDF (renderer emits a structurally valid PDF)
7+
// AC-13 TestExport_FacesAndCaching (json + pdf faces, report_faces cache, errors)
8+
9+
package report
10+
11+
import (
12+
"bytes"
13+
"context"
14+
"errors"
15+
"testing"
16+
"time"
17+
18+
"github.com/google/uuid"
19+
)
20+
21+
// @ac AC-12
22+
// The PDF renderer emits a structurally valid, non-trivial PDF document
23+
// (magic header + EOF trailer) from a report + its content. Pure: no DB.
24+
func TestRenderExecutivePDF(t *testing.T) {
25+
t.Run("api-reports/AC-12", func(t *testing.T) {
26+
pct := 65
27+
rep := Report{
28+
Title: executiveTitle,
29+
ScopeLabel: "RHEL hosts · CIS",
30+
DataAsOf: time.Date(2026, 6, 21, 14, 30, 0, 0, time.UTC),
31+
GeneratedBy: "alice@example.com",
32+
ContentSHA256: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
33+
}
34+
c := ExecutiveContent{
35+
CompliancePct: &pct,
36+
HostCount: 5,
37+
PassingRules: 1812,
38+
FailingRules: 821,
39+
CriticalIssues: 0,
40+
TopFailingRules: []TopFailingRule{
41+
{RuleID: "audit-chmod-changes", FailingHostCount: 5},
42+
{RuleID: "audit-chown-changes", FailingHostCount: 5},
43+
},
44+
Coverage: Coverage{HostsTotal: 5, HostsFresh: 4, HostsStale: 1, HostsUnreachable: 1},
45+
}
46+
47+
pdf, err := renderExecutivePDF(rep, c)
48+
if err != nil {
49+
t.Fatalf("renderExecutivePDF: %v", err)
50+
}
51+
if len(pdf) < 800 {
52+
t.Errorf("pdf is implausibly small: %d bytes", len(pdf))
53+
}
54+
if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
55+
t.Errorf("pdf does not start with the %%PDF- magic: %q", pdf[:min(8, len(pdf))])
56+
}
57+
if !bytes.Contains(pdf, []byte("%%EOF")) {
58+
t.Errorf("pdf has no %%%%EOF trailer")
59+
}
60+
})
61+
}
62+
63+
// @ac AC-13
64+
// Export serves the json face (canonical content) and the pdf face
65+
// (rendered + cached in report_faces). A second pdf export re-streams the
66+
// cached bytes; an unknown face is ErrInvalidFace; an unknown id is
67+
// ErrNotFound.
68+
func TestExport_FacesAndCaching(t *testing.T) {
69+
t.Run("api-reports/AC-13", func(t *testing.T) {
70+
pool := freshPool(t)
71+
ctx := context.Background()
72+
svc := NewService(pool)
73+
owner := seedUser(t, pool)
74+
h := seedHost(t, pool, owner, false)
75+
seedRuleState(t, pool, h, "r1", "fail", "high")
76+
77+
rep, err := svc.Generate(ctx, "alice@example.com", GenerateRequest{})
78+
if err != nil {
79+
t.Fatalf("Generate: %v", err)
80+
}
81+
82+
// json face == the canonical stored content.
83+
jsonBody, jsonMedia, err := svc.Export(ctx, rep.ID, FaceJSON)
84+
if err != nil {
85+
t.Fatalf("Export json: %v", err)
86+
}
87+
if jsonMedia != "application/json" {
88+
t.Errorf("json media = %q", jsonMedia)
89+
}
90+
if !bytes.Equal(jsonBody, rep.Content) {
91+
t.Errorf("json face is not the canonical content")
92+
}
93+
94+
// pdf face renders and caches.
95+
pdfBody, pdfMedia, err := svc.Export(ctx, rep.ID, FacePDF)
96+
if err != nil {
97+
t.Fatalf("Export pdf: %v", err)
98+
}
99+
if pdfMedia != "application/pdf" {
100+
t.Errorf("pdf media = %q", pdfMedia)
101+
}
102+
if !bytes.HasPrefix(pdfBody, []byte("%PDF-")) {
103+
t.Errorf("pdf face is not a PDF")
104+
}
105+
106+
// A report_faces row was written with status ready + a content hash.
107+
var status, blob string
108+
var size int
109+
if err := pool.QueryRow(ctx,
110+
`SELECT status, blob_sha256, size_bytes FROM report_faces WHERE snapshot_id = $1 AND face = 'pdf'`,
111+
rep.ID).Scan(&status, &blob, &size); err != nil {
112+
t.Fatalf("face row lookup: %v", err)
113+
}
114+
if status != "ready" || len(blob) != 64 || size != len(pdfBody) {
115+
t.Errorf("face row = status %q, blob %q (len %d), size %d (want ready/64/%d)",
116+
status, blob, len(blob), size, len(pdfBody))
117+
}
118+
119+
// Second export re-streams the cached bytes (identical).
120+
pdfBody2, _, err := svc.Export(ctx, rep.ID, FacePDF)
121+
if err != nil {
122+
t.Fatalf("Export pdf #2: %v", err)
123+
}
124+
if !bytes.Equal(pdfBody, pdfBody2) {
125+
t.Errorf("cached pdf differs from first render")
126+
}
127+
128+
// Unknown face -> ErrInvalidFace.
129+
if _, _, err := svc.Export(ctx, rep.ID, "oscal"); !errors.Is(err, ErrInvalidFace) {
130+
t.Errorf("Export bogus face err = %v, want ErrInvalidFace", err)
131+
}
132+
// Unknown id -> ErrNotFound.
133+
if _, _, err := svc.Export(ctx, uuid.New(), FacePDF); !errors.Is(err, ErrNotFound) {
134+
t.Errorf("Export unknown id err = %v, want ErrNotFound", err)
135+
}
136+
})
137+
}
138+
139+
func min(a, b int) int {
140+
if a < b {
141+
return a
142+
}
143+
return b
144+
}

0 commit comments

Comments
 (0)