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
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.2.0-rc.13] Eyrie — 2026-06-22

The Reports surface is now a complete compliance-artifact platform, spanning
the executive summary, an auditor/GRC bulk path, two new GRC read-model
kinds, and recurring email delivery.

### Added
- **Framework Attestation report kind** (auditor/GRC): a point-in-time,
signed attestation that freezes the latest completed scan per in-scope
host. Faces: a fleet **OSCAL 1.0.6 assessment-results (SAR)** with
evidence referenced by content hash, a per-(host, rule) **CSV** evidence
extract, a bounded one-page **PDF cover**, and the canonical **JSON**.
A frozen, signed compliance rollup (compliance %, pass/fail, top-failing)
is shown in-app and on the PDF cover.
- **Exception Register report kind** (Compliance/GRC): a point-in-time
read-model of compliance waivers — counts by state (active, pending,
expiring-soon) plus the register rows — with CSV + PDF + JSON faces.
- **Remediation Activity report kind** (Operations): a read-model of
remediation requests over a look-back window (last 7/30/90 days), with
an outcome summary and CSV + PDF + JSON faces.
- **Scheduled reports + email delivery**: a daily/weekly/monthly schedule
generates a report and emails its rendered PDF (MIME attachment) through
an email notification channel. Managed from a live **Scheduled** tab
(create / pause-resume / delete). Endpoints under
`/api/v1/reports/schedules`.
- **Asynchronous report rendering** + a new `report.ready` event on the
event bus — the first producer of the in-app **notification bell**.
- **Ed25519 report signing** with an in-browser **offline Verify**, a
fleet **framework catalog** endpoint, and a kind selector + scope/period
pickers on the Library tab.
- Report kinds are admitted by `report_snapshots.kind` (migrations
0043–0045); `report_schedules` lands in migration 0046.

### Audit
- Report generation and report-schedule create/delete/enable-disable now
emit audit events (`report.generated`, `report.schedule.*`).

### Security / hardening
- The scheduled-report dispatcher claims due schedules with
`FOR UPDATE SKIP LOCKED`, so concurrent dispatchers never double-send.
- Report-email subjects are CRLF-sanitized (header-injection defense).

---

## [0.2.0-rc.12] Eyrie — 2026-06-20

The fleet activity stream and audit trail are now readable end to end: every
Expand Down
38 changes: 38 additions & 0 deletions audit/events.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,44 @@ events:
- code: remediation.rolled_back
severity: warning

# =========================================================================
# reports — generation + scheduled delivery
# =========================================================================
- code: report.generated
severity: info
detail_schema:
type: object
properties:
kind: {type: string}
scope_label: {type: string}
report_id: {type: string}

- code: report.schedule.created
severity: info
detail_schema:
type: object
properties:
schedule_id: {type: string}
name: {type: string}
kind: {type: string}
frequency: {type: string}
channel_id: {type: string}

- code: report.schedule.deleted
severity: warning
detail_schema:
type: object
properties:
schedule_id: {type: string}

- code: report.schedule.toggled
severity: info
detail_schema:
type: object
properties:
schedule_id: {type: string}
enabled: {type: boolean}

# =========================================================================
# integration — external systems
# =========================================================================
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/pages/reports/ReportsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1905,9 +1905,10 @@ function ComingSoon({ what }: { what: string }) {
lineHeight: 1.5,
}}
>
{what === 'Templates'
? 'The report kinds (Fleet Compliance Executive Summary and Framework Attestation) are live in the Library tab, each with signed PDF, CSV, OSCAL, and JSON faces. A gallery for building and saving custom report templates is not built yet.'
: 'Scheduled report delivery requires a dispatcher, which is not built yet. For now, generate reports on demand from the Library tab.'}
The report kinds (executive, attestation, exception, remediation) are live in the Library
tab, each with signed PDF, CSV, OSCAL, and JSON faces, and reports can be delivered on a
schedule from the Scheduled tab. A gallery for building and saving custom report templates
is not built yet.
</div>
</div>
</Panel>
Expand Down
40 changes: 40 additions & 0 deletions internal/audit/events.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions internal/notification/report_email.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func (s *Service) SendReportEmail(ctx context.Context, channelID uuid.UUID, subj
// buildReportEmail assembles a multipart/mixed RFC 5322 message: a
// text/plain body part and a base64-encoded application/pdf attachment.
func buildReportEmail(from string, to []string, subject, body, filename string, attachment []byte) []byte {
// Defense-in-depth: strip CR/LF from the subject so a value flowing into
// the Subject header can never inject additional headers (CWE-93). Report
// titles are fixed today, but this future-proofs the header.
subject = stripCRLF(subject)
filename = stripCRLF(filename)
var parts bytes.Buffer
w := multipart.NewWriter(&parts)

Expand Down Expand Up @@ -85,6 +90,12 @@ func buildReportEmail(from string, to []string, subject, body, filename string,
return msg.Bytes()
}

// stripCRLF removes carriage returns and newlines so a value cannot inject
// extra MIME/RFC-5322 headers.
func stripCRLF(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}

// wrap76 breaks a base64 string into 76-character CRLF-terminated lines
// (RFC 2045).
func wrap76(s string) string {
Expand Down
18 changes: 9 additions & 9 deletions internal/report/types.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// Package report implements the Reports library: point-in-time,
// immutable compliance artifacts. The MVP generates exactly one kind,
// the Fleet Compliance Executive Summary. Generating it computes a
// posture snapshot from data that already exists (host_rule_state
// pass/fail counts + critical, host count, top failing rules) and
// stores it as a JSON document; the row is then never recomputed.
// immutable, Ed25519-signed compliance artifacts. It generates four kinds
// (executive summary, framework attestation, exception register,
// remediation activity), each computed once from data that already exists
// and stored as a frozen JSON document; the row is never recomputed. Each
// kind renders to downloadable faces (PDF / CSV / OSCAL SAR / JSON).
//
// DEFERRED (not built here, see the migration + spec excludes): Ed25519
// signing, PDF/OSCAL rendering, the Scheduled dispatcher, the Templates
// gallery, retention sweeps.
// DEFERRED: the Templates gallery and retention sweeps (see the spec
// excludes). Recurring generation + email delivery lives in
// internal/reportschedule.
//
// Spec: api-reports v1.0.0.
// Spec: api-reports.
package report

import (
Expand Down
18 changes: 9 additions & 9 deletions internal/reportschedule/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,26 @@ func NewDispatcher(svc *Service, gen Generator, deliver Deliverer) *Dispatcher {
return &Dispatcher{svc: svc, gen: gen, deliver: deliver}
}

// Tick processes every due schedule once. It is the cron TickFunc; an error
// from one schedule is recorded on that schedule (last_status) and does not
// abort the others. The return error is non-nil only on a query failure.
// Tick claims + processes every due schedule once. It is the cron TickFunc.
// ClaimDue atomically reserves the due schedules (FOR UPDATE SKIP LOCKED +
// advance), so concurrent dispatchers never double-send. An error from one
// schedule is recorded on that schedule (last_status) and does not abort the
// others. The return error is non-nil only on a claim failure.
func (d *Dispatcher) Tick(ctx context.Context) error {
now := time.Now().UTC()
due, err := d.svc.Due(ctx, now)
claimed, err := d.svc.ClaimDue(ctx, now)
if err != nil {
return err
}
for _, sch := range due {
for _, sch := range claimed {
status := "ok"
if rerr := d.run(ctx, sch); rerr != nil {
status = "failed: " + rerr.Error()
slog.WarnContext(ctx, "report schedule run failed",
slog.String("schedule_id", sch.ID.String()), slog.String("error", rerr.Error()))
}
// Advance from now so a slow/failed run does not immediately re-fire.
next := ComputeNextRun(sch.Frequency, sch.Hour, sch.Weekday, sch.DayOfMonth, time.Now().UTC())
if merr := d.svc.MarkRun(ctx, sch.ID, next, status); merr != nil {
slog.WarnContext(ctx, "report schedule mark-run failed",
if merr := d.svc.MarkResult(ctx, sch.ID, status); merr != nil {
slog.WarnContext(ctx, "report schedule mark-result failed",
slog.String("schedule_id", sch.ID.String()), slog.String("error", merr.Error()))
}
}
Expand Down
44 changes: 44 additions & 0 deletions internal/reportschedule/schedule_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,50 @@ func seedChannel(t *testing.T, pool *pgxpool.Pool) uuid.UUID {
return id
}

// @ac AC-05
// ClaimDue atomically advances next_run_at under FOR UPDATE SKIP LOCKED, so
// a second claim at the same instant returns nothing - the property that
// stops two concurrent dispatchers from double-sending a report.
func TestClaimDue_NoDoubleClaim(t *testing.T) {
t.Run("system-report-schedule/AC-05", func(t *testing.T) {
pool := freshPool(t)
ctx := context.Background()
svc := NewService(pool)
ch := seedChannel(t, pool)
sch, err := svc.Create(ctx, CreateParams{
Name: "daily", Kind: "executive", Frequency: Daily, Hour: 6, ChannelID: ch,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
// Make it due.
if _, err := pool.Exec(ctx,
`UPDATE report_schedules SET next_run_at = now() - interval '1 minute' WHERE id = $1`, sch.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
now := time.Now().UTC()

first, err := svc.ClaimDue(ctx, now)
if err != nil {
t.Fatalf("first ClaimDue: %v", err)
}
if len(first) != 1 {
t.Fatalf("first claim = %d schedules, want 1", len(first))
}
if !first[0].NextRunAt.After(now) {
t.Errorf("claim did not advance next_run_at: %v", first[0].NextRunAt)
}
// A second claim at the same instant must see nothing (already advanced).
second, err := svc.ClaimDue(ctx, now)
if err != nil {
t.Fatalf("second ClaimDue: %v", err)
}
if len(second) != 0 {
t.Errorf("second claim = %d schedules, want 0 (no double-claim)", len(second))
}
})
}

// @ac AC-02
func TestDispatcher_RunsDueSchedule(t *testing.T) {
t.Run("system-report-schedule/AC-02", func(t *testing.T) {
Expand Down
66 changes: 61 additions & 5 deletions internal/reportschedule/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,69 @@ func (s *Service) Due(ctx context.Context, now time.Time) ([]Schedule, error) {
return out, rows.Err()
}

// MarkRun records a run outcome and advances next_run_at.
func (s *Service) MarkRun(ctx context.Context, id uuid.UUID, next time.Time, status string) error {
// ClaimDue atomically claims the due schedules: in ONE transaction it locks
// them with FOR UPDATE SKIP LOCKED and advances next_run_at to the next
// occurrence. A concurrent dispatcher (a second serve process) therefore
// sees a DISJOINT set and never re-claims the same schedule, so a report is
// never double-generated or double-emailed. The claimed schedules are
// returned for processing; record the per-run outcome with MarkResult.
// next_run advances at claim time, so a crash mid-run simply skips that run
// rather than re-firing it every tick.
func (s *Service) ClaimDue(ctx context.Context, now time.Time) ([]Schedule, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("reportschedule: claim begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()

rows, err := tx.Query(ctx,
`SELECT `+scheduleCols+` FROM report_schedules
WHERE enabled AND next_run_at <= $1
ORDER BY next_run_at
FOR UPDATE SKIP LOCKED`, now)
if err != nil {
return nil, fmt.Errorf("reportschedule: claim select: %w", err)
}
var claimed []Schedule
for rows.Next() {
sch, serr := scanSchedule(rows)
if serr != nil {
rows.Close()
return nil, serr
}
claimed = append(claimed, sch)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}

// Advance next_run_at within the same locked transaction so the claim is
// effective the moment we commit.
for i := range claimed {
next := ComputeNextRun(claimed[i].Frequency, claimed[i].Hour,
claimed[i].Weekday, claimed[i].DayOfMonth, now)
if _, err := tx.Exec(ctx,
`UPDATE report_schedules SET next_run_at = $2, updated_at = now() WHERE id = $1`,
claimed[i].ID, next); err != nil {
return nil, fmt.Errorf("reportschedule: claim advance: %w", err)
}
claimed[i].NextRunAt = next
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("reportschedule: claim commit: %w", err)
}
return claimed, nil
}

// MarkResult records the outcome of a claimed run (last_run_at + last_status).
// next_run_at was already advanced by ClaimDue.
func (s *Service) MarkResult(ctx context.Context, id uuid.UUID, status string) error {
_, err := s.pool.Exec(ctx,
`UPDATE report_schedules SET last_run_at = now(), last_status = $2, next_run_at = $3, updated_at = now() WHERE id = $1`,
id, status, next)
`UPDATE report_schedules SET last_run_at = now(), last_status = $2, updated_at = now() WHERE id = $1`,
id, status)
if err != nil {
return fmt.Errorf("reportschedule: mark run: %w", err)
return fmt.Errorf("reportschedule: mark result: %w", err)
}
return nil
}
Expand Down
7 changes: 7 additions & 0 deletions internal/server/api_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ func freshAPIServer(t *testing.T) (string, *pgxpool.Pool) {
_, _ = pool.Exec(ctx, "TRUNCATE TABLE posture_snapshots")
_, _ = pool.Exec(ctx, "TRUNCATE TABLE compliance_exceptions")
_, _ = pool.Exec(ctx, "TRUNCATE TABLE job_queue")
// Reports: schedules FK notification_channels; report_faces FK
// report_snapshots. CASCADE so a leftover schedule/snapshot/channel from a
// prior test cannot leak (a stale channel encrypted with a prior ephemeral
// key would break notification decrypt in a later test).
_, _ = pool.Exec(ctx, "TRUNCATE TABLE report_schedules CASCADE")
_, _ = pool.Exec(ctx, "TRUNCATE TABLE report_snapshots CASCADE")
_, _ = pool.Exec(ctx, "TRUNCATE TABLE notification_channels CASCADE")
// SSO config + federation links (cascades to identities + auth states).
_, _ = pool.Exec(ctx, "TRUNCATE TABLE sso_providers CASCADE")
// TRUNCATE…CASCADE delegates child cleanup to the schema — the
Expand Down
Loading
Loading