Grove
Middleware

Namespace Middleware

Automatic key prefixing for multi-tenant isolation in KV stores.

The namespace middleware automatically prepends a prefix to all keys, providing logical isolation between tenants, environments, or application modules. This eliminates the need to manually prefix keys throughout your application code.

Installation

import "github.com/xraph/grove/kv/middleware"

Usage

import (
    "github.com/xraph/grove/kv"
    "github.com/xraph/grove/kv/middleware"
)

store, err := kv.Open(drv,
    kv.WithHook(middleware.NewNamespace("tenant:acme")),
)

// All operations are transparently prefixed:
// store.Set(ctx, "user:123", value)  -->  stored as "tenant:acme:user:123"
// store.Get(ctx, "user:123", &dest)  -->  fetches "tenant:acme:user:123"

Constructor

func NewNamespace(prefix string, separator ...string) *NamespaceHook
ParameterDescription
prefixThe namespace prefix to prepend to all keys
separatorOptional separator between prefix and key (default: ":")

Custom Separator

// Uses "/" as separator: "tenant/acme/user:123"
middleware.NewNamespace("tenant/acme", "/")

// Uses "." as separator: "tenant.acme.user:123"
middleware.NewNamespace("tenant.acme", ".")

How It Works

The namespace hook implements PreQueryHook. Before any KV operation reaches the driver, it modifies the keys by prepending the configured prefix and separator:

Application:  store.Get(ctx, "user:123", &dest)
                |
                v
Namespace Hook: key = "tenant:acme" + ":" + "user:123"
                      = "tenant:acme:user:123"
                |
                v
Driver:         GET "tenant:acme:user:123"

This transformation applies to all operations (GET, SET, DELETE, MSET, etc.) and all keys in multi-key operations.

Multi-Tenancy Isolation

The primary use case for namespace middleware is multi-tenant key isolation. Each tenant's data is stored under a unique prefix, preventing key collisions between tenants.

Static Tenant Prefix

For applications where the tenant is known at store creation time:

func newStoreForTenant(drv driver.Driver, tenantID string) (*kv.Store, error) {
    return kv.Open(drv,
        kv.WithHook(middleware.NewNamespace("t:"+tenantID)),
    )
}

// Tenant "acme" store: all keys prefixed with "t:acme:"
acmeStore, _ := newStoreForTenant(drv, "acme")

// Tenant "globex" store: all keys prefixed with "t:globex:"
globexStore, _ := newStoreForTenant(drv, "globex")

Extracting Tenant from Context

For request-scoped tenancy where the tenant ID comes from the request context, you can create a store per request or use a custom hook that reads the tenant from context:

type DynamicNamespaceHook struct{}

var _ hook.PreQueryHook = (*DynamicNamespaceHook)(nil)

func (h *DynamicNamespaceHook) BeforeQuery(ctx context.Context, qc *hook.QueryContext) (*hook.HookResult, error) {
    tenantID := tenant.FromContext(ctx)
    if tenantID == "" {
        return &hook.HookResult{Decision: hook.Deny, Error: errors.New("missing tenant")}, nil
    }

    if keys, ok := qc.Values["_kv_keys"].([]string); ok {
        modified := make([]string, len(keys))
        for i, k := range keys {
            modified[i] = "t:" + tenantID + ":" + k
        }
        qc.Values["_kv_keys"] = modified
        if len(modified) > 0 {
            qc.RawQuery = modified[0]
        }
    }

    return &hook.HookResult{Decision: hook.Modify}, nil
}

Environment Isolation

Namespace middleware is also useful for isolating environments that share a single KV backend:

// Development
store, _ := kv.Open(drv, kv.WithHook(middleware.NewNamespace("dev")))

// Staging
store, _ := kv.Open(drv, kv.WithHook(middleware.NewNamespace("staging")))

// Production
store, _ := kv.Open(drv, kv.WithHook(middleware.NewNamespace("prod")))

Example: Tenant-Based Key Isolation

A complete example showing how namespace middleware isolates tenant data on a shared KV store:

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/xraph/grove/kv"
    "github.com/xraph/grove/kv/middleware"
    "github.com/xraph/grove/kv/driver/boltdriver"
)

func main() {
    drv := boltdriver.New()
    drv.Open(context.Background(), "shared.db")

    // Create isolated stores for two tenants
    acmeStore, _ := kv.Open(drv,
        kv.WithHook(middleware.NewNamespace("tenant:acme")),
    )
    globexStore, _ := kv.Open(drv,
        kv.WithHook(middleware.NewNamespace("tenant:globex")),
    )

    ctx := context.Background()

    // Both tenants can use the same key names without collision
    acmeStore.Set(ctx, "config", map[string]string{"theme": "blue"})
    globexStore.Set(ctx, "config", map[string]string{"theme": "green"})

    // Each tenant sees only their own data
    var acmeConfig, globexConfig map[string]string
    acmeStore.Get(ctx, "config", &acmeConfig)     // {"theme": "blue"}
    globexStore.Get(ctx, "config", &globexConfig)  // {"theme": "green"}
}

Under the hood, the backing store contains two distinct keys: tenant:acme:config and tenant:globex:config.

API Reference

MethodDescription
NewNamespace(prefix, separator...)Create a new namespace hook with the given prefix and optional separator

On this page