From e2fa9c7efdcb6f6a5ab83ce351616a9b200246a4 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Wed, 29 Jul 2026 22:50:44 -0400 Subject: [PATCH 1/2] test(licensing): fail the build when a paid route ships ungated (v0.7 criterion 2) The licensing machinery is complete and wired to exactly one demo route. Seven of the nine paid capabilities have no code yet, so this check passes almost trivially today. That is the point: it is not here to find current gaps, it is here so that when bulk remediation lands in v0.9 and someone forgets the gate, the build fails instead of the capability shipping free. Checks both directions: declared -> enforced a route naming a feature must name a REGISTERED one and must enforce it in that route's own call graph enforced -> declared a paid feature enforced in the server package must be declared by some route, or the contract hides a paywall The second direction found a gap I created three PRs ago: the attestation gate from #766 is enforced in the handler and declared nowhere, so the public contract was silent about a paywall that exists. Fixed here. Adds x-conditional-feature alongside x-required-feature, and the distinction is load-bearing. The attestation gate depends on the request BODY (only report kind `attestation` is entitled), so a route-level x-required-feature would declare a free route paid. Collapsing the two annotations forces a choice between lying about a free route and leaving a real gate undeclared, and the second is exactly what happened. Two mistakes in my own first cut, both caught by negative-testing rather than by review, both the same class: The conditional branch searched the whole PACKAGE for the constant. That passed a route whose gate had been swapped to a different feature, because the right constant still appeared elsewhere. A loose match in a security check is worse than none: it reads as coverage. enforces() hardcoded the "auth." prefix. Reusing it for license constants silently reported every license gate as missing. Now parameterised, and it follows package-level helpers as well as methods, since RBAC delegates to a method and the license gate to a package function. Negative-tested all three failure modes: an unregistered flag, a gate swapped to a different feature, and a gate enforced but undeclared. All three fail the AC; the middle one is the case that slipped through the first implementation. Spec: system-license-features 1.1.0 -> 1.2.0, AC-14. 116 specs, 116 passing. --- api/openapi.yaml | 12 ++ internal/server/license_coverage_test.go | 184 +++++++++++++++++++++++ internal/server/rbac_coverage_test.go | 30 +++- specs/system/license-features.spec.yaml | 20 ++- 4 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 internal/server/license_coverage_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index ea5c0e34..b5c7565f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2680,6 +2680,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 +2771,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 +2942,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 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/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] From 939fdc166015e7586417c279b38575630ef725d6 Mon Sep 17 00:00:00 2001 From: Remylus Losius Date: Wed, 29 Jul 2026 23:16:51 -0400 Subject: [PATCH 2/2] feat(licensing): GET /capabilities so a customer can see the paywall (v0.7 criterion 3) Before this, a Community user could not tell that a paid tier existed. The licensing machinery shipped complete and earned nothing, partly because there was no way for the interface to lock or upsell a control: the only way to discover an entitlement was to click something and collect a 402. Built entirely from licensing/features.yaml plus the runtime entitlement check, so a capability cannot be reported without being registered first. Same registry-first rule the gating side follows, and it means this endpoint cannot drift from what is actually gated. Unauthenticated by design, like GET /license: a pre-login screen needs to know what the deployment offers. It discloses capability ids, their tier, and availability here, and nothing about who holds the license. AC-03 asserts that against the RAW JSON rather than a typed struct, because a typed decode silently ignores an added field, which is exactly the leak being guarded against. 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 percent-of-allowance indicator would invent a limit that does not exist. Order is stable by id. Go map iteration would make the response non-deterministic and churn any client that diffs it. New spec api-capabilities 1.0.0, registered in specter.yaml. 117 specs. NOT DONE: the lock and upsell UI. The endpoint is the contract the UI needs, but where a lock appears, what the upsell says, and how a grace-period banner reads are product decisions rather than plumbing. Explicitly out of scope in the spec so it does not read as finished. --- .secrets.baseline | 6 +- api/openapi.yaml | 43 +++++++ frontend/src/api/schema.d.ts | 67 ++++++++++ internal/server/api/server.gen.go | 136 ++++++++++++++++++-- internal/server/api_capabilities_test.go | 153 +++++++++++++++++++++++ internal/server/capabilities_handler.go | 58 +++++++++ specs/api/capabilities.spec.yaml | 87 +++++++++++++ specter.yaml | 1 + 8 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 internal/server/api_capabilities_test.go create mode 100644 internal/server/capabilities_handler.go create mode 100644 specs/api/capabilities.spec.yaml 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 b5c7565f..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 @@ -5321,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/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/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