Skip to content

ADR middleware extensibility

Pola, Sudhir edited this page Apr 27, 2026 · 1 revision

ADR: Middleware Extensibility for Console (Go)

Status: Proposed

Context

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
Loading

Decision

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 the RegisterCustomMiddleware hook, 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.

Rationale

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.

Alternatives Considered

Option A — API Gateway (Traefik) as external middleware

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
Loading

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

Option B — Compiled-in Gin middleware

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
Loading

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.

Option C — Go plugin package (os/plugin)

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
Loading

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.

Option D — hashicorp/go-plugin (subprocess-based plugins)

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
Loading

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.

Option E — WASM-based plugins (Extism / wazero)

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
Loading

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.

Options Comparison

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 ⚠️ External dependency ✅ Self-contained ⚠️ CGO + Linux ⚠️ Subprocess mgmt ⚠️
License risk ✅ None ✅ None ✅ None ⚠️ MPL 2.0 ✅ None
Maturity ⚠️

Consequences

Positive

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

Negative / Risks

  • ISVs must recompile Console — this is a higher barrier than dropping a .js file (MPS/RPS model).
  • A stable, versioned API contract for gin.Context keys (tenantId, etc.) and injected dependencies must be defined and maintained; breaking changes require ISV recompilation.
  • No hot-reload — middleware changes require a Console restart.

References

Clone this wiki locally