think
16px
820px

Module-licensing audit — does Obscura work without its modules?

Date: 2026-08-13 · Revision: 18335a3 · Method: the real chi router booted 15× against a
throwaway Postgres+MinIO, one boot per licence set, every registered route probed through the live
middleware chain. Not a reading of requireModule call sites.

Harness: go/internal/httpapi/zz_moduleaudit_test.go (build tag moduleaudit, inert in normal runs).

go test -tags moduleaudit -run 'TestModuleAudit|TestMeModuleProjection' ./internal/httpapi/ -v

Verdict

Core-only works. The standard DMS lifecycle runs with zero modules licensed, every premium
surface fails closed with 403 module.not_licensed, and no core route changes behaviour with the
licence. Module combinations resolve exactly as designed, including the retired-name aliases.

Four gaps, all in the same direction — enforcement missing, never enforcement too strict. Nothing
found blocks a core customer on a fresh install.


Status

Finding Status
F1 securefolder sold but never enforced delegated — handed to the Secure Folder workstream
F2 watermarking pipeline runs unlicensed on every PDF egress fixed
F3 seal-time marking runs unlicensed fixed
F4 Workflows offers a letter subject the deployment can't run fixed

What the fixes changed

F2egressProtectPDF now asks the licence, not just whether an engine is wired:

if s.protection == nil || !s.moduleEnabled(r.Context(), "watermarking") {
    return raw, nil, nil
}

One clause, at the single chokepoint every whole-PDF egress already funnels through, so downloads,
shares, previews, public signing, transfer packages and official copies are all covered by it. It
makes the function consistent with guardUnmarkableFormat, which has always asked this question.

F3sealTimeMarker gained a modules moduleGate port and checks it first thing in
MarkForSeal, before any lookup. Nil gate = fail closed (no mark): not marking is always safe here
since the ceremony proceeds either way, whereas marking without entitlement is not. The wiring moved
from next to protectionSvc to after NewServer — the live, hot-swappable entitlement lives on the
HTTP server, the same reason SetModuleChecker is already deferred there.

F4SUBJECT_TYPES is filtered on the correspondence entitlement in both
StartInstanceModal and CustomWorkflowModal, and useLetterOptions(enabled) takes the same
predicate so the module-gated register is never fetched on a deployment that cannot have it.

Regression guards

Four new tests, all passing. Both Go guards build their service over nil ports on purpose: getting
past the gate dereferences them, so a regression is a loud panic rather than a quiet re-enablement.

  • internal/httpapi/egress_licence_test.go — unlicensed egress returns the bytes untouched with no
    protection headers; the licensed direction still reaches the engine.
  • cmd/obscura-server/sealmarker_licence_test.go — unwired gate / esign-only / nothing-licensed all
    seal the original bytes; watermarking-licensed still reaches the marking path.

Coverage

Routes registered 644
Routes probed 582
Skipped 62 (non-/api/v1, auth routes, and side-effecting probes: licence upload, rate-limit config, backup/restore, SMTP test)
Licence sets booted 15
Module-gated routes 185
Ungated (core) routes 397

Licence sets: core-only · each of the 7 modules alone · corr+esign · corr+office ·
esign+watermarking · ai+securefolder · klasifikasi+corr · legacy aliases · all.

Gate classes derived from behaviour

Every one of the 185 gated routes falls into exactly one class — no route is gated on a module
other than the one its surface belongs to, and no route needs a module it shouldn't.

Required module Routes
esign 64
correspondence 52
ai 31
klasifikasi 15
watermarking 9
office 7
correspondence AND esign 7
securefolder 0 — see F1

The 7-route both-modules class is letter signing (/letters/{id}/sign, /meterai, /stamp,
/seals, /verify, /pending-seal). Neither module alone opens them; corr+esign does. Correct.


What was verified working

  1. Core DMS lifecycle, zero modules. TestFullFlow (folder → document → multipart version
    upload → version list → byte-exact download → checkout → metadata round-trip) passes under a
    missing licence. The full internal/httpapi suite is green — and that suite is core-only by
    construction, since its harness passes no licence at all.
  2. Fail-closed everywhere. All 185 gated routes return 403 module.not_licensed when their
    module is absent. No gated route leaked open in any of the 15 sets.
  3. No licence-dependent core behaviour. Across 582 routes × 15 sets, no ungated route changed
    status by licence. (One apparent diff, POST /shared/{token}/otp/send, is the harness's own
    shared rate-limiter window, not a licence effect.)
  4. /me enabled_modules is exact for all 15 sets — this is the single source of truth for every
    cosmetic UI gate, and it never over- or under-reports.
  5. Legacy aliases work. A licence naming the retired semantic / mcp / templates resolves to
    [ai, office] and unblocks exactly the ai (31) and office (7) routes. Field licences keep
    working untouched, as ModuleAliases intends.
  6. Cross-module couplings that are correctly guarded:
    - workflow.requireSignLicensed rejects a definition containing a Sign / Meterai / Stamp /
    SignExternal step at author and start time with a clean module.not_licensed, and no-ops on
    a nil checker. No stuck task nobody can complete.
    - guardUnmarkableFormat checks the module and no-ops without it, so a non-PDF download is
    never blocked by a policy the deployment doesn't own.
    - office is fail-closed in-handler across 22 call sites: officeEditEnabled = module AND doc-server secret. Its routes are ungated at the router by design — licensed-but-
    unconfigured and configured-but-unlicensed both stay off.
    - Frontend gates are wired in the views that matter (DocumentDetailView, InboxPage,
    SearchResultsPage, UploadDocumentModal, nav.ts sections).

Findings

F1 — securefolder is a sellable SKU with zero enforcement · High

requireModule("securefolder") appears 0 times in the codebase. The module sits in
KnownModules (config.go:663), is priced and ticked per tenant, and is consulted in exactly one
place: a boot-time config requirement that warns when SECUREFOLDER_KEK_FILE is unset
(requirements.go:511).

The entire encrypted-folder tier is therefore open on every deployment, core-only included:

POST   /api/v1/folders/{id}/vault-keys
GET    /api/v1/folders/{id}/vault-keys
GET    /api/v1/folders/{id}/vault-keys/mine
DELETE /api/v1/folders/{id}/vault-keys/{userID}
GET    /api/v1/folders/{id}/break-glass
POST   /api/v1/folders/{id}/break-glass
POST   /api/v1/folders/{id}/break-glass/{bgID}/approve
POST   /api/v1/folders/{id}/break-glass/{bgID}/revoke
GET    /api/v1/admin/secure-folders

The base tier (members_only visibility, step-up entry) is core by design — only the encryption
tier is meant to be licensed, and it is the encryption tier that is unguarded. A customer who buys
securefolder gets nothing a core customer doesn't already have.

Note the /me projection does report securefolder when licensed, so the UI will show the
feature as a paid capability the API hands out for free.

Fix: wrap the vault-key and break-glass groups in requireModule("securefolder"), or gate
in-handler the way office does if the base/encryption split needs finer granularity than the route.

F2 — the watermarking pipeline runs unlicensed on every PDF egress · Medium-High

egressProtectPDF (handlers_dms.go:485) guards on s.protection == nil and never on the
module
— while guardUnmarkableFormat, 120 lines below in the same file, does check it. And
protectionSvc is wired unconditionally (wire.go:1358, "Wired even under the no-op engine so
policy admin and the issuance ledger exist before the engine lands"
), so s.protection is never nil
in a real deployment.

Every PDF egress on every deployment therefore enters the forensic-watermarking pipeline —
downloads, shares, previews, public signing, transfer packages, official copies — regardless of
licence.

A fresh core-only install is safe: migration 00092 seeds default_mode='none' and
fail_closed=FALSE, so the pipeline resolves to no-op and passes the bytes through.

The bite is licence downgrade or expiry. An expired licence yields modules=[] while the
protection_settings / per-document / per-folder policy rows persist. The 9 admin routes that would
turn the policy off are now 403. So:

  • a fail-closed traceability/hybrid policy keeps being enforced on a deployment that can no
    longer administer it, and
  • if the stego sidecar is down or OOMs, protectEgress fails → protection.failedcore
    document downloads are blocked
    , with no remedy short of SQL.

Fix: add !s.moduleEnabled(r.Context(), "watermarking") to the early return in
egressProtectPDF, matching guardUnmarkableFormat.

F3 — seal-time marking is unlicensed too · Medium

sealTimeMarker.runSealMark (cmd/obscura-server/resolvers.go:429) calls
protection.ProtectEgress(… ChannelSeal …) with no module check, and is wired unconditionally at
wire.go:1364. An esign-only deployment embeds the paid forensic mark into the bytes of every
signed document and letter.

Same precondition as F2 (a non-none resolved policy), and it is best-effort — any failure returns
the unmarked bytes, so it can never block a ceremony. Lower severity than F2 for that reason, but
it's the same missing check and should be fixed alongside it.

F4 — Workflows offers a letter subject the deployment can't run · Low (UX)

SUBJECT_TYPES = ['document', 'letter', 'other'] is a hardcoded constant in both
StartInstanceModal.tsx:34 and CustomWorkflowModal.tsx:52, and useLetterOptions() is called
unconditionally in both (:69, :175). Workflows is a core nav section — no module: in
SECTIONS.

On a core-only deployment: opening Start-workflow fires GET /api/v1/letters403. Queries carry
no error toast (only mutationCache has onError), so it fails silently and the picker renders
empty — a dead end with no explanation. The Custom-workflow modal will also let an author build a
definition over a subject type this deployment can never run.

Fix: filter SUBJECT_TYPES on moduleEnabled(me.enabledModules, 'correspondence') and pass the
same predicate to useLetterOptions() as its enabled.


Harness limitation (not a product defect)

50 routes returned 500 identically under all 15 licence sets. Cause: the shared test harness
(newTestServer) leaves ~20 of Deps' 44 ports nil — Groups, DocTemplates, Gatekeeper,
Shortlink, Letterhead, Reporting, ExtApproval, SCIM, LDAPGroupRoles, MCPSettings — so
those handlers panic into the Recoverer. They carry no licence signal.

Worth noting the 500 is itself evidence: it comes from inside the handler, which means every
middleware ahead of it — requireModule included — already ran and allowed the request. Their gate
status is correctly classified.

Not covered by this run

  • Functional depth per module. This proves the gate opens and core survives; it does not prove
    each module's feature works end-to-end when licensed alone (e.g. actually numbering and sending a
    letter on a correspondence-only licence). Route-level behaviour is proven; business flows are not.
  • Cloud tenancy. Audited in Enterprise (single) mode. The per-tenant intersection is covered by
    the existing entitlements_test.go unit tests, which pass.
  • The 62 skipped routes.

Noted in passing

While the fixes were being verified, the in-flight workflow work un-gated
POST /workflow/tasks/{taskID}/send and moved the check into the handler's letter branch — correct,
since a document send task must complete on a deployment that never licensed correspondence. It
does mean the audit's gated-route count reads 184/51-correspondence rather than 185/52.

One inconsistency worth picking up there: that new in-handler gate returns
license.module_disabled, while every other module gate in the codebase returns
module.not_licensed. The SPA and this audit harness both key on the latter, so as written this one
gate is invisible to both.