-
Notifications
You must be signed in to change notification settings - Fork 12
ADR middleware extensibility
MPS and RPS (both TypeScript / Express.js) allow customers, ISVs, and developers to drop custom request handlers into a middleware/custom/ directory. At startup the service scans that directory, dynamically import()s each .js file, and registers the exported handler in the Express chain before route handlers. This lets operators add custom authentication, multi-tenant routing, or request tracing without modifying or recompiling the core service.
Console is written in Go with the Gin web framework. Go is a compiled language — there is no direct equivalent to Node.js's runtime import() of arbitrary code. console#780 tracks the request to offer equivalent extensibility in Console. The question is: what is the safest, most practical mechanism to do so?
Current MPS/RPS request chain:
sequenceDiagram
actor Client
participant Express as Express
participant Inject as Context injector
participant CMW as Custom middleware (ISV)
participant Route as Route handler
participant DB as Database
Client->>Express: HTTP request /api/v1/...
Express->>Inject: inject req.db, req.secrets
Inject->>CMW: next()
Note over CMW: ISV logic runs here
CMW->>CMW: read header, validate token
CMW->>CMW: set req.tenantId
alt Unauthorised
CMW-->>Client: 401 Unauthorized
else Authorised
CMW->>Route: next()
Route->>DB: query(tenantId)
DB-->>Route: result
Route-->>Client: 200 OK
end
Two paths are supported depending on the operator's deployment model:
-
Compiled-in Gin middleware (Option B) — for operators who are willing to recompile or build Console from source. ISVs implement
gin.HandlerFunc, register it via theRegisterCustomMiddlewarehook, and ship their own Console binary. - API Gateway (Traefik) as external middleware (Option A) — for operators who cannot or do not want to recompile Console, or who deploy Console as a container (Docker runtime or Kubernetes pod). Traefik runs as a sidecar container or ingress controller and handles custom middleware at the network layer without any changes to the Console binary.
Go's compiled nature means that any runtime-loading mechanism either requires CGO (Linux only, breaks static builds) or incurs per-request cross-process overhead that is unacceptable for HTTP middleware. The simplest path consistent with Go's design and Console's existing patterns is to define a stable gin.HandlerFunc hook that ISVs implement and compile in. This is already how JWTAuthMiddleware() works inside Console today.
For operators deploying Console as a container or in Kubernetes, Traefik as an external reverse proxy (sidecar or ingress controller) covers the most common ISV use cases (custom JWT validation, header injection, tenant routing) without any changes to Console.
ISVs configure Traefik middleware via YAML or Docker labels. Console is unchanged.
sequenceDiagram
actor Client
participant Traefik as Traefik (reverse proxy)
participant Console as Console /api/v1
participant DB as Console DB
Note over Traefik: ISV middleware configured via YAML/labels
Client->>Traefik: HTTP request + Authorization header
Traefik->>Traefik: JWT validation
Traefik->>Traefik: inject x-tenant-id header
alt Token invalid
Traefik-->>Client: 401 Unauthorized
else Token valid
Traefik->>Console: forward request + injected headers
Console->>DB: query with tenantId from header
DB-->>Console: result
Console-->>Traefik: 200 OK
Traefik-->>Client: 200 OK
end
Pros: Zero Console source changes; rich built-in middleware (JWT, OAuth2, rate limiting, mTLS); language-agnostic; well-tested in Kubernetes/Docker.
Cons: Adds an operational dependency; no access to Console internals (DB, secrets); not part of the Console binary distribution.
Verdict: ✅ Chosen for operators who cannot or do not want to recompile Console, or who deploy Console as a container (Docker runtime or Kubernetes pod).
ISVs implement gin.HandlerFunc, register it via a RegisterCustomMiddleware hook in app.go, and build their own Console binary.
sequenceDiagram
actor Client
participant Gin as Gin router
participant JWT as JWTAuthMiddleware
participant CMW as Custom gin.HandlerFunc (ISV)
participant Route as Route handler
participant DB as Console DB
Note over CMW: Compiled into Console binary at build time
Client->>Gin: HTTP request /api/v1/...
Gin->>JWT: JWTAuthMiddleware()
alt JWT invalid
JWT-->>Client: 401 Unauthorized
else JWT valid
JWT->>CMW: c.Next()
Note over CMW: ISV logic — read headers,\nset c.Set("tenantId", ...)
alt Custom auth fails
CMW-->>Client: 401 Unauthorized
else Authorised
CMW->>Route: c.Next()
Route->>DB: query with tenantId
DB-->>Route: result
Route-->>Client: 200 OK
end
end
Pros: Native Go; full type safety; full access to Console internals; consistent with existing JWTAuthMiddleware() pattern; no new dependencies; works on all platforms.
Cons: ISVs must recompile Console; requires a stable, versioned API contract for context keys and injected dependencies.
Verdict: ✅ Chosen for ISVs who can recompile Console from source.
Console loads .so shared libraries at runtime from a plugins/ directory.
sequenceDiagram
participant Console as Console (boot — Linux only)
participant FS as plugins/
participant SO as tenant.so (ISV)
participant Gin as Gin chain
Console->>FS: scan for *.so files
FS-->>Console: ["tenant.so"]
Console->>SO: plugin.Open("tenant.so")
SO-->>Console: plugin.Plugin
Console->>SO: Lookup("Middleware")
SO-->>Console: gin.HandlerFunc symbol
Console->>Gin: handler.Use(customMiddleware)
Note over Console,SO: ⚠️ Go toolchain + dependency versions must match exactly
Pros: True runtime loading; no Console recompilation; full access to Go types.
Cons: Linux only; strict ABI coupling to Go toolchain version and all shared dependencies; incompatible with CGO_ENABLED=0 static builds; high ISV maintenance burden on every Console release.
Rejected. Linux-only restriction and ABI fragility make this impractical for production use.
Each plugin runs as a separate OS process; Console communicates via gRPC per request.
sequenceDiagram
participant Console as Console (host)
participant Plugin as ISV plugin binary
participant gRPC as gRPC (localhost)
Note over Console,Plugin: Startup
Console->>Plugin: exec plugin binary
Plugin-->>Console: gRPC server ready (handshake)
Note over Console,Plugin: Per HTTP request
actor Client
Client->>Console: HTTP request /api/v1/...
Console->>gRPC: HandleRequest(headers, body)
gRPC->>Plugin: RPC call
Plugin-->>gRPC: {tenantId, allow: true/false}
gRPC-->>Console: response
alt Denied
Console-->>Client: 401 Unauthorized
else Allowed
Console->>Console: run route handler
Console-->>Client: 200 OK
end
Note over Console,Plugin: ⚠️ gRPC round-trip per request adds latency
Pros: Cross-platform; process isolation; language-agnostic (gRPC); no ABI coupling.
Cons: MPL 2.0 license requires compatibility review; gRPC round-trip per request is unacceptable latency for HTTP middleware; plugin subprocess lifecycle management complexity; no in-process access to Console DB or secrets.
Rejected. Per-request gRPC overhead is unsuitable for HTTP middleware. Suitable for long-lived extension points (e.g., custom secret provider) but not for this use case.
Console embeds a WASM runtime and executes ISV-supplied .wasm modules per request.
sequenceDiagram
participant Console as Console (boot)
participant FS as plugins/
participant WASM as tenant.wasm (ISV)
participant Runtime as WASM runtime (wazero)
Console->>FS: scan for *.wasm files
FS-->>Console: ["tenant.wasm"]
Console->>Runtime: compile + instantiate tenant.wasm
Runtime-->>Console: module instance
actor Client
Client->>Console: HTTP request /api/v1/...
Console->>Runtime: call handle_request(serialised headers)
Runtime->>WASM: execute
WASM-->>Runtime: {tenantId, allow: true/false}
Runtime-->>Console: result
alt Denied
Console-->>Client: 401 Unauthorized
else Allowed
Console->>Console: run route handler
Console-->>Client: 200 OK
end
Note over Console,WASM: ⚠️ No direct access to Console DB/secrets
Pros: Cross-platform sandboxed execution; language-agnostic; no ABI coupling; no Console recompilation.
Cons: Ecosystem too immature for HTTP middleware use cases; no direct access to Console internals without explicit host function exports; non-trivial integration effort; adds binary size and startup overhead.
Rejected. Not mature enough for a near-term implementation story.
| Criterion | A — Traefik | B — Compiled Gin ✅ | C — os/plugin
|
D — hashicorp/go-plugin
|
E — WASM |
|---|---|---|---|---|---|
| No Console recompilation | ✅ | ❌ | ✅ | ✅ | ✅ |
| Cross-platform | ✅ | ✅ | ❌ Linux only | ✅ | ✅ |
| Access to Console internals (DB/secrets) | ❌ | ✅ | ✅ | ❌ (via gRPC only) | ❌ (via host functions) |
| Per-request performance | ✅ | ✅ | ✅ | ❌ gRPC overhead | |
| Operational complexity | ✅ Self-contained | ||||
| License risk | ✅ None | ✅ None | ✅ None | ✅ None | |
| Maturity | ✅ | ✅ | ✅ | ❌ |
- Consistent with Console's existing
JWTAuthMiddleware()pattern — no new concepts introduced. - Full type safety and access to Console internals for ISV middleware.
- No new runtime dependencies or operational complexity inside Console.
- Traefik alternative means operators without Go build tooling are not blocked.
- ISVs must recompile Console — this is a higher barrier than dropping a
.jsfile (MPS/RPS model). - A stable, versioned API contract for
gin.Contextkeys (tenantId, etc.) and injected dependencies must be defined and maintained; breaking changes require ISV recompilation. - No hot-reload — middleware changes require a Console restart.
- MPS middleware source
- RPS middleware source
- Middleware extensibility documentation
- Go
pluginpackage - hashicorp/go-plugin — MPL 2.0
- wazero — WASM runtime for Go