diff --git a/internal/license/features.gen.go b/internal/license/features.gen.go index 7ab7dece..e1164bfb 100644 --- a/internal/license/features.gen.go +++ b/internal/license/features.gen.go @@ -33,6 +33,8 @@ const ( StructuredExceptions Feature = "structured_exceptions" // Early access to Kensa rule updates and framework mappings PriorityUpdates Feature = "priority_updates" + // Fleet-wide signed attestation reports and the fleet OSCAL SAR export + ComplianceAttestation Feature = "compliance_attestation" // SAML 2.0 single sign-on integration SsoSaml Feature = "sso_saml" // FIDO2 / WebAuthn second factor for user authentication @@ -99,6 +101,12 @@ var FeatureRegistry = map[Feature]FeatureMeta{ Description: `Early access to Kensa rule updates and framework mappings`, Introduced: "1.0.0", }, + ComplianceAttestation: { + ID: ComplianceAttestation, + Tier: TierEnterprise, + Description: `Fleet-wide signed attestation reports and the fleet OSCAL SAR export`, + Introduced: "1.0.0", + }, SsoSaml: { ID: SsoSaml, Tier: TierFree, @@ -135,6 +143,7 @@ var featureOrder = []Feature{ RemediationAuto, StructuredExceptions, PriorityUpdates, + ComplianceAttestation, SsoSaml, Fido2Mfa, PremiumDiagnostics, diff --git a/internal/license/features_test.go b/internal/license/features_test.go index 085e9c9e..5defa6ad 100644 --- a/internal/license/features_test.go +++ b/internal/license/features_test.go @@ -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)) } }) } diff --git a/internal/license/state.go b/internal/license/state.go index 8b41e16b..5a477923 100644 --- a/internal/license/state.go +++ b/internal/license/state.go @@ -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) } +} diff --git a/internal/reportschedule/dispatcher.go b/internal/reportschedule/dispatcher.go index ccf6ce8d..e2a7b4f5 100644 --- a/internal/reportschedule/dispatcher.go +++ b/internal/reportschedule/dispatcher.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + "github.com/Hanalyx/openwatch/internal/license" "github.com/Hanalyx/openwatch/internal/report" ) @@ -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, diff --git a/internal/reportschedule/dispatcher_license_test.go b/internal/reportschedule/dispatcher_license_test.go new file mode 100644 index 00000000..859c961d --- /dev/null +++ b/internal/reportschedule/dispatcher_license_test.go @@ -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) + } + }) +} diff --git a/internal/reportschedule/schedule_db_test.go b/internal/reportschedule/schedule_db_test.go index 13efbd71..02dd9c6c 100644 --- a/internal/reportschedule/schedule_db_test.go +++ b/internal/reportschedule/schedule_db_test.go @@ -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" ) @@ -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) diff --git a/internal/server/api_report_schedule_test.go b/internal/server/api_report_schedule_test.go index afec6434..bd3ee0bd 100644 --- a/internal/server/api_report_schedule_test.go +++ b/internal/server/api_report_schedule_test.go @@ -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 @@ -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() diff --git a/internal/server/api_reports_test.go b/internal/server/api_reports_test.go index 28621ac2..a242c0af 100644 --- a/internal/server/api_reports_test.go +++ b/internal/server/api_reports_test.go @@ -11,6 +11,8 @@ import ( "encoding/base64" "encoding/json" "net/http" + "os" + "strings" "testing" "github.com/Hanalyx/openwatch/internal/auth" @@ -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) + } + } + }) +} diff --git a/internal/server/report_handlers.go b/internal/server/report_handlers.go index 322a8e61..33c4e19f 100644 --- a/internal/server/report_handlers.go +++ b/internal/server/report_handlers.go @@ -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" ) @@ -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", @@ -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) @@ -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) +} diff --git a/internal/server/report_schedule_handlers.go b/internal/server/report_schedule_handlers.go index 93b93eda..3449dbff 100644 --- a/internal/server/report_schedule_handlers.go +++ b/internal/server/report_schedule_handlers.go @@ -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" ) @@ -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), diff --git a/licensing/features.yaml b/licensing/features.yaml index a71371c5..1268d664 100644 --- a/licensing/features.yaml +++ b/licensing/features.yaml @@ -76,6 +76,18 @@ features: # paywall. Both ids stay in the registry (rather than being deleted) so that # any already-minted license naming them still resolves, and so a future # managed/hosted SSO service has an id to hang off. + # The auditor-facing compliance artifact. OpenWatch monetizes the evidence a + # compliance officer files, not the ability to see your own posture. The + # `executive` report kind, every per-scan and per-host OSCAL export, the + # signing key, and report listing all stay FREE: a Community user can see + # their compliance state and prove it per scan. What is paid is the + # fleet-wide signed attestation package (report kind `attestation`, which is + # the only producer of the fleet OSCAL SAR face). + - id: compliance_attestation + tier: enterprise + description: Fleet-wide signed attestation reports and the fleet OSCAL SAR export + introduced: "1.0.0" + - id: sso_saml tier: free description: SAML 2.0 single sign-on integration diff --git a/specs/api/reports.spec.yaml b/specs/api/reports.spec.yaml index df808857..621ecc3f 100644 --- a/specs/api/reports.spec.yaml +++ b/specs/api/reports.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-reports title: Reports library - point-in-time Fleet Compliance Executive Summary artifacts (list / generate / fetch) - version: "1.14.0" + version: "1.15.0" status: approved tier: 2 @@ -404,6 +404,18 @@ spec: type: technical enforcement: error + - id: C-19 + description: > + Report tiering gates on the report KIND, never on scope. An absent + scope body means all hosts and all frameworks, so a scope-based gate + would gate the default executive summary, which is the everyday free + artifact. Only kind `attestation` is licensed + (`compliance_attestation`); it is the sole producer of the fleet OSCAL + SAR face. The gate is enforced at generation, at export (on the stored + kind), at schedule creation, and at scheduled fire time. + type: security + enforcement: error + acceptance_criteria: - id: AC-01 description: >- @@ -666,3 +678,21 @@ spec: oscal_sar is ErrInvalidFace for the remediation kind. priority: high references_constraints: [C-18] + - id: AC-25 + description: > + The auditor-facing compliance artifact is licensed; observing your own + posture is not. Generating a report of kind `attestation` requires the + `compliance_attestation` feature and returns 402 without it. Exporting + an existing attestation report is gated on the STORED report's kind, so + a report generated while licensed does not stay exportable afterwards; + this matters because report.FaceOSCALSAR is attestation-only, making + that face the exact artifact being monetized. Creating a report + schedule of kind `attestation` is gated, and the dispatcher re-checks + the licence at FIRE time and skips rather than generating, so a lapsed + licence stops the timer instead of minting the paid artifact + indefinitely. Everything else stays free at every scope: the + `executive` kind, per-scan and per-host OSCAL + (`/scans/{id}/oscal`, `/scans/{id}/rules/{ruleId}/oscal`), the signing + key, report listing, and report read. + priority: critical + references_constraints: [C-19]