refactor(licensing): Community/Enterprise tier model (rename openwatch_plus, move SSO/MFA to free) - #755
Merged
Conversation
Collapse the license model to two user types: OpenWatch Community (free) and OpenWatch Enterprise (paid). The `openwatch_plus` tier is removed; every feature it gated is now `tier: enterprise`. Gating logic only ever checked `== TierFree`, so this is a rename, not a behavior change. - licensing/features.yaml: all openwatch_plus -> enterprise; header enum free|enterprise. Add `remediation_auto` (enterprise, registry-first, dormant) and narrow `remediation_execution` to bulk-only. - scripts/gen-license-features.go: drop TierOpenWatchPlus constant + case; regenerate internal/license/features.gen.go. - OpenAPI (api/openapi.yaml + fragments): license tier enum [free, openwatch_plus] -> [free, enterprise]; "OpenWatch+" copy -> "OpenWatch Enterprise". Regenerate server.gen.go + schema.d.ts. - Frontend: tier-display map -> Community/Enterprise; upsell/comment copy. - Go non-generated, tests, specs, public guides, .env.example, and an 0037 migration comment (comment-only; goose tracks by version). Verified: go build ./..., go test ./internal/license/..., tsc, the remediation-tab vitest suite, specter (116 specs), gofmt; codegen is idempotent and zero residual openwatch_plus/OpenWatch+ remain. Note: any already-minted openwatch_plus license JWTs (Hanalyx issuer infra) should be re-issued as `enterprise` (pre-GA; no live customers).
govulncheck flags GO-2026-5970 (infinite loop on invalid input) in golang.org/x/text v0.38.0 and reports it as symbol-reachable: pgx.Connect reaches norm.Form.Transform via x/text/secure/precis, which internal/group also calls directly. That fails `make vuln`, a required gate, so it blocks any PR open when the advisory landed rather than anything in the diff. v0.39.0 carries the fix. The module is an indirect dependency, so this is a go.mod/go.sum bump with no source change.
The rename changed a public OpenAPI enum, which makes it a user-visible change that belongs in the changelog. Two consequences were not written down anywhere an operator deciding whether to upgrade would look: a client matching the literal openwatch_plus breaks, and a license key already minted with that tier must be re-issued as enterprise before it will validate. Entered under Changed, with the pre-1.0 allowance for a breaking change in a minor release called out, and with the note that feature gating is unchanged so no deployment gains or loses access by upgrading.
The README advertised the current version as 0.4.0 while packaging/version.env said 0.6.0, two releases stale, and nothing caught it because no test compares the two. This class of drift is silent: the build stays green while the docs stop being true, and a reader who catches one stale fact discounts every other claim on the page. Adds two gates plus the fixes they force: - Version agreement across packaging/version.env, the README project-status paragraph, and the newest CHANGELOG heading. The documented release flow moves all three in one prepare-release change, so they never legitimately disagree on a merged commit. - Presence of README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, and LICENSE, plus a substance check that the security policy names a reporting address matching the README and routes reports away from public issues. SECURITY.md was missing outright. The reporting address was already in the README and CONTRIBUTING, but not in the file GitHub keys on for its advisory UI, so a reporter arriving through GitHub found no policy and no disclosure terms. Commit and branch naming are deliberately left out of CI; those need a person to weigh them.
Enterprise authentication is free core. OIDC SSO and TOTP MFA already ship free, so gating their SAML and FIDO2 siblings behind a paid tier would put an authentication-strength ceiling on the free tier. Security posture is not the moat, and it should not be a paywall. Both ids stay registered rather than being deleted, so an already-minted license naming either still resolves, and a future managed or hosted SSO offering has an id to hang off. Registry is now 3 free (compliance_check, sso_saml, fido2_mfa) and 8 enterprise; the paid tier is 8 flags, not 10. No entitlement change: neither flag is referenced by any x-required-feature gate, and no handler calls EnforceFeature on them, so no deployment gains or loses access. There IS one observable API change: GetLicense derives its features array from the registry's free tier (stageZeroFreeFeatures reads FeatureRegistry for Tier == TierFree), so an unlicensed deployment now advertises [compliance_check, fido2_mfa, sso_saml] where it previously advertised [compliance_check] alone. The tier enum is unchanged, and the frontend references neither id. Supersedes 62b6c031 on feat/apache-relicense, which made the same flip against the pre-rename tier names. Drop that commit from the relicense branch so it carries only the relicense; this branch is now the single authoritative tier change, holding both the openwatch_plus -> enterprise rename (af6c0b0) and this free-tier move. Verified: make generate-license is idempotent, gofmt clean, go build ./..., full go test ./internal/... green, advertised free list confirmed empirically against the registry. Note: licensing/features.yaml's header claims scripts/validate-features.go enforces the registry build invariants and that a /license/features endpoint renders its documentation. Neither exists. Left as-is here; wiring the validator is the A2 epic.
The entitlement registry is not itself public surface, but two consequences of this move are. First, it is a product statement: SAML and FIDO2 are free core from here, and an operator evaluating the tiers should be able to read that without diffing a YAML file. Second, GET /api/v1/license derives its features array from the registry's free tier, so an unlicensed deployment's response grows from one entitlement to three. A client asserting on that array's length or contents breaks, which is exactly what a changelog is for. Entitlement itself is unchanged: neither id gated a route before or after.
…es AC-13) The pre-push spec gate blocked the tier move for changing an implementation file with no AC delta, and it was right to. Moving sso_saml and fido2_mfa to free is not purely a label change: GET /api/v1/license builds its advertised features array from the registry's free tier (stageZeroFreeFeatures iterates FeatureRegistry for Tier == TierFree), so an unlicensed deployment's response grew from one entitlement to three. Nothing asserted that. AC-13 pins the free set explicitly rather than deriving it, so a tier reassignment fails the build until someone updates the expectation on purpose. AC-04 already covered "a free feature is enabled without a license" generically but never said which features are free, which is exactly what moved. Negative-tested both directions: silently flipping audit_query to free fails with "became free tier without updating this AC"; silently flipping sso_saml back to enterprise fails with "expected to be free tier but the registry has it paid". Spec bumped 1.0.0 -> 1.1.0; 116 specs still 100%.
remyluslosius
added a commit
that referenced
this pull request
Jul 28, 2026
…rted as unchanged (#761) * fix(frontend): the remediation modal claimed applying a fix is a paid feature The Request Remediation modal told the operator "Applying the fix on the host is an OpenWatch Enterprise feature: the free tier governs the request and approval only." That is false, and has been since single-rule remediation shipped free in v0.2.0-rc.11. Verified against the registry rather than the copy: audit_export is the ONLY license_gated permission in auth/permissions.yaml. remediation:execute and remediation:rollback carry no license gate, and api-remediation C-06 states it outright ("Execute/rollback are FREE core (Tier A), NOT license-gated ... No remediation act endpoint returns 402"). frontend-remediation-tab AC-07 already required this upsell copy to be gone; it was removed from the tab but not from this modal. The practical effect is a Community user being told to buy something they already have, on the screen where they decide whether to act. Also reformats the paragraph: PR #755's "OpenWatch+" -> "OpenWatch Enterprise" rename pushed the line past the print width and merged unformatted, because CI does not run prettier (only the local pre-commit hook does). Worth noting as a gap rather than a one-off. * feat(remediation): outcome vocabulary so a changed host is never reported as unchanged Remediation reported two terminal outcomes, executed and failed, and "failed" meant five different things carrying one message: "Remediation did not complete. No host change was committed." That message is false for Kensa v0.8.0's new `staged` status. On a host whose audit config is immutable (auditctl -s reports `enabled 2`) an audit_rule_set apply writes the reboot-deferred persist layer and terminates staged. The host IS mutated. OpenWatch absorbed the unknown status through a default branch, marked the request failed, told the operator nothing had changed, journalled it as 'skipped', then refused rollback because rollback required 'executed'. The change sat on the host, invisible, with no route back through the UI. Verified reachable on 2 of 6 reachable dev-fleet hosts (owas-tst01 RHEL 8.10, owas-tst02 RHEL 9.6, both enabled 2), across 103 audit_rule_set rules. Outcomes now map one-to-one onto operator actions: executed runtime converged, host protected staged persist written, NOT converged, host changed, needs reboot reverted validation failed, Kensa restored pre-state, host clean not_applied engine declined, host untouched partially_applied stranded steps, host state unknown failed errored or unreachable Three behaviours follow, and they are the point of the change: - staged does NOT flip host_rule_state to pass. The kernel has not loaded the change, a re-scan correctly still fails the rule, and claiming a pass would assert a protection the host does not have. - staged IS rollback-eligible. Kensa captured pre-state and reverses a staged drop-in byte-perfect; the precondition and the rollback-handle lookup both widened, since either alone still stranded the change. - reverted is not red and does not page. Validation failing and the host being restored is the atomic model working. Alerting on it teaches operators to ignore the alert. remediation.OutcomeOf is now the single place a Kensa status is interpreted, returning (Status, known bool). An unknown status logs at WARN naming the value and fails closed to failed, never to a success-shaped outcome. That guard is AC-11, and its absence is what caused this bug: a default branch silently swallowed a new terminal state. Deliberately NOT done: detecting Kensa's "engine refused without mutating" case (the v0.8.0 duplicate-audit-action guard). Kensa surfaces it as StepResult.Success=false plus human-readable Detail with no distinguishable TransactionStatus, so telling it from a real failure would mean matching step text on a path that runs as root. Refusals map by status to reverted, which is truthful if less specific. StatusNotApplied and the DB enum already accept the value, so wiring it is one line once Kensa exposes a signal. Same reasoning for already-compliant runs, which Kensa reports as committed with a synthetic check step. Both filed upstream. Migration 0054 widens both CHECK constraints. The one-open-request partial unique index is deliberately untouched: every new value is terminal, so a staged request must not block a fresh request for the same host and rule. .secrets.baseline is refreshed for two reasons, neither a new finding: line drift in the generated server.gen.go from the enum addition, and one pre-existing false positive at api/openapi.yaml:127 (the word "password" in prose about which profile fields are editable). That finding fires on unmodified main too; the baseline predates it. Specs: api-remediation 1.1.0 -> 1.2.0 (C-09, AC-09/10/11), frontend-remediation-tab 1.1.0 -> 1.2.0 (AC-08). 116 specs, 116 passing. Verified: gofmt, go vet, go build, full go test ./internal/..., tsc, prettier, vitest 361/361, OpenAPI drift gate green after regeneration.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Collapse the license model to two user types: OpenWatch Community (free) and OpenWatch Enterprise (paid). The
openwatch_plustier is removed; every feature it gated is nowtier: enterprise. Gating logic only ever checked== TierFree, so this is a rename, not a behavior change.Changes
licensing/features.yaml: allopenwatch_plus→enterprise; header enumfree | enterprise. Addsremediation_auto(enterprise, registry-first, dormant) and narrowsremediation_executionto bulk-only.TierOpenWatchPlusconstant + case; regeneratefeatures.gen.go(11 features).[free, openwatch_plus]→[free, enterprise]; "OpenWatch+" copy → "OpenWatch Enterprise". Regeneratedserver.gen.go+schema.d.ts..env.example, and an0037migration comment (comment-only; goose tracks by version).Verification
go build ./...,go test ./internal/license/...,tsc, theremediation-tabvitest suite (7/7),specter check(116 specs),gofmt— all green.openwatch_plus/OpenWatch+remain.Note
Any already-minted
openwatch_pluslicense JWTs (Hanalyx issuer infra) should be re-issued asenterprise. Pre-GA, so no live customers affected.Also in this branch
Three follow-on commits, bundled here rather than split into their own PRs because the first unblocks this one's CI and the rest are small.
build(deps)bumpgolang.org/x/textv0.38.0 → v0.39.0.make vulnstarted failing on GO-2026-5970 (infinite loop on invalid input), reported symbol-reachable viapgx.Connect→x/text/secure/precis→norm.Form.Transform, whichinternal/groupalso calls directly. Unrelated to the rename — the advisory landed while this PR was open and would fail onmaintoo. Indirect dependency, sogo.mod/go.sumonly.docs(changelog): an[Unreleased] → Changedentry for the rename. The tier enum is public OpenAPI surface, so this is user-visible: a client matching the literalopenwatch_plusbreaks, and already-minted keys need re-issuing asenterprise. Both consequences are now written where an operator deciding whether to upgrade will find them, with the pre-1.0 breaking-change allowance called out.test(packaging): gate the front-door files and the advertised version. The README claimed0.4.0againstversion.env's0.6.0— two releases stale, uncaught because nothing compared them. Adds a version-agreement gate (version.env⟷ README ⟷ newest CHANGELOG heading) and a front-door presence gate (README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, LICENSE) plus a substance check on the security policy. Fixes the README and adds the missingSECURITY.md; the reporting address was already in README/CONTRIBUTING but not in the file GitHub keys on for its advisory UI.Both new gates were negative-tested: stale README version, removed version phrase, missing
SECURITY.md, and a policy with the public-issue routing line stripped all fail as intended.make vulnnow reports no vulnerabilities; full./packaging/tests/suite andspecter check(116 specs) green.Commit and branch naming are deliberately not gated in CI — those stay review discipline.
Scope grew: this PR now settles the tier boundary, not just its name
Two commits added 2026-07-27. The tier question was being decided on two branches at once, which was a live risk to the enforcement work planned for v0.7. It is now settled here.
refactor(licensing): movesso_samlandfido2_mfato the free tier. Enterprise authentication is free core. OIDC SSO and TOTP MFA already ship free, so gating their SAML and FIDO2 siblings behind a paid tier would put an authentication-strength ceiling on the free tier. Security posture is not the moat, and it should not be a paywall. Both ids stay registered rather than deleted, so an already-minted license naming either still resolves and a future managed/hosted SSO offering has an id to hang off.The paid tier is now 8 entitlements, not 10. Registry is 3 free (
compliance_check,sso_saml,fido2_mfa) and 8 enterprise.docs(changelog): record the move. Operator-facing, for the reason in the next section.Entitlement is unchanged; one response is not
Neither id gated a route before or after: nothing declares them via
x-required-featureand no handler callsEnforceFeatureon them. No deployment gains or loses access.But
GET /api/v1/licensedoes change.stageZeroFreeFeaturesbuilds thefeaturesarray by readingFeatureRegistryforTier == TierFree, so a deployment running without a license key now returns:A client asserting on that array's length or contents breaks. Confirmed empirically against the registry, not inferred. The
tierenum is untouched.Action required on
feat/apache-relicenseThat branch carries
62b6c031, which makes the same flip against the pre-rename tier names (openwatch_plus). It is superseded. Drop it from the relicense branch so that branch carries only the relicense, otherwise the two conflict.Verification
make generate-licenseidempotent;gofmtclean;go build ./...; fullgo test ./internal/...green. Frontend references neither id, so no UI change. Advertised free-feature list verified empirically.Reviewer note, out of scope here
licensing/features.yaml's header claimsscripts/validate-features.goenforces the registry build invariants, and that a/license/featuresendpoint renders its documentation. Neither exists. Nothing validates the registry today, only two routes declarex-required-feature, and no test checks them. Deliberately not fixed in this PR: wiring that validator is the A2 epic and belongs with the license-enforcement work.