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
9 changes: 9 additions & 0 deletions internal/license/features.gen.go

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

5 changes: 3 additions & 2 deletions internal/license/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,15 @@ func TestCodegen_FeatureConstants(t *testing.T) {
SsoSaml,
Fido2Mfa,
PremiumDiagnostics,
ComplianceAttestation,
}
for _, f := range mustExist {
if _, ok := FeatureRegistry[f]; !ok {
t.Errorf("FeatureRegistry missing %q", f)
}
}
if len(FeatureRegistry) != 11 {
t.Errorf("FeatureRegistry size = %d, want 11", len(FeatureRegistry))
if len(FeatureRegistry) != 12 {
t.Errorf("FeatureRegistry size = %d, want 12", len(FeatureRegistry))
}
})
}
Expand Down
18 changes: 18 additions & 0 deletions internal/license/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,21 @@ func setKeyring(ring *publicKeyRing) {
func activeKeyring() *publicKeyRing {
return keyring.Load()
}

// EnableFeatureForTesting turns f on in the active state and returns a restore
// func. It exists because gating a capability broke tests that use that
// capability as a fixture but are not testing licensing: the report-schedule
// suites both use the `attestation` kind because it has the richest content,
// so they must run as a licensed deployment would.
//
// Do NOT use it to paper over a gate. A test asserting that an UNLICENSED
// caller is refused must not call this; that is the case the gate exists for.
func EnableFeatureForTesting(f Feature) (restore func()) {
prev := current.Load()
lic := &License{Features: []Feature{f}}
if prev != nil && prev.License != nil {
lic.Features = append(append([]Feature{}, prev.License.Features...), f)
}
setState(&State{License: lic, LoadedAt: time.Now()})
return func() { current.Store(prev) }
}
14 changes: 14 additions & 0 deletions internal/reportschedule/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/google/uuid"

"github.com/Hanalyx/openwatch/internal/license"
"github.com/Hanalyx/openwatch/internal/report"
)

Expand Down Expand Up @@ -65,7 +66,20 @@ func (d *Dispatcher) Tick(ctx context.Context) error {
}

// run generates the scheduled report, renders its PDF face, and emails it.
//
// The licence is checked HERE, at fire time, not only when the schedule was
// created. A schedule created while licensed would otherwise keep minting the
// paid attestation artifact on a timer after the licence lapsed, which would
// make the gate on the HTTP paths theatre. Skipping is deliberate rather than
// failing: an unlicensed attestation schedule is inert, not broken, and the
// operator sees it in the log.
func (d *Dispatcher) run(ctx context.Context, sch Schedule) error {
if report.Kind(sch.Kind) == report.KindAttestation && !license.IsEnabled(license.ComplianceAttestation) {
slog.WarnContext(ctx, "skipping scheduled attestation report: not licensed",
slog.String("schedule_id", sch.ID.String()),
slog.String("feature", string(license.ComplianceAttestation)))
return nil
}
req := report.GenerateRequest{
Kind: report.Kind(sch.Kind),
GroupID: sch.Scope.GroupID,
Expand Down
79 changes: 79 additions & 0 deletions internal/reportschedule/dispatcher_license_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @spec api-reports
//
// AC-25 TestDispatcher_SkipsUnlicensedAttestation
package reportschedule

import (
"context"
"testing"

"github.com/Hanalyx/openwatch/internal/license"
"github.com/Hanalyx/openwatch/internal/report"
"github.com/google/uuid"
)

// countingGen records whether Generate was reached.
type countingGen struct{ generated int }

func (g *countingGen) Generate(_ context.Context, _ string, _ report.GenerateRequest) (report.Report, error) {
g.generated++
return report.Report{ID: uuid.New()}, nil
}
func (g *countingGen) Export(_ context.Context, _ uuid.UUID, _ string) ([]byte, string, error) {
return []byte("pdf"), "application/pdf", nil
}

// noopDeliver stands in for the email path, which this AC does not exercise.
type noopDeliver struct{}

func (noopDeliver) SendReportEmail(_ context.Context, _ uuid.UUID, _, _, _ string, _ []byte) error {
return nil
}

// @ac AC-25
// AC-25 (behavioural, fire-time half): an attestation schedule does not mint
// the paid artifact when the licence is absent, and DOES when it is present.
//
// The source-inspection AC proves the check is written. This proves it works,
// which matters because the fire-time gate is the one an operator cannot see:
// a schedule created while licensed would otherwise keep producing the artifact
// on a timer forever after the licence lapsed, with no request to refuse.
func TestDispatcher_SkipsUnlicensedAttestation(t *testing.T) {
t.Run("api-reports/AC-25", func(t *testing.T) {
if err := license.Init(); err != nil {
t.Fatalf("license init: %v", err)
}
sch := Schedule{ID: uuid.New(), Kind: "attestation"}

// Unlicensed: skipped, and skipping is not an error. An unlicensed
// schedule is inert, not broken.
gen := &countingGen{}
d := &Dispatcher{gen: gen, deliver: noopDeliver{}}
if err := d.run(context.Background(), sch); err != nil {
t.Fatalf("unlicensed run should skip cleanly, got %v", err)
}
if gen.generated != 0 {
t.Errorf("unlicensed attestation must NOT be generated; Generate called %d time(s)", gen.generated)
}

// The free kind is unaffected by the gate: it must REACH Generate.
// Delivery is stubbed; only the gate decision matters here.
gen2 := &countingGen{}
d2 := &Dispatcher{gen: gen2, deliver: noopDeliver{}}
_ = d2.run(context.Background(), Schedule{ID: uuid.New(), Kind: "executive"})
if gen2.generated != 1 {
t.Errorf("executive (free) must reach Generate; called %d time(s)", gen2.generated)
}

// And a LICENSED attestation must reach Generate too, so the gate is
// proven to be the licence and not the kind alone.
restore := license.EnableFeatureForTesting(license.ComplianceAttestation)
defer restore()
gen3 := &countingGen{}
d3 := &Dispatcher{gen: gen3, deliver: noopDeliver{}}
_ = d3.run(context.Background(), sch)
if gen3.generated != 1 {
t.Errorf("licensed attestation must reach Generate; called %d time(s)", gen3.generated)
}
})
}
9 changes: 9 additions & 0 deletions internal/reportschedule/schedule_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"

"github.com/Hanalyx/openwatch/internal/db/dbtest"
"github.com/Hanalyx/openwatch/internal/license"
"github.com/Hanalyx/openwatch/internal/report"
)

Expand Down Expand Up @@ -161,7 +162,15 @@ func TestClaimDue_NoDoubleClaim(t *testing.T) {
// @ac AC-02
func TestDispatcher_RunsDueSchedule(t *testing.T) {
t.Run("system-report-schedule/AC-02", func(t *testing.T) {
// This suite uses the `attestation` kind as its fixture because it has
// the richest content, and that kind is licensed
// (compliance_attestation). It is testing schedule mechanics, not
// licensing, so it runs as a licensed deployment would. The refusal
// path has its own test below.
pool := freshPool(t)
// Enable AFTER the fixture setup: helpers that call license.Init()
// reset state and would wipe an earlier enablement.
defer license.EnableFeatureForTesting(license.ComplianceAttestation)()
ctx := context.Background()
svc := NewService(pool)
ch := seedChannel(t, pool)
Expand Down
6 changes: 6 additions & 0 deletions internal/server/api_report_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/google/uuid"

"github.com/Hanalyx/openwatch/internal/auth"
"github.com/Hanalyx/openwatch/internal/license"
)

// @spec system-report-schedule
Expand All @@ -20,6 +21,11 @@ import (
func TestAPI_ReportSchedules(t *testing.T) {
t.Run("system-report-schedule/AC-04", func(t *testing.T) {
url, pool := freshAPIServer(t)
// The `attestation` fixture is a licensed kind (compliance_attestation);
// this suite tests schedule mechanics, not licensing. Enable AFTER
// freshAPIServer: it calls license.Init(), which resets state and would
// wipe an earlier enablement.
defer license.EnableFeatureForTesting(license.ComplianceAttestation)()

// A real email channel for the schedule's FK + delivery transport.
chID := uuid.New()
Expand Down
77 changes: 77 additions & 0 deletions internal/server/api_reports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"os"
"strings"
"testing"

"github.com/Hanalyx/openwatch/internal/auth"
Expand Down Expand Up @@ -121,3 +123,78 @@ func TestAPI_ReportFrameworks(t *testing.T) {
}
})
}

// @ac AC-25
// AC-25: the auditor artifact is licensed; seeing your own posture is not.
//
// Source inspection, because the gate spans four call sites and the failure
// mode is one of them being missed: generating an attestation, exporting a
// stored attestation, scheduling one, and the dispatcher firing one. A gate on
// generation alone would leave every already-stored attestation freely
// exportable, and a gate on the HTTP paths alone would let a schedule created
// while licensed keep minting the artifact after the licence lapsed.
func TestReports_AttestationIsLicensed(t *testing.T) {
t.Run("api-reports/AC-25", func(t *testing.T) {
read := func(rel string) string {
raw, err := os.ReadFile(rel)
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
return string(raw)
}
handlers := read("report_handlers.go")
schedules := read("report_schedule_handlers.go")
dispatcher := read("../reportschedule/dispatcher.go")

// The gate exists and keys on the feature, not on a string literal.
if !strings.Contains(handlers, "license.ComplianceAttestation") {
t.Error("report handlers must gate on license.ComplianceAttestation")
}
if !strings.Contains(handlers, "func enforceAttestationLicense") {
t.Error("expected a single named gate helper so the four call sites cannot drift")
}

// It keys on KIND, not scope. Scope is the wrong axis: an absent body
// means all hosts, so a scope gate would gate the free executive
// summary.
if !strings.Contains(handlers, "kind != report.KindAttestation") {
t.Error("the gate must key on the attestation KIND, not on scope")
}

// All four call sites.
if !strings.Contains(handlers, "enforceAttestationLicense(w, r, req.Kind)") {
t.Error("generation must be gated")
}
if !strings.Contains(handlers, "enforceAttestationLicense(w, r, rep.Kind)") {
t.Error("export must be gated on the STORED report's kind")
}
if !strings.Contains(schedules, "enforceAttestationLicense(w, r, report.Kind(body.Kind))") {
t.Error("schedule creation must be gated")
}
if !strings.Contains(dispatcher, "license.IsEnabled(license.ComplianceAttestation)") {
t.Error("the dispatcher must re-check the licence at fire time")
}

// What must stay free. These are the routes a Community user needs to
// see and prove their own compliance state.
spec := read("../../api/openapi.yaml")
for _, free := range []string{
"/api/v1/scans/{id}/oscal:",
"/api/v1/reports/signing-key:",
} {
i := strings.Index(spec, free)
if i < 0 {
t.Errorf("expected free route %s to exist", free)
continue
}
// No x-required-feature within the route's own block.
block := spec[i:]
if j := strings.Index(block[1:], "\n /api/v1/"); j >= 0 {
block = block[:j]
}
if strings.Contains(block, "x-required-feature") {
t.Errorf("%s must stay free (no x-required-feature)", free)
}
}
})
}
41 changes: 41 additions & 0 deletions internal/server/report_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/Hanalyx/openwatch/internal/audit"
"github.com/Hanalyx/openwatch/internal/auth"
"github.com/Hanalyx/openwatch/internal/group"
"github.com/Hanalyx/openwatch/internal/license"
"github.com/Hanalyx/openwatch/internal/report"
"github.com/Hanalyx/openwatch/internal/server/api"
)
Expand Down Expand Up @@ -216,6 +217,10 @@ func (h *handlers) PostReportGenerate(w http.ResponseWriter, r *http.Request) {
req.PeriodDays = *body.PeriodDays
}

if enforceAttestationLicense(w, r, req.Kind) {
return
}

rep, err := h.reportSvc.Generate(r.Context(), reportActor(r), req)
if errors.Is(err, report.ErrInvalidKind) {
writeError(w, http.StatusBadRequest, "reports.invalid_kind", "client",
Expand Down Expand Up @@ -274,6 +279,18 @@ func (h *handlers) GetReportExport(w http.ResponseWriter, r *http.Request, id op
face = string(*params.Format)
}

// Gate on the STORED report's kind, not on anything the caller sends.
// Gating only generation would leave every attestation report already in
// the database freely exportable, and the OSCAL SAR face is exactly the
// artifact being monetized. A report that cannot be read is treated as
// not found by the export call below, so a lookup failure here is not a
// reason to open the gate.
if rep, rerr := h.reportSvc.Get(r.Context(), id); rerr == nil {
if enforceAttestationLicense(w, r, rep.Kind) {
return
}
}

body, mediaType, err := h.reportSvc.Export(r.Context(), id, face)
if errors.Is(err, report.ErrNotFound) {
writeError(w, http.StatusNotFound, "reports.not_found", "client", "report not found", false)
Expand Down Expand Up @@ -329,3 +346,27 @@ func (h *handlers) GetReportByID(w http.ResponseWriter, r *http.Request, id open
}
writeJSON(w, http.StatusOK, out)
}

// enforceAttestationLicense gates the auditor-facing compliance artifact.
//
// OpenWatch monetizes the evidence a compliance officer files, not the ability
// to see your own posture. Everything that lets a Community user observe and
// prove compliance stays free: the `executive` report kind at any scope, every
// per-scan and per-host OSCAL export, the signing key, and report listing.
// What is paid is the fleet-wide signed attestation package.
//
// The line is the report KIND, not the scope. Scope was the obvious axis and
// it is wrong: an empty request body means all hosts and all frameworks, so a
// scope-based gate would gate the default executive summary, which is the
// everyday free artifact. Kind is also exact, because report.FaceOSCALSAR is
// attestation-only (exportFleetOSCALSAR), so gating this one kind gates the
// fleet OSCAL SAR and nothing else.
//
// Returns true when the caller was denied and a 402 has been written.
// Spec api-reports C-09 / AC-14.
func enforceAttestationLicense(w http.ResponseWriter, r *http.Request, kind report.Kind) bool {
if kind != report.KindAttestation {
return false
}
return license.EnforceFeature(w, r, license.ComplianceAttestation)
}
9 changes: 9 additions & 0 deletions internal/server/report_schedule_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/Hanalyx/openwatch/internal/audit"
"github.com/Hanalyx/openwatch/internal/auth"
"github.com/Hanalyx/openwatch/internal/notification"
"github.com/Hanalyx/openwatch/internal/report"
"github.com/Hanalyx/openwatch/internal/reportschedule"
"github.com/Hanalyx/openwatch/internal/server/api"
)
Expand Down Expand Up @@ -117,6 +118,14 @@ func (h *handlers) CreateReportSchedule(w http.ResponseWriter, r *http.Request)
return
}

// A scheduled attestation would otherwise mint the paid artifact on a
// timer without ever touching the gated generate endpoint. Gate it here
// too. Update cannot change the kind, so it needs no gate; the dispatcher
// re-checks at fire time so a lapsed licence stops the timer.
if enforceAttestationLicense(w, r, report.Kind(body.Kind)) {
return
}

p := reportschedule.CreateParams{
Name: body.Name,
Kind: string(body.Kind),
Expand Down
Loading
Loading