diff --git a/.secrets.baseline b/.secrets.baseline index 63351755..f62b97f5 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -469,14 +469,14 @@ "filename": "internal/server/api/server.gen.go", "hashed_secret": "9fd0aaae1a3d0bc789d081307161ea9a821f9dee", "is_verified": false, - "line_number": 3999 + "line_number": 4089 }, { "type": "Secret Keyword", "filename": "internal/server/api/server.gen.go", "hashed_secret": "eca525ee60b3564d9633eb140726685271d52341", "is_verified": false, - "line_number": 4137 + "line_number": 4227 } ], "internal/server/api_scans_test.go": [ @@ -674,5 +674,5 @@ } ] }, - "generated_at": "2026-07-28T01:05:36Z" + "generated_at": "2026-07-30T02:56:38Z" } diff --git a/api/openapi.yaml b/api/openapi.yaml index ea5c0e34..af9adc41 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -321,6 +321,30 @@ paths: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/capabilities: + get: + operationId: getCapabilities + summary: What this deployment is entitled to use + description: | + The paywall, made visible. Returns every registered capability with + whether THIS deployment can use it, so the interface can lock or + upsell a control instead of letting a Community user click it and + collect a 402. + + Deliberately unauthenticated, like GET /license: a pre-login screen + needs to know what the deployment offers. It exposes no customer + identity and no license key material, only capability ids, their tier, + and whether each is available here. + + There are no quotas. Tiering is by capability alone: OpenWatch does not + cap hosts, scans, or users. + responses: + '200': + description: Capability set for this deployment + content: + application/json: + schema: {$ref: '#/components/schemas/CapabilitiesResponse'} + /api/v1/license: get: operationId: getLicense @@ -2680,6 +2704,10 @@ paths: /api/v1/reports:generate: post: operationId: postReportGenerate + # Gated on the REQUEST, not the route: only report kind `attestation` + # requires the entitlement. The rest of this route is free, so a + # route-level x-required-feature would be a false claim. + x-conditional-feature: compliance_attestation summary: Generate a Fleet Compliance Executive Summary description: | Computes a point-in-time posture snapshot from current data @@ -2767,6 +2795,10 @@ paths: schema: {$ref: '#/components/schemas/ErrorEnvelope'} post: operationId: createReportSchedule + # Gated on the REQUEST, not the route: only report kind `attestation` + # requires the entitlement. The rest of this route is free, so a + # route-level x-required-feature would be a false claim. + x-conditional-feature: compliance_attestation summary: Create a report delivery schedule description: | Schedules a report to generate on a daily/weekly/monthly cadence and @@ -2934,6 +2966,10 @@ paths: /api/v1/reports/{id}/export: get: operationId: getReportExport + # Gated on the REQUEST, not the route: only report kind `attestation` + # requires the entitlement. The rest of this route is free, so a + # route-level x-required-feature would be a false claim. + x-conditional-feature: compliance_attestation summary: Download a rendered face of a report (PDF, JSON, CSV, or OSCAL SAR) description: | Streams a rendered face of the report as a downloadable @@ -5309,6 +5345,25 @@ components: correlation_id: {type: string} audit_event_id: {type: string, format: uuid} + CapabilitiesResponse: + type: object + required: [tier, status, capabilities] + properties: + tier: {type: string, enum: [free, enterprise]} + status: {type: string, enum: [active, grace, expired, no_license, invalid]} + capabilities: + type: array + items: {$ref: '#/components/schemas/Capability'} + + Capability: + type: object + required: [id, tier, available, description] + properties: + id: {type: string, description: 'Registry id, e.g. remediation_execution'} + tier: {type: string, enum: [free, enterprise]} + available: {type: boolean, description: 'Whether THIS deployment may use it now'} + description: {type: string} + LicenseStateResponse: type: object required: [tier, status, features] diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 560b1d69..321d1f4a 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -222,6 +222,37 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * What this deployment is entitled to use + * @description The paywall, made visible. Returns every registered capability with + * whether THIS deployment can use it, so the interface can lock or + * upsell a control instead of letting a Community user click it and + * collect a 402. + * + * Deliberately unauthenticated, like GET /license: a pre-login screen + * needs to know what the deployment offers. It exposes no customer + * identity and no license key material, only capability ids, their tier, + * and whether each is available here. + * + * There are no quotas. Tiering is by capability alone: OpenWatch does not + * cap hosts, scans, or users. + */ + get: operations["getCapabilities"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/license": { parameters: { query?: never; @@ -3449,6 +3480,22 @@ export interface components { /** Format: uuid */ audit_event_id?: string; }; + CapabilitiesResponse: { + /** @enum {string} */ + tier: "free" | "enterprise"; + /** @enum {string} */ + status: "active" | "grace" | "expired" | "no_license" | "invalid"; + capabilities: components["schemas"]["Capability"][]; + }; + Capability: { + /** @description Registry id, e.g. remediation_execution */ + id: string; + /** @enum {string} */ + tier: "free" | "enterprise"; + /** @description Whether THIS deployment may use it now */ + available: boolean; + description: string; + }; LicenseStateResponse: { /** @enum {string} */ tier: "free" | "enterprise"; @@ -5035,6 +5082,26 @@ export interface operations { }; }; }; + getCapabilities: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Capability set for this deployment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CapabilitiesResponse"]; + }; + }; + }; + }; getLicense: { parameters: { query?: never; diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 08432cb1..3a641367 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -122,6 +122,69 @@ func (e AlertState) Valid() bool { } } +// Defines values for CapabilitiesResponseStatus. +const ( + CapabilitiesResponseStatusActive CapabilitiesResponseStatus = "active" + CapabilitiesResponseStatusExpired CapabilitiesResponseStatus = "expired" + CapabilitiesResponseStatusGrace CapabilitiesResponseStatus = "grace" + CapabilitiesResponseStatusInvalid CapabilitiesResponseStatus = "invalid" + CapabilitiesResponseStatusNoLicense CapabilitiesResponseStatus = "no_license" +) + +// Valid indicates whether the value is a known member of the CapabilitiesResponseStatus enum. +func (e CapabilitiesResponseStatus) Valid() bool { + switch e { + case CapabilitiesResponseStatusActive: + return true + case CapabilitiesResponseStatusExpired: + return true + case CapabilitiesResponseStatusGrace: + return true + case CapabilitiesResponseStatusInvalid: + return true + case CapabilitiesResponseStatusNoLicense: + return true + default: + return false + } +} + +// Defines values for CapabilitiesResponseTier. +const ( + CapabilitiesResponseTierEnterprise CapabilitiesResponseTier = "enterprise" + CapabilitiesResponseTierFree CapabilitiesResponseTier = "free" +) + +// Valid indicates whether the value is a known member of the CapabilitiesResponseTier enum. +func (e CapabilitiesResponseTier) Valid() bool { + switch e { + case CapabilitiesResponseTierEnterprise: + return true + case CapabilitiesResponseTierFree: + return true + default: + return false + } +} + +// Defines values for CapabilityTier. +const ( + CapabilityTierEnterprise CapabilityTier = "enterprise" + CapabilityTierFree CapabilityTier = "free" +) + +// Valid indicates whether the value is a known member of the CapabilityTier enum. +func (e CapabilityTier) Valid() bool { + switch e { + case CapabilityTierEnterprise: + return true + case CapabilityTierFree: + return true + default: + return false + } +} + // Defines values for ConnectivityCheckResultNewReachabilityStatus. const ( ConnectivityCheckResultNewReachabilityStatusReachable ConnectivityCheckResultNewReachabilityStatus = "reachable" @@ -1414,25 +1477,25 @@ func (e GetAuditEventsExportParamsFormat) Valid() bool { // Defines values for GetComplianceExceptionsParamsStatus. const ( - Approved GetComplianceExceptionsParamsStatus = "approved" - Expired GetComplianceExceptionsParamsStatus = "expired" - Rejected GetComplianceExceptionsParamsStatus = "rejected" - Requested GetComplianceExceptionsParamsStatus = "requested" - Revoked GetComplianceExceptionsParamsStatus = "revoked" + GetComplianceExceptionsParamsStatusApproved GetComplianceExceptionsParamsStatus = "approved" + GetComplianceExceptionsParamsStatusExpired GetComplianceExceptionsParamsStatus = "expired" + GetComplianceExceptionsParamsStatusRejected GetComplianceExceptionsParamsStatus = "rejected" + GetComplianceExceptionsParamsStatusRequested GetComplianceExceptionsParamsStatus = "requested" + GetComplianceExceptionsParamsStatusRevoked GetComplianceExceptionsParamsStatus = "revoked" ) // Valid indicates whether the value is a known member of the GetComplianceExceptionsParamsStatus enum. func (e GetComplianceExceptionsParamsStatus) Valid() bool { switch e { - case Approved: + case GetComplianceExceptionsParamsStatusApproved: return true - case Expired: + case GetComplianceExceptionsParamsStatusExpired: return true - case Rejected: + case GetComplianceExceptionsParamsStatusRejected: return true - case Requested: + case GetComplianceExceptionsParamsStatusRequested: return true - case Revoked: + case GetComplianceExceptionsParamsStatusRevoked: return true default: return false @@ -1778,6 +1841,33 @@ type AuthRefreshRequest struct { RefreshToken string `json:"refresh_token"` } +// CapabilitiesResponse defines model for CapabilitiesResponse. +type CapabilitiesResponse struct { + Capabilities []Capability `json:"capabilities"` + Status CapabilitiesResponseStatus `json:"status"` + Tier CapabilitiesResponseTier `json:"tier"` +} + +// CapabilitiesResponseStatus defines model for CapabilitiesResponse.Status. +type CapabilitiesResponseStatus string + +// CapabilitiesResponseTier defines model for CapabilitiesResponse.Tier. +type CapabilitiesResponseTier string + +// Capability defines model for Capability. +type Capability struct { + // Available Whether THIS deployment may use it now + Available bool `json:"available"` + Description string `json:"description"` + + // Id Registry id, e.g. remediation_execution + Id string `json:"id"` + Tier CapabilityTier `json:"tier"` +} + +// CapabilityTier defines model for Capability.Tier. +type CapabilityTier string + // CategoryEntry defines model for CategoryEntry. type CategoryEntry struct { Description string `json:"description"` @@ -4216,6 +4306,9 @@ type ServerInterface interface { // Begin SSO sign-in — redirects to the IdP authorization endpoint // (GET /api/v1/auth/sso/{id}/login) GetAuthSSOLogin(w http.ResponseWriter, r *http.Request, id openapi_types.UUID, params GetAuthSSOLoginParams) + // What this deployment is entitled to use + // (GET /api/v1/capabilities) + GetCapabilities(w http.ResponseWriter, r *http.Request) // Fleet-wide compliance exception queue // (GET /api/v1/compliance/exceptions) GetComplianceExceptions(w http.ResponseWriter, r *http.Request, params GetComplianceExceptionsParams) @@ -4771,6 +4864,12 @@ func (_ Unimplemented) GetAuthSSOLogin(w http.ResponseWriter, r *http.Request, i w.WriteHeader(http.StatusNotImplemented) } +// What this deployment is entitled to use +// (GET /api/v1/capabilities) +func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Fleet-wide compliance exception queue // (GET /api/v1/compliance/exceptions) func (_ Unimplemented) GetComplianceExceptions(w http.ResponseWriter, r *http.Request, params GetComplianceExceptionsParams) { @@ -6486,6 +6585,20 @@ func (siw *ServerInterfaceWrapper) GetAuthSSOLogin(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// GetCapabilities operation middleware +func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetCapabilities(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetComplianceExceptions operation middleware func (siw *ServerInterfaceWrapper) GetComplianceExceptions(w http.ResponseWriter, r *http.Request) { @@ -10152,6 +10265,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/auth/sso/{id}/login", wrapper.GetAuthSSOLogin) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/capabilities", wrapper.GetCapabilities) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/compliance/exceptions", wrapper.GetComplianceExceptions) }) diff --git a/internal/server/api_capabilities_test.go b/internal/server/api_capabilities_test.go new file mode 100644 index 00000000..18654b5e --- /dev/null +++ b/internal/server/api_capabilities_test.go @@ -0,0 +1,153 @@ +// @spec api-capabilities +// +// AC-01 TestCapabilities_MirrorsTheRegistry +// AC-02 TestCapabilities_AvailabilityFollowsEntitlement +// AC-03 TestCapabilities_DisclosesNoIdentityOrKeyMaterial +// AC-04 TestCapabilities_StableOrder +package server + +import ( + "encoding/json" + "io" + "net/http" + "sort" + "testing" + + "github.com/Hanalyx/openwatch/internal/license" +) + +type capsBody struct { + Tier string `json:"tier"` + Status string `json:"status"` + Capabilities []struct { + ID string `json:"id"` + Tier string `json:"tier"` + Available bool `json:"available"` + Description string `json:"description"` + } `json:"capabilities"` +} + +func getCaps(t *testing.T, url string) (capsBody, map[string]any) { + t.Helper() + req, _ := http.NewRequest("GET", url+"/api/v1/capabilities", nil) + resp := doReq(t, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var raw map[string]any + var typed capsBody + b, rerr := io.ReadAll(resp.Body) + if rerr != nil { + t.Fatalf("read body: %v", rerr) + } + if err := json.Unmarshal(b, &typed); err != nil { + t.Fatalf("decode: %v", err) + } + _ = json.Unmarshal(b, &raw) + return typed, raw +} + +// @ac AC-01 +// AC-01: the response mirrors the registry exactly. A capability cannot be +// reported without being registered, and none may be silently omitted. +func TestCapabilities_MirrorsTheRegistry(t *testing.T) { + t.Run("api-capabilities/AC-01", func(t *testing.T) { + url, _ := freshAPIServer(t) + got, _ := getCaps(t, url) + if len(got.Capabilities) != len(license.FeatureRegistry) { + t.Fatalf("returned %d capabilities, registry has %d", + len(got.Capabilities), len(license.FeatureRegistry)) + } + seen := map[string]bool{} + for _, c := range got.Capabilities { + seen[c.ID] = true + if c.Description == "" { + t.Errorf("%s has no description; the UI has nothing to show", c.ID) + } + } + for id := range license.FeatureRegistry { + if !seen[string(id)] { + t.Errorf("registry capability %q missing from the response", id) + } + } + }) +} + +// @ac AC-02 +// AC-02: availability is the runtime entitlement, not a static label. +func TestCapabilities_AvailabilityFollowsEntitlement(t *testing.T) { + t.Run("api-capabilities/AC-02", func(t *testing.T) { + url, _ := freshAPIServer(t) + + got, _ := getCaps(t, url) + for _, c := range got.Capabilities { + want := c.Tier == "free" + if c.Available != want { + t.Errorf("unlicensed: %s (tier %s) available=%v, want %v", + c.ID, c.Tier, c.Available, want) + } + } + + // Granting one paid capability flips exactly that one. + restore := license.EnableFeatureForTesting(license.ComplianceAttestation) + defer restore() + got2, _ := getCaps(t, url) + for _, c := range got2.Capabilities { + want := c.Tier == "free" || c.ID == string(license.ComplianceAttestation) + if c.Available != want { + t.Errorf("licensed: %s available=%v, want %v", c.ID, c.Available, want) + } + } + }) +} + +// @ac AC-03 +// AC-03 (NEGATIVE PATH): the endpoint is anonymous, so it must disclose +// nothing about WHO holds the license. Assert on the raw JSON rather than the +// typed struct: a typed decode would silently ignore an extra field, which is +// exactly the leak this guards against. +func TestCapabilities_DisclosesNoIdentityOrKeyMaterial(t *testing.T) { + t.Run("api-capabilities/AC-03", func(t *testing.T) { + url, _ := freshAPIServer(t) + _, raw := getCaps(t, url) + + for _, forbidden := range []string{ + "customer_id", "customerId", "fingerprint", + "expires_at", "issued_at", "issuer", "audience", + "license_jwt", "public_key", "signature", + } { + if _, present := raw[forbidden]; present { + t.Errorf("anonymous capabilities response leaks %q", forbidden) + } + } + caps, _ := raw["capabilities"].([]any) + for _, c := range caps { + m, _ := c.(map[string]any) + for k := range m { + switch k { + case "id", "tier", "available", "description": + default: + t.Errorf("unexpected per-capability field %q; keep this response minimal", k) + } + } + } + }) +} + +// @ac AC-04 +// AC-04: stable order, so a client diffing the response does not churn on Go +// map iteration order. +func TestCapabilities_StableOrder(t *testing.T) { + t.Run("api-capabilities/AC-04", func(t *testing.T) { + url, _ := freshAPIServer(t) + got, _ := getCaps(t, url) + ids := make([]string, len(got.Capabilities)) + for i, c := range got.Capabilities { + ids[i] = c.ID + } + if !sort.StringsAreSorted(ids) { + t.Errorf("capabilities are not in stable id order: %v", ids) + } + }) +} diff --git a/internal/server/capabilities_handler.go b/internal/server/capabilities_handler.go new file mode 100644 index 00000000..55f64c70 --- /dev/null +++ b/internal/server/capabilities_handler.go @@ -0,0 +1,58 @@ +// Capability discovery: what THIS deployment may use. +// +// Spec api-capabilities. +package server + +import ( + "net/http" + "sort" + + "github.com/Hanalyx/openwatch/internal/license" + "github.com/Hanalyx/openwatch/internal/server/api" +) + +// GetCapabilities implements api.ServerInterface. +// +// Built entirely from the feature registry plus the runtime entitlement check, +// so a capability cannot appear here without being registered first. That is +// the same registry-first rule the roadmap applies to gating: if it is not in +// licensing/features.yaml, it does not exist. +// +// WHY IT IS UNAUTHENTICATED: the interface needs to know what the deployment +// offers before a user signs in, exactly like GET /license. It discloses +// capability ids, their tier, and whether each is available here. It does not +// disclose customer identity, key material, expiry, or anything about who +// holds the license. GET /license is the authenticated surface for that, and +// its customer_id is now gated on system:read. +// +// There are no quotas in the response because there are no quotas in the +// product: tiering is by capability alone. OpenWatch does not cap hosts, +// scans, or users, so a "78% of your host allowance" indicator would be +// inventing a limit that does not exist. +func (h *handlers) GetCapabilities(w http.ResponseWriter, r *http.Request) { + tier := license.TierFree + status := license.StatusNoLicense + if st := license.CurrentState(); st != nil && st.License != nil { + tier = st.License.Tier + status = st.License.Status + } + + caps := make([]api.Capability, 0, len(license.FeatureRegistry)) + for id, meta := range license.FeatureRegistry { + caps = append(caps, api.Capability{ + Id: string(id), + Tier: api.CapabilityTier(meta.Tier), + Available: license.IsEnabled(id), + Description: meta.Description, + }) + } + // Stable order: a map iteration would make the response non-deterministic + // and churn any client that diffs it. + sort.Slice(caps, func(i, j int) bool { return caps[i].Id < caps[j].Id }) + + writeJSON(w, http.StatusOK, api.CapabilitiesResponse{ + Tier: api.CapabilitiesResponseTier(tier), + Status: api.CapabilitiesResponseStatus(status), + Capabilities: caps, + }) +} diff --git a/internal/server/license_coverage_test.go b/internal/server/license_coverage_test.go new file mode 100644 index 00000000..a587edc3 --- /dev/null +++ b/internal/server/license_coverage_test.go @@ -0,0 +1,184 @@ +// @spec system-license-features +// +// Per-route license enforcement coverage (v0.7 exit criterion 2, epic A2). +// +// AC-14 TestLicenseCoverage_EveryDeclaredFeatureIsRegisteredAndEnforced +// +// WHY THIS EXISTS: the licensing machinery is complete and was wired to +// exactly one demo route. Seven of the paid capabilities have no code yet, so +// today this check passes almost trivially. That is the point. It is not here +// to find current gaps; it is here so that when bulk remediation lands and +// someone forgets the gate, the build fails instead of the capability shipping +// free. The RBAC equivalent (system-rbac AC-18) exists for the same reason and +// found a real contract bug on its first run. +package server + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// featureConstByValue maps a feature id ("audit_export") to its generated Go +// constant name ("AuditExport"), read out of the generated registry rather +// than by reimplementing the generator's naming rule. +func featureConstByValue(t *testing.T) map[string]string { + t.Helper() + src, err := os.ReadFile(filepath.Join("..", "license", "features.gen.go")) + if err != nil { + t.Fatalf("read features.gen.go: %v", err) + } + re := regexp.MustCompile(`(?m)^\s*([A-Z][A-Za-z0-9]*)\s+Feature\s*=\s*"([^"]+)"`) + out := map[string]string{} + for _, m := range re.FindAllStringSubmatch(string(src), -1) { + out[m[2]] = m[1] + } + if len(out) == 0 { + t.Fatal("no Feature constants parsed from features.gen.go") + } + return out +} + +// declaredFeatures returns operationId -> (feature, conditional). +// +// Two annotations, deliberately distinct: +// +// x-required-feature the WHOLE route is paid +// x-conditional-feature the route is paid for SOME requests, decided from +// the body. Reports are the live case: only the +// `attestation` kind is entitled, so declaring the +// route paid outright would be a false claim in the +// public contract. +// +// Collapsing the two would force a choice between lying about a free route and +// leaving a real gate undeclared. The second is what happened when the +// attestation gate shipped: enforced in the handler, invisible in the contract. +func declaredFeatures(t *testing.T) map[string]struct { + Feature string + Conditional bool +} { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "api", "openapi.yaml")) + if err != nil { + t.Fatalf("read openapi.yaml: %v", err) + } + var doc struct { + Paths map[string]map[string]struct { + OperationID string `yaml:"operationId"` + Required string `yaml:"x-required-feature"` + Conditional string `yaml:"x-conditional-feature"` + } `yaml:"paths"` + } + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse openapi.yaml: %v", err) + } + out := map[string]struct { + Feature string + Conditional bool + }{} + for _, item := range doc.Paths { + for method, op := range item { + switch method { + case "get", "post", "put", "patch", "delete": + default: + continue + } + if op.OperationID == "" { + continue + } + switch { + case op.Required != "": + out[op.OperationID] = struct { + Feature string + Conditional bool + }{op.Required, false} + case op.Conditional != "": + out[op.OperationID] = struct { + Feature string + Conditional bool + }{op.Conditional, true} + } + } + } + return out +} + +// @ac AC-14 +// AC-14: a route that declares a license feature names a registered one and +// actually enforces it. A paid route cannot ship ungated. +func TestLicenseCoverage_EveryDeclaredFeatureIsRegisteredAndEnforced(t *testing.T) { + t.Run("system-license-features/AC-14", func(t *testing.T) { + declared := declaredFeatures(t) + constOf := featureConstByValue(t) + src := serverSource(t) + // The conditional gates live outside this package. + extra := "" + for _, rel := range []string{ + filepath.Join("..", "reportschedule", "dispatcher.go"), + } { + if b, err := os.ReadFile(rel); err == nil { + extra = extra + "\n" + string(b) + } + } + all := src + extra + + if len(declared) == 0 { + t.Fatal("no feature declarations found; the contract or this parser is broken") + } + + for opID, d := range declared { + constName, ok := constOf[d.Feature] + if !ok { + t.Errorf("%s declares feature %q which is not in licensing/features.yaml", opID, d.Feature) + continue + } + body := handlerBody(src, opID) + if body == "" { + t.Errorf("no handler found for operationId %q; the contract is stale", opID) + continue + } + // A conditional gate is usually delegated to a named helper, so + // look at the whole package for the constant when the handler body + // does not carry it directly. + if strings.Contains(body, "license."+constName) { + continue + } + // Follow the route's own call graph. Deliberately NOT a + // package-wide search for the constant: the first cut of this + // check did that, and it passed a route whose gate had been + // swapped to a different feature, because the right constant still + // appeared elsewhere in the package. A loose match in a security + // check is worse than none, because it reads as coverage. + if enforces(all, body, "license."+constName, 0) { + continue + } + t.Errorf("route %s declares feature %q but does not enforce license.%s", + opID, d.Feature, constName) + } + + // The reverse direction: a paid feature enforced somewhere in the + // server package must be declared by at least one route, or the public + // contract hides a paywall. This is the gap the attestation gate left. + for value, constName := range constOf { + if !strings.Contains(src, "license."+constName) { + continue + } + found := false + for _, d := range declared { + if d.Feature == value { + found = true + break + } + } + if !found { + t.Errorf("license.%s is enforced in the server package but no route declares %q; "+ + "add x-required-feature (whole route) or x-conditional-feature (body-dependent)", + constName, value) + } + } + }) +} diff --git a/internal/server/rbac_coverage_test.go b/internal/server/rbac_coverage_test.go index 12c84dc1..7ed56087 100644 --- a/internal/server/rbac_coverage_test.go +++ b/internal/server/rbac_coverage_test.go @@ -131,17 +131,31 @@ func handlerBody(src, operationID string) string { // to weaken the check, which is how a real gap gets let through later. const maxDelegationDepth = 2 -func enforces(src, body, constName string, depth int) bool { - if strings.Contains(body, "auth."+constName) { +// qualified is the package-qualified constant the caller is looking for, e.g. +// "auth.HostRead" or "license.ComplianceAttestation". It is a parameter rather +// than a hardcoded "auth." prefix because the license coverage check reuses +// this walker; the first cut hardcoded auth. and silently reported every +// license gate as missing. +func enforces(src, body, qualified string, depth int) bool { + if strings.Contains(body, qualified) { return true } if depth >= maxDelegationDepth { return false } - // Follow calls to same-receiver helpers, e.g. "h.reviewRemediation(". - re := regexp.MustCompile(`h\.([a-z][A-Za-z0-9]*)\(`) - for _, m := range re.FindAllStringSubmatch(body, -1) { - marker := "func (h *handlers) " + m[1] + "(" + // Follow calls to same-receiver helpers, e.g. "h.reviewRemediation(", and + // to package-level helpers, e.g. "enforceAttestationLicense(". Both are + // real patterns here: RBAC delegates to a method, the license gate to a + // package function. Following only one of them silently under-checks the + // other. + seen := map[string]bool{} + for _, m := range regexp.MustCompile(`h\.([a-z][A-Za-z0-9]*)\(`).FindAllStringSubmatch(body, -1) { + seen["func (h *handlers) "+m[1]+"("] = true + } + for _, m := range regexp.MustCompile(`(?:^|[^.\w])([a-z][A-Za-z0-9]*)\(`).FindAllStringSubmatch(body, -1) { + seen["func "+m[1]+"("] = true + } + for marker := range seen { i := strings.Index(src, marker) if i < 0 { continue @@ -150,7 +164,7 @@ func enforces(src, body, constName string, depth int) bool { if j := strings.Index(rest, "\nfunc "); j >= 0 { rest = rest[:j] } - if enforces(src, rest, constName, depth+1) { + if enforces(src, rest, qualified, depth+1) { return true } } @@ -186,7 +200,7 @@ func TestRBACCoverage_EveryDeclaredPermissionIsEnforced(t *testing.T) { } // The handler must reference the generated constant, not a raw // string. forbidigo separately bans raw permission literals. - if enforces(src, body, constName, 0) { + if enforces(src, body, "auth."+constName, 0) { continue } notEnforced = append(notEnforced, opID+" must enforce auth."+constName+" ("+perm+")") diff --git a/specs/api/capabilities.spec.yaml b/specs/api/capabilities.spec.yaml new file mode 100644 index 00000000..c23f43d4 --- /dev/null +++ b/specs/api/capabilities.spec.yaml @@ -0,0 +1,87 @@ +spec: + id: api-capabilities + title: Capability discovery - what this deployment is entitled to use + version: "1.0.0" + status: approved + tier: 2 + + context: + system: openwatch + feature: The paywall, made visible + description: > + Every registered capability, with whether THIS deployment can use it, so + the interface can lock or upsell a control instead of letting a Community + user click it and collect a 402. Before this endpoint existed a customer + could not see that a paid tier existed at all, which is the reason the + licensing machinery shipped complete and earned nothing. + + Built from licensing/features.yaml plus the runtime entitlement check, so + a capability cannot appear without being registered first. Same + registry-first rule the gating side follows. + related_specs: + - system-license-features + - api-license + + objective: + summary: > + A client can render an accurate lock or upsell state without guessing, + and without discovering entitlement by provoking a 402. + scope: + includes: + - GET /api/v1/capabilities + - Capability id, tier, availability, description + excludes: + - Quotas. There are none; tiering is by capability alone. + - Customer identity, expiry, and key material (see api-license) + - The lock and upsell UI itself + + constraints: + - id: C-01 + description: > + The response MUST be derived from the feature registry, never from a + hand-maintained list, so it cannot drift from what is actually gated. + type: technical + enforcement: error + - id: C-02 + description: > + The endpoint is unauthenticated by design, so it MUST NOT disclose + customer identity, license expiry, fingerprint, or key material. It + discloses only capability ids, their tier, and availability here. + type: security + enforcement: error + - id: C-03 + description: > + Capability order MUST be stable, so a client that diffs the response + does not churn on Go map iteration order. + type: technical + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: > + GET /api/v1/capabilities returns every entry in the feature registry, + each with id, tier, availability and description. The count matches the + registry exactly: a capability cannot be reported without being + registered, and none may be silently omitted. + priority: critical + references_constraints: [C-01] + - id: AC-02 + description: > + availability reflects the runtime entitlement. On an unlicensed + deployment every free-tier capability reports available=true and every + enterprise capability reports available=false. Loading a license that + grants a paid capability flips that one to true without a restart. + priority: critical + references_constraints: [C-01] + - id: AC-03 + description: > + The response discloses no customer identity, expiry, fingerprint, or + key material, even though the endpoint is anonymous. Only id, tier, + available and description appear per capability. + priority: critical + references_constraints: [C-02] + - id: AC-04 + description: > + Capabilities are returned in a stable order (by id). + priority: high + references_constraints: [C-03] diff --git a/specs/system/license-features.spec.yaml b/specs/system/license-features.spec.yaml index d92b4cfa..c8bb31e8 100644 --- a/specs/system/license-features.spec.yaml +++ b/specs/system/license-features.spec.yaml @@ -1,7 +1,7 @@ spec: id: system-license-features title: License features and gating middleware - version: "1.1.0" + version: "1.2.0" status: approved tier: 1 @@ -111,3 +111,21 @@ spec: a tier change alters that response for every unlicensed deployment. priority: high references_constraints: [C-03] + - id: AC-14 + description: > + License enforcement is structurally guaranteed, not remembered. Every + route that declares a license feature MUST name one registered in + licensing/features.yaml, and MUST enforce it somewhere in that route's + own call graph. Conversely a paid feature enforced in the server + package MUST be declared by at least one route, or the public contract + hides a paywall. Two annotations exist and are distinct: + x-required-feature means the whole route is paid; x-conditional-feature + means the route is paid only for some requests, decided from the body. + Reports are the live case for the second, since only the `attestation` + report kind is entitled and declaring the route paid outright would be + false. Resolution follows the route's own call graph, NOT a package-wide + search for the constant: a package-wide match passes a route whose gate + has been swapped to a different feature, which reads as coverage while + providing none. + priority: critical + references_constraints: [C-01] diff --git a/specter.yaml b/specter.yaml index 2dad799e..bc634661 100644 --- a/specter.yaml +++ b/specter.yaml @@ -11,6 +11,7 @@ domains: description: Default domain for OpenWatch. Specs are the .spec.yaml files under specs/ (indexed below); coverage is gated in CI via specter ingest + sync (strictness threshold). specs: - api-audit-events-query + - api-capabilities - api-diagnostics-echo - api-health - api-license