Skip to content

feat(licensing): gate the auditor attestation artifact behind compliance_attestation - #766

Merged
remyluslosius merged 3 commits into
mainfrom
feat/gate-attestation-reports
Jul 29, 2026
Merged

feat(licensing): gate the auditor attestation artifact behind compliance_attestation#766
remyluslosius merged 3 commits into
mainfrom
feat/gate-attestation-reports

Conversation

@remyluslosius

Copy link
Copy Markdown
Contributor

Why

You named "a signed OSCAL report for a compliance officer" as a moat item, meaning something OpenWatch monetizes. Every report route was ungated: all twelve, including /reports/{id}/export and the fleet OSCAL SAR face.

That collided head-on with the opencore roadmap's locked tiering rule, which names reports explicitly among things that "stay free" because they already shipped free. Honoring the lock literally would make your clearest monetization case permanently unmonetizable. Ignoring it would revoke a shipped capability.

The split, and why the axis matters

Resolved by splitting rather than revoking. Scope was the obvious axis and it is wrong: an absent scope body means all hosts and all frameworks, so a scope-based gate would have gated the default executive summary, which is the everyday free artifact.

The line is the report kind:

Free executive kind at any scope; per-scan and per-host OSCAL (/scans/{id}/oscal, /scans/{id}/rules/{ruleId}/oscal); the signing key; report list and read
Enterprise the attestation kind

Kind is also exact rather than approximate: report.FaceOSCALSAR is attestation-only (exportFleetOSCALSAR), so gating this one kind gates the fleet OSCAL SAR and nothing else.

A Community user keeps a free path to OSCAL evidence per scan. What is paid is the fleet-wide signed package filed with an auditor.

Four gate sites, because any one alone is theatre

Site Without it
Generation, on requested kind the obvious one
Export, on the stored report's kind every attestation already in the database stays freely exportable
Schedule creation the artifact gets minted on a timer
Dispatcher, at fire time a schedule created while licensed keeps producing after the licence lapses

Each was negative-tested independently, neutering it while keeping the build valid so the assertion fires rather than the compiler.

The locked rule is amended, not bent

Recorded as an explicit exception in opencore/ROADMAP.md (local-only, gitignored). A locked rule that gets silently excepted stops being a rule.

Registry-first

compliance_attestation added to licensing/features.yaml and codegen'd before any route referenced it. Enterprise is now 9 flags; the free set is unchanged, so system-license-features AC-13 still pins it.

SDD

api-reports 1.14.0 -> 1.15.0, C-19 and AC-25. 116 specs, 116 passing.

Two id collisions caught before landing: C-09 already existed (caught by the duplicate check I added after the AC-13 collision in #763), and the renumbering initially repointed AC-11's reference away from it. specter's orphan_constraint gate caught the second one.

Verification

gofmt, go vet, go build ./..., full go test ./internal/..., spec gate.

…nce_attestation

The founder named "a signed OSCAL report for a compliance officer" as a moat
item, meaning something OpenWatch monetizes. Every report route was ungated:
all twelve, including /reports/{id}/export and the fleet OSCAL SAR face.

That collided with the opencore roadmap's LOCKED tiering rule, which names
reports explicitly among things that "stay free" because they already shipped
free. Honoring the lock literally would have made the clearest monetization
case permanently unmonetizable; ignoring it would revoke a shipped capability.

Resolved by splitting rather than revoking, and the axis matters. Scope was the
obvious choice and is wrong: an absent scope body means all hosts and all
frameworks, so a scope-based gate would have gated the default executive
summary, which is the everyday free artifact. The line is the report KIND.

  free        executive kind at ANY scope; per-scan and per-host OSCAL
              (/scans/{id}/oscal, /scans/{id}/rules/{ruleId}/oscal); the
              signing key; report list and read
  enterprise  the attestation kind

Kind is also exact rather than approximate: report.FaceOSCALSAR is
attestation-only (exportFleetOSCALSAR), so gating this one kind gates the fleet
OSCAL SAR and nothing else. A Community user keeps a free path to OSCAL
evidence per scan; what is paid is the fleet-wide signed package filed with an
auditor.

Gated at four sites, because any one alone is theatre:

  - generation, on the requested kind
  - export, on the STORED report's kind, or every attestation already in the
    database stays freely exportable
  - schedule creation, or the artifact is minted on a timer
  - the dispatcher at FIRE time, or a schedule created while licensed keeps
    producing the paid artifact after the licence lapses

Each of the four was negative-tested independently by neutering it while
keeping the build valid, so the assertion fires rather than the compiler.

The locked tiering rule is amended in opencore/ROADMAP.md rather than quietly
bent. A locked rule that gets silently excepted stops being a rule.

Registry-first: compliance_attestation added to licensing/features.yaml and
codegen'd before any route referenced it. Enterprise is now 9 flags; the free
set is unchanged, so system-license-features AC-13 still pins it.

Spec: api-reports 1.14.0 -> 1.15.0, C-19 and AC-25. Two id collisions caught by
the duplicate check before landing: C-09 already existed, and the renumbering
sed initially repointed AC-11's reference away from it. specter's
orphan_constraint gate caught the second.

116 specs, 116 passing. gofmt, go vet, go build, full go test ./internal/...
… the fire-time gate

CI caught what the local suite could not: two DB-gated report-schedule suites
use the `attestation` kind as their fixture, because it has the richest
content. Gating that kind made both fail. They test schedule mechanics, not
licensing, so they now run as a licensed deployment would.

That needed an exported hook. license.EnableFeatureForTesting mirrors the
existing SetVerificationKeyForTesting pattern and carries an explicit warning
not to use it to paper over a gate: a test asserting an unlicensed caller is
refused must not call it, because that is the case the gate exists for.

Adds the behavioural half of AC-25 for the fire-time gate. The source
inspection proves the check is written; this proves it works, which matters
most here because the dispatcher gate is the one an operator cannot see. A
schedule created while licensed would otherwise keep minting the paid artifact
on a timer forever after the licence lapsed, with no request to refuse. The
test asserts three things: unlicensed attestation never reaches Generate and
skipping is not an error, the free `executive` kind is unaffected, and a
LICENSED attestation does reach Generate, so the gate is proven to be the
licence rather than the kind alone.
…fore

freshAPIServer calls license.Init(), which resets state, so enabling the
feature beforehand was wiped before the request ran and the suite still saw a
402. Enable after fixture setup in both suites; the dispatcher one happened to
work only because freshPool does not init license, which is the kind of
ordering dependency that fails later for an unrelated reason.
@remyluslosius
remyluslosius force-pushed the feat/gate-attestation-reports branch from bc52bfe to f573da6 Compare July 29, 2026 23:36
@remyluslosius
remyluslosius merged commit 77279d8 into main Jul 29, 2026
13 checks passed
remyluslosius added a commit that referenced this pull request Jul 30, 2026
…#767)

* ci: local test database so DB-gated suites run before push, not in CI

The DB-gated suites do not run without OPENWATCH_TEST_DSN, so `make ci-local`
silently skipped them and a change could look clean locally and fail in CI.
That happened three times in one day on the v0.7 branches: the report-schedule
suites (#766), the RBAC role-assign and token tests (#765), and the go mod tidy
drift (#762). Every one was a real defect that a local run would have caught.

scripts/test-db.sh mirrors the CI service container exactly. Image and
credentials match .github/workflows/go-ci.yml, because if they drift then
"passes locally" stops meaning "passes in CI", which is the whole point.

Two settings are load-bearing and are commented as such in the script:

  max_connections=400. The default 100 is not enough. The suite runs packages
  in parallel and internal/db/dbtest clones a template database per package, so
  a default container deadlocks: the first DB test times out at 120s and the
  rest cascade. Found this the honest way, by hitting it. It looks exactly like
  a real failure and is not one, which is worse than no local database at all.

  fsync, synchronous_commit and full_page_writes off. The database is
  disposable; durability buys nothing and costs most of the runtime.

Port defaults to 5455 rather than 5432: on this workstation 5432 is the dev
database and 5433 is hanalyx-postgres. Override with TEST_DB_PORT. The database
name ends in _test so the internal/db guard accepts it without the
ALLOW_NONTEST bypass, which we never want set.

`make ci-local` now warns when OPENWATCH_TEST_DSN is unset and says exactly how
to fix it. A silent skip is what let this through repeatedly; a loud skip is
the minimum.

Verified: full `go test ./internal/...` against the container is clean, and the
three suites CI previously caught now run and pass locally.

* ci: un-ignore scripts/test-db.sh so the Makefile target has a script

The blanket `*test*.sh` in .gitignore swallowed scripts/test-db.sh. `git add`
refused it, the ci-local change shipped referencing a script that was never
committed, and nothing failed loudly: the PR just contained a Makefile target
pointing at a file no one else would have.

Same trap as the blanket `*.spec` rule that drops new packaging spec files.
Negate deliberately and verify with `git check-ignore -v <path>` after adding
anything to scripts/ whose name contains "test".
remyluslosius added a commit that referenced this pull request Jul 30, 2026
…ria 2 and 3) (#770)

* 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.

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant