Skip to content

refactor(licensing): Community/Enterprise tier model (rename openwatch_plus, move SSO/MFA to free) - #755

Merged
remyluslosius merged 7 commits into
mainfrom
refactor/community-enterprise-tiers
Jul 28, 2026
Merged

refactor(licensing): Community/Enterprise tier model (rename openwatch_plus, move SSO/MFA to free)#755
remyluslosius merged 7 commits into
mainfrom
refactor/community-enterprise-tiers

Conversation

@remyluslosius

@remyluslosius remyluslosius commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What

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.

Changes

  • licensing/features.yaml: all openwatch_plusenterprise; header enum free | enterprise. Adds remediation_auto (enterprise, registry-first, dormant) and narrows remediation_execution to bulk-only.
  • Generator: drop TierOpenWatchPlus constant + case; regenerate features.gen.go (11 features).
  • OpenAPI contract: license tier enum [free, openwatch_plus][free, enterprise]; "OpenWatch+" copy → "OpenWatch Enterprise". Regenerated 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).

Verification

  • go build ./..., go test ./internal/license/..., tsc, the remediation-tab vitest suite (7/7), specter check (116 specs), gofmt — all green.
  • Codegen idempotent; 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, 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) bump golang.org/x/text v0.38.0 → v0.39.0. make vuln started failing on GO-2026-5970 (infinite loop on invalid input), reported symbol-reachable via pgx.Connectx/text/secure/precisnorm.Form.Transform, which internal/group also calls directly. Unrelated to the rename — the advisory landed while this PR was open and would fail on main too. Indirect dependency, so go.mod/go.sum only.
  • docs(changelog): an [Unreleased] → Changed entry for the rename. The tier enum is public OpenAPI surface, so this is user-visible: a client matching the literal openwatch_plus breaks, and already-minted keys need re-issuing as enterprise. 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 claimed 0.4.0 against version.env's 0.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 missing SECURITY.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 vuln now reports no vulnerabilities; full ./packaging/tests/ suite and specter 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): move sso_saml and fido2_mfa to 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-feature and no handler calls EnforceFeature on them. No deployment gains or loses access.

But GET /api/v1/license does change. stageZeroFreeFeatures builds the features array by reading FeatureRegistry for Tier == TierFree, so a deployment running without a license key now returns:

features: ["compliance_check", "fido2_mfa", "sso_saml"]   # was: ["compliance_check"]

A client asserting on that array's length or contents breaks. Confirmed empirically against the registry, not inferred. The tier enum is untouched.

Action required on feat/apache-relicense

That 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-license idempotent; gofmt clean; go build ./...; full go 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 claims scripts/validate-features.go enforces the registry build invariants, and that a /license/features endpoint renders its documentation. Neither exists. Nothing validates the registry today, only two routes declare x-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.

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.
@remyluslosius remyluslosius changed the title refactor(licensing): rename paid tier openwatch_plus -> enterprise (Community/Enterprise) refactor(licensing): Community/Enterprise tier model (rename openwatch_plus, move SSO/MFA to free) Jul 28, 2026
…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%.
@github-actions github-actions Bot added size/XL and removed size/L labels Jul 28, 2026
@remyluslosius
remyluslosius merged commit a4b6de2 into main Jul 28, 2026
21 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation frontend size/XL tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant