← All work
Case studySaaS backend · Cloud marketplace

Licensing & Entitlement Platform

A production licensing backend for a cloud marketplace. The marketplace sells the add-on; this service decides what the buyer is entitled to, signs the licence, and answers the software that asks whether it may run — online, offline, behind NAT, or with no internet at all.

One Go binary with the operator console compiled into it, deployed as one container image. No microservice sprawl, no framework magic — a small number of deliberate architectural decisions, each enforced by a test that fails the build when someone drifts from it.

Role
Technical lead, architecture & coding
Domain
B2B licensing · subscriptions · entitlement
Shape
One Go binary · one container image
Status
Shipped to production
The problem
A cloud marketplace sells the add-on, but something has to decide what each buyer may run — online, offline, behind NAT or during a vendor outage.
What was built
Morsalin led the design and did the coding: one Go service with a Vue operator console that provisions purchases, signs licences and answers entitlement checks.
The result
Shipped to production. Licences verify offline, so a vendor outage never stops a paying customer, and support moved from engineering to audited operator actions.
~31klines of production Go
~23klines of Go tests
654Go test functions
65Playwright E2E tests
69console API routes
41console pages
39database tables
99verified docs
01The problem

A marketplace sells it. Everything after the sale is yours.

A cloud marketplace handles the transaction and the billing. It does not handle anything a software vendor actually needs once the purchase is made.

Q1

Which plan did they buy, and what does it permit?

The marketplace sends a plan slug and expects config variables back within a hard deadline.

Q2

How does software on a customer machine prove it may run?

It may be behind NAT, rebuilt every deploy, cloned, or unable to reach the internet at all.

Q3

What happens when the subscription lapses?

The marketplace is silent on enforcement after a deprovision — the policy is the vendor’s to define and defend.

Q4

What is authoritative?

The marketplace publishes no way to read a resource back. Lose the record of what was sold, and it is gone.

So the service has to be the system of record, answer a hard real-time contract on the purchase path, and keep paying customers running when it is itself unreachable. Those three constraints drive nearly everything that follows.
02What it does

Six areas, all shipped and under test.

Marketplace integration

  • Synchronous and asynchronous provisioning inside the response deadline
  • Plan changes, deprovisioning and lifecycle notifications
  • Replay safety enforced by database constraint
  • OAuth grant exchange, config variable push, SSO handoff

Licensing

  • Three strategies: IP binding, activation code, none
  • ed25519 signing with per-product keys and safe rotation
  • Dependency-free offline verification SDK
  • 160-bit activation codes looked up by digest

Entitlement

  • Four states, ten reasons, over HTTP and websocket
  • Grace windows so a billing blip never stops a customer
  • Installation seats with least-recently-seen eviction
  • Revoke, reissue, rebind and reset

Events & webhooks

  • Transactional outbox — events recorded in the causing transaction
  • 14 event kinds, HMAC-signed, at-least-once
  • Dead letters surfaced separately from retries
  • SSRF-protected destinations

Identity & access

  • OIDC provider for dashboard sign-on
  • 26 permissions, 5 roles, 3 scope kinds
  • argon2id + mandatory TOTP, recovery codes
  • Hashed, shown-once service-account tokens

Operations

  • 12-group CLI in the same binary
  • 28 settings editable without a redeploy — only 3 env vars
  • Secret references hot-reloaded on SIGHUP
  • Dry-run by default for destructive commands
03Architecture

Three listeners are the security boundary.

fig.02 — system architecturethree listeners · one binary
Cloud marketplacevendor API · basic authLicensed softwareVM · NAT · standaloneOperatorspassword + TOTPPrometheusmetrics tokenONE GO BINARYservice + CLI + console · one container image:8080public — unauthenticated surface designed not to be an oracleVendor APIprovision · plan change · deprovision · SSOEntitlement APIHTTP + websocket, one implementationOIDC providerdashboard sign-on onlyHealthliveness · readiness:8081console — SPA fallback never reaches the public port/admin/api/*69 routes · scoped RBACEmbedded Vue SPAcompiled in with go:embed:8082metrics — 35 domain metrics · token-guarded expositionbackground · 5 worker loops — marketplace · outbox · retention · rekey · keyset reloadMariaDB39 tables · 19 encrypted columns

Three ports, not one

The public surface, the operator console and the metrics exposition each bind their own port. The console’s single-page app answers any unclaimed path — exactly why it must never sit on the public listener. Route registration lives in one function so a test can read the result: a surface added to the wrong listener fails the build instead of being published.

Configuration lives in the database

Exactly three environment variables exist. The other 28 settings are rows, editable from the console, with values that can be Secret Manager references. They take effect at restart by design — the encryption key set is the one exception, reloaded on SIGHUP, because widening what can be read harms nothing mid-request.

04Entitlement engine

May this software run? One answer, two transports.

VM client · POST /v1/entitlement/validate
NAT client · GET /v1/entitlement/ws
Service.Validate
the only implementation
database

The transports only decode, call and encode. This is the most important invariant in the codebase: a rule enforced over HTTP and forgotten over the websocket is a licence bypass, and the only reliable prevention is exactly one implementation. The websocket exists for clients behind NAT that cannot be polled.

activeEntitled
graceEntitled, warn the user
provisioningPurchase still completing
deniedNot entitled — one of ten reasons

Three licensing strategies

StrategyBound byCaller can assert it?Survives NATSurvives a rebuild
ip_bindingAddress observed on the connectionNoNoNo — needs a rebind
activation_codeThe plan’s seat ceilingYes — self-reported install idYesYes
noneThe SSO token; nothing is issued

Offline verification

Every licence is ed25519-signed. A standalone Go package with no dependencies beyond the standard library verifies it on the customer’s machine — a public key verifies licences, it cannot mint them.

// verify on start, keep running if valid
pub, err := licensecheck.DecodePublicKey(embeddedKey)
claims, err := licensecheck.Verify(pub, licenceKey)

Grace with teeth

A suspended or deprovisioned resource stays entitled for a configured window — 72 hours by default — and says so with a grace_until timestamp, so software can warn rather than stop. Suspension usually means a late invoice.

Grace lives on the listing — the vendor’s tolerance for late payment. Seat ceilings, which a buyer pays for, live on the plan.

05Security

Designed so the wrong thing cannot ship.

01

Per-column encryption, rotated online

19 columns sealed with AES-GCM, each ciphertext bound to its row by associated data — a value lifted from one row fails to decrypt in another. Every value names its key, so rotation is an online operation with per-column status proving when it is finished.

02

SSRF checked at dial time

A callback URL that resolved publicly when stored can resolve to 169.254.169.254 by the time a worker posts an access token to it. Addresses are checked when dialled and redirects are refused — a finding from a mutation test, not a hypothetical.

03

Denials that say nothing

The entitlement endpoint is unauthenticated by necessity, so it is designed not to be an oracle: unknown credentials are indistinguishable, malformed bodies look the same, and a rate-limit refusal is a 429 with an empty body.

04

Authorization as a route table

Every endpoint declares its permission, target resolver, or a named exemption saying why it needs none. The router is built from that table and a test compares the two — an unguarded endpoint fails the build.

05

An OIDC provider for one flow

Buyers are handed from the marketplace to a vendor dashboard via a verified HMAC handoff and a single-use code. The authorize endpoint is deliberately disabled and no refresh token is issued — there is nothing for a buyer to sign in to.

06

Customers never authenticate

A buyer has no account, password or session here — not even a short-lived one. Removing that boundary removes an entire category of attack surface; customer machines are not IAM principals either.

Who proves what

CallerProves it byGets
MarketplaceBasic auth, per listingThe four vendor endpoints for that listing
Marketplace, at SSOHMAC-SHA256 keyed by the listing saltA single-use handoff
Licensed softwareActivation code, licence key or bound addressOne question: may it run
A dashboardclient_secret_basic at the token endpointA token for one buyer
An operatorPassword + TOTPA console session, within their grants
A machineBearer tokenIts grants, minus four forbidden permissions
06Operator console

41 pages, compiled into the binary.

A Vue 3 + Vuetify single-page console in TypeScript, embedded with go:embed so the deployable stays one file. Accessibility is treated as a correctness property: colour never carries meaning alone, filtering is announced to screen readers, and dialog focus is shared behaviour rather than per-page improvisation.

Route dialogs

A dialog that is a route, so Escape, the scrim, Cancel and Back all agree on what closing means.

State chips

State as colour and icon, so it survives a screenshot and a reader who cannot distinguish hue.

Live list status

A visually hidden live region announcing how many rows a filtered table now shows.

One API client

CSRF token held in memory, not localStorage; 401 handled once; a 30-second timeout so no spinner runs forever.

Two places told an operator to create a signing key from a product page that could not do it — and a product with no key fails every purchase. The control now lives where the text pointed, and the product page shows the key, or none — cannot issue a licence, matching on usable rather than merely active.

What does the person need at this moment, and what does the screen let them believe that is not true?

07DevOps & cloud

One image. A pipeline that stops at an artifact.

Multi-stage build

nodegodebian-slim
  • CGO_ENABLED=0 -trimpath -s -w — no libc coupling, no build paths, no symbols
  • ca-certificates installed explicitly — otherwise every outbound TLS call fails
  • Dedicated non-root UID, no home, nologin shell; writes nothing to disk
  • HEALTHCHECK runs the binary’s own health subcommand — no curl in the image
  • Exec-form ENTRYPOINT, so SIGTERM and the key-reload SIGHUP actually arrive

Build & release

  1. Cloud Build
    build image
  2. Smoke test
    --version inside image
  3. Artifact Registry
    SHA + latest
  4. Human deploy
    compose pull && up -d

“A build that can restart production is a merge that can restart production.”

CI — real MariaDB and SMTP service containers, no mocks

gofmtgo vetfull suitedev-tagged suiterace detectorstaticcheck (pinned)govulncheckPlaywright + Mailpit
08Testing

Tests that make decisions unbreakable.

654 Go test functions and 65 end-to-end tests driven through the real UI. The real contribution is a category: structural tests that fail the build when an architectural decision is violated, rather than only examples of behaviour.

Route table ↔ routerimpossibleAn endpoint registered but undeclared, or declared but unrouted
Listener mount testimpossibleA surface published on the wrong port
Argon2id router walkimpossibleA password check reachable without rate limiting
OIDC / IAM separationimpossibleA buyer-facing token accepted by a console route
Wipe covers every tableimpossibleA migration adding a table the dev wipe does not clear
Encrypted-column inventoryimpossibleAn encrypted column missing from key rotation
Binding matches columnimpossibleAssociated data drifting from the column it protects
Literal env lookups onlyimpossibleConfiguration escaping the settings registry
Metric label cardinalityimpossibleA metric label taking a caller-chosen value
09Observability

Metrics chosen for what a failure makes visible.

35 domain metrics plus four HTTP families. A Prometheus registry never releases a series, so no label ever takes a value the caller chose: the Host header is emptied, methods collapse to an allowlist, and 404 paths collapse — keeping the 404 rate visible without keeping the paths.

Structured JSON logs, separate liveness and readiness, and a documented alerting guide round it out.

async provisioning backlog

Accepted-but-unreported, overdue past SLA, age of the oldest — the only place a customer workflow that died shows up at all.

rekey_values_unreadable

Must be zero. Non-zero means data sealed under a key the process no longer holds.

licenses_issued_unbound_total

A licence issued without its IP binding — previously visible only as one ERROR log line.

10Key decisions

Every non-obvious choice, with its reason.

Three listeners on three ports

Which listener answers what is the security boundary; a test reads the mount table so a misplaced surface fails the build.

Configuration in the database

Operators change behaviour without a redeploy; only three environment variables exist and secrets stay references.

Settings apply at restart

A service that changes behaviour mid-request cannot answer “what was the config when this happened?”

One Validate, two transports

A rule enforced over one transport and forgotten over the other is a licence bypass.

Signed licences for offline use

The vendor’s outage must not take a paying customer’s software down.

Grace on the listing, seats on the plan

Grace is tolerance for late payment; seats are what a buyer pays for.

4xx abandons, 5xx retries, 429 waits

Retrying a rejected request forever hides a contract violation as a transient fault.

Events recorded in-transaction

A subscriber being down must never fail the change that produced the event.

404 rather than 403 outside scope

The existence of a resource is itself information.

Uniqueness constraints for idempotency

A replayed provision is a replay because the database says so, not because a code path remembered.

Pipeline stops at an image

A build that can restart production is a merge that can restart production.

Two-role build identity

Push a container without the default account’s roles/editor and access to every secret.

11Problems solved

Latent failures, found and closed.

  1. 1

    A licence issued without its binding

    Failing the purchase would be worse, but the buyer held a key that answered ip_not_bound to every check, with one log line as evidence. It is now counted by reason, and the console shows the live binding beside the reported address.

  2. 2

    An access token handed to a redirect target

    Found by a mutation test. Fixed by refusing redirects and checking addresses at dial time — with a deliberately permissive client kept for the operator-configured API base, because the two cases deserve opposite treatment.

  3. 3

    Unbounded metric cardinality

    Three label leaks on a public listener, found by scraping a running binary and in review. Fixed with an allowlist-and-collapse strategy rather than one-off patches, locked down by a test.

  4. 4

    A guard that had never run

    The test that catches a table the dev wipe misses had been broken for two migrations, because go test ./... never compiled its build tag. It now has its own CI step.

  5. 5

    A wrong encryption key was nearly invisible

    The service started, readiness stayed green and existing licences validated — only new purchases failed, one at a time. Moving configuration into the database made it fail at startup with a message naming the cause.

12Outcome

From nothing to production, in one image.

A single container one person can roll forward or back with two commands — and the vendor’s system of record for every resource, licence and buyer.

The revenue path

Purchase to signed licence inside the marketplace deadline, replay-safe, on two provisioning models.

The enforcement path

Entitlement over two transports, with offline verification so a vendor outage never stops a customer.

The operations path

A 41-page console, a 12-group CLI, and runbooks for every failure the design anticipates.

The trust path

Per-column encryption with zero-downtime rotation, scoped RBAC, and a public surface that is not an oracle.

  • Support moved from engineering to operators — revoking, rebinding, releasing seats, merging buyers and redelivering webhooks are audited console actions.
  • Failures surface before customers report them: each anticipated fault has a metric and a documented response.
  • Twenty-eight settings change without a deploy, including the mail relay, proxy mode and retention windows.
  • Onboarding is reading, not archaeology — every non-obvious decision is recorded with the alternative that was rejected.

My role

I led the design and the engineering decisions: the security boundaries, the data model, the failure taxonomy, the deployment shape, the test strategy, and what the documentation had to prove. I also did the coding, working with AI pair-programming — I set the architecture, wrote the implementation, drove the reviews, and owned what shipped.

Stack

Service
Go 1.26 · Echo v5 · GORM · goose · Cobra
Console
Vue 3.5 · Vuetify 4 · TypeScript 5.9 · Vite 8 · Pinia
Data
MariaDB — 39 tables, 19 individually encrypted columns
Identity
OIDC provider · scoped RBAC · TOTP · argon2id
Crypto
ed25519 licence signing · AES-GCM per column · online rotation
Cloud
GCP — Cloud Build · Artifact Registry · Secret Manager
CI
GitHub Actions — service containers, race detector, staticcheck, govulncheck
Ops
Prometheus · structured JSON logs · liveness/readiness · Compose
GoVue 3TypeScriptSystem architectureAPI designApplied cryptographyOAuth 2.0 / OIDCRBAC & multi-tenancyApplication securitySQL schema designDockerCI/CDGCPPrometheusTechnical documentation

Described generically — company, product, domain and infrastructure identifiers are deliberately omitted.

Next

Building something that has to be right?

Get in touch →
© 2026 MD Morsalinbuilt with care · OSS-first · sustainable by design
lat: 23.81°Nlon: 90.41°Etz: UTC+6status: 200 OK