Select tenants and authorize resources
When to use this guide
Section titled “When to use this guide”Use these tasks when routes name tenants or resources that a verified principal must be allowed to address. Install Echo Pro and configure authentication first. Run the executable examples with:
go test -run Example ./tenant/...The tenant overview explains selector conflicts, anonymous access and cancellation. The reference lists all configuration fields.
Select a path tenant and enforce stored ownership
Section titled “Select a path tenant and enforce stored ownership”Construct FromPathParam("tenant") before serving and handle its error. Add it alongside FromCredential(). Keep Required: true on tenant API routes. Add Member for unrestricted users; return membership from trusted data with the request context.
The complete example issues a restricted key, resolves a path tenant and authorizes an invoice from its stored owner. Its immutable map is the fixture repository. The three requests print 204, 403 and 403. The foreign invoice never reaches the handler even though its path names acme.
func ExampleAuthorizeConfig_ToMiddleware() { ctx := context.Background() pepper := make([]byte, 32) if _, err := rand.Read(pepper); err != nil { panic(err) } ring := auth.PepperRing{{Version: 1, Key: pepper}} token, record, err := auth.IssueKey("demo", ring, "invoice-reader", auth.KeyOptions{ Kind: auth.Service, TenantRestriction: "acme", Scopes: []string{"invoice:read"}, }) if err != nil { panic(err) } store := &auth.MemoryKeyStore{Capacity: 1} if err := store.Put(ctx, record); err != nil { panic(err) } path, err := tenant.FromPathParam("tenant") if err != nil { panic(err) } guard, err := authbind.Middleware(auth.Config{ Schemes: map[string]auth.Scheme{"key": auth.APIKey{ Namespace: "invoices", Prefix: "demo", Peppers: ring, Store: store, Header: "X-API-Key", }}, Fallback: auth.Require(map[string][]string{"key": {"invoice:read"}}), }, tenant.Config{Selectors: []tenant.Selector{ tenant.FromCredential(), path, }, Required: true}) if err != nil { panic(err) } policy, err := tenant.Static([]tenant.Grant{{ Scope: "invoice:read", ResourceType: "invoice", Actions: []string{"read"}, }}) if err != nil { panic(err) } // This immutable map stands in for a repository lookup made with the // resolver's c.Request().Context(). Ownership comes from stored data. owners := map[string]tenant.ID{"inv-1": "acme", "inv-2": "globex"} check, err := (tenant.AuthorizeConfig{Authorizer: policy, Action: "read", Resource: func(c *echo.Context) (tenant.Resource, error) { id := c.Param("invoice") return tenant.Resource{Type: "invoice", ID: id, Tenant: owners[id]}, nil }, }).ToMiddleware() if err != nil { panic(err) } e := echo.New() e.HTTPErrorHandler, err = (echoadapter.ErrorHandlerConfig{}).ToErrorHandler( e.HTTPErrorHandler, ) if err != nil { panic(err) } e.Group( "/tenants/:tenant", guard, ).GET("/invoices/:invoice", func(c *echo.Context) error { return c.NoContent(http.StatusNoContent) }, check) for _, path := range []string{ "/tenants/acme/invoices/inv-1", "/tenants/acme/invoices/inv-2", "/tenants/globex/invoices/inv-2", } { r := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil) r.Header.Set("X-API-Key", token) w := httptest.NewRecorder() e.ServeHTTP(w, r) fmt.Println(w.Code) } // Output: // 204 // 403 // 403}Replace the map with a repository lookup using c.Request().Context(). Return the resource tenant from that lookup, not from c.Param("tenant"). Any error from Resource, including not found, returns 403. The middleware applies the shared timeout and refuses a mismatched owner before policy.
If your application wants 404 for missing resources, check existence after authentication and tenant selection, before resource authorization. This example checks within the selected tenant and passes existing resources on to the authorization middleware. The resolver must still check ownership; account for deletion between the lookup and authorization.
// MountInvoice checks existence within the selected tenant before authorization.func MountInvoice(e *echo.Echo, exists func(context.Context, tenant.ID, string) (bool, error), authorize echo.MiddlewareFunc, handler echo.HandlerFunc) { checkExists := func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { ctx := c.Request().Context() id, ok := tenant.From(ctx) if !ok { return echo.ErrForbidden } found, err := exists(ctx, id, c.Param("id")) if err != nil { return err } if !found { return echo.ErrNotFound } return next(c) } } // Install authentication and tenant selection before this route. e.GET("/invoices/:id", handler, checkExists, authorize)}Alternatively, authorize a collection-level resource and check item existence in the handler, returning 404 there. Returning echo.ErrNotFound from Resource still produces 403.
Select headers, subdomains or anonymous tenant pages
Section titled “Select headers, subdomains or anonymous tenant pages”This executable fixture combines path, header and subdomain selectors for an unrestricted service key. The membership callback permits only acme. It prints 204 for agreement, 403 for conflicting selectors, 400 for a malformed header and 403 for failed membership.
func ExampleConfig_selectors() { ctx := context.Background() pepper := make([]byte, 32) if _, err := rand.Read(pepper); err != nil { panic(err) } ring := auth.PepperRing{{Version: 1, Key: pepper}} token, record, err := auth.IssueKey( "demo", ring, "worker", auth.KeyOptions{Kind: auth.Service}, ) if err != nil { panic(err) } keys := &auth.MemoryKeyStore{Capacity: 1} if err := keys.Put(ctx, record); err != nil { panic(err) } path, err := tenant.FromPathParam("tenant") if err != nil { panic(err) } header, err := tenant.FromHeader("X-Tenant") if err != nil { panic(err) } host, err := tenant.FromSubdomain("api.example") if err != nil { panic(err) } guard, err := authbind.Middleware(auth.Config{ Schemes: map[string]auth.Scheme{"key": auth.APIKey{ Namespace: "services", Prefix: "demo", Peppers: ring, Store: keys, }}, Fallback: auth.Require(map[string][]string{"key": nil}), }, tenant.Config{ Selectors: []tenant.Selector{path, header, host}, Required: true, Member: func(ctx context.Context, p auth.Principal, id tenant.ID) (bool, error) { // Replace this immutable fixture with a context-aware membership read. if err := ctx.Err(); err != nil { return false, err } return p.ID() == "services:worker" && id == "acme", nil }, }) if err != nil { panic(err) } e := echo.New() e.GET("/tenants/:tenant", func(c *echo.Context) error { return c.NoContent( http.StatusNoContent, ) }, guard) for _, tc := range []struct{ path, header, host string }{ {"acme", "acme", "acme"}, {"acme", "other", "acme"}, {"acme", "bad space", "acme"}, {"other", "other", "other"}, } { r := httptest.NewRequestWithContext( ctx, http.MethodGet, "http://"+tc.host+".api.example/tenants/"+tc.path, nil, ) r.Header.Set("Authorization", "ApiKey "+token) r.Header.Set("X-Tenant", tc.header) w := httptest.NewRecorder() e.ServeHTTP(w, r) fmt.Println(w.Code) } // Output: // 204 // 403 // 400 // 403}Use FromHeader only with a credential restriction or a Member check. Otherwise the header alone picks the tenant. Use FromSubdomain when one tenant label sits directly below a trusted base host. The selector reads Request.Host, so configure proxy host handling explicitly. Both constructors validate configuration and return errors.
For tenant-addressed public pages, set AnonymousSelector deliberately. Ordinary selectors do not authorize anonymous tenant access. Keep public metering separate if AuthBySpec skips the authenticated chain. Test empty selectors, conflicting selectors, malformed IDs and membership errors before shipping a route change.
This explicitly public route returns 200 acme without a credential:
func ExampleConfig_anonymous() { selector, err := tenant.FromPathParam("tenant") if err != nil { panic(err) } middleware, err := (tenant.Config{ Required: true, AnonymousSelector: selector, }).ToMiddleware() if err != nil { panic(err) } e := echo.New() e.GET("/public/:tenant", func(c *echo.Context) error { id, _ := tenant.From(c.Request().Context()) return c.String(http.StatusOK, string(id)) }, middleware) w := httptest.NewRecorder() e.ServeHTTP(w, httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/public/acme", nil, )) fmt.Println(w.Code, w.Body.String()) // Output: 200 acme}Use Cerbos
Section titled “Use Cerbos”Map roles and selected attributes from trusted application data. This example calls a local protocol fixture and prints true <nil>. It checks adapter wiring; it does not test an installed Cerbos policy.
func ExampleCerbos() { server := httptest.NewServer(http.HandlerFunc(func( w http.ResponseWriter, _ *http.Request, ) { if err := json.NewEncoder( w, ).Encode(map[string]any{"results": []any{map[string]any{ "resource": map[string]string{"id": "inv-1", "kind": "invoice"}, "actions": map[string]string{"read": "EFFECT_ALLOW"}, }}}); err != nil { panic(err) } })) defer server.Close() policy, err := tenant.Cerbos(tenant.CerbosConfig{ HTTP: tenant.HTTPConfig{ URL: server.URL, Client: server.Client(), MaxConcurrent: 8, }, Principal: func(tenant.Check) (tenant.CerbosPrincipal, error) { // Map roles from trusted application data, never arbitrary claims. return tenant.CerbosPrincipal{Roles: []string{"invoice-reader"}}, nil }, }) if err != nil { panic(err) } principal, err := auth.NewPrincipal(auth.PrincipalConfig{ Namespace: "employees", Subject: "alice", Scheme: "oauth", Kind: auth.User, }) if err != nil { panic(err) } decision, err := policy.Authorize(context.Background(), tenant.Check{ Principal: principal, Tenant: "acme", Action: "read", Resource: tenant.Resource{Type: "invoice", ID: "inv-1", Tenant: "acme"}, }) fmt.Println(decision == tenant.Allow, err) // Output: true <nil>}Set HTTP.URL to the deployed service origin and supply a caller-owned client. Configure HTTP.Timeout and MaxConcurrent for the policy service’s capacity. Inject service credentials into AuthHeader and AuthValue. Use HTTPS for those credentials. Add a real-service test for the resource kind, action and role mapping used by your deployment.
Use OpenFGA
Section titled “Use OpenFGA”Pin the authorization model used to evaluate the tuple. Supply the action-to-relation mapping. This local fixture demonstrates construction and the check call without creating a remote store or model:
func ExampleOpenFGA() { server := httptest.NewServer(http.HandlerFunc(func( w http.ResponseWriter, _ *http.Request, ) { if err := json.NewEncoder(w).Encode( map[string]bool{"allowed": true}, ); err != nil { panic(err) } })) defer server.Close() policy, err := tenant.OpenFGA(tenant.OpenFGAConfig{ HTTP: tenant.HTTPConfig{URL: server.URL, Client: server.Client()}, StoreID: "invoice-store", AuthorizationModelID: "reviewed-model-v1", ActionRelations: map[string]string{"read": "reader"}, }) if err != nil { panic(err) } principal, err := auth.NewPrincipal(auth.PrincipalConfig{ Namespace: "employees", Subject: "alice", Scheme: "oauth", Kind: auth.User, }) if err != nil { panic(err) } decision, err := policy.Authorize(context.Background(), tenant.Check{ Principal: principal, Tenant: "acme", Action: "read", Resource: tenant.Resource{Type: "invoice", ID: "inv-1", Tenant: "acme"}, }) fmt.Println(decision == tenant.Allow, err) // Output: true <nil>}Replace the fixture identifiers with your existing store and reviewed model ID. Missing action mappings deny. Use the validated type, tenant and resource IDs. The OpenFGA adapter rejects characters that could inject object separators. Configure User only when your model needs a different trusted user mapping.
Use an application policy or Casbin
Section titled “Use an application policy or Casbin”AuthorizerFunc receives the caller context and a Check containing the principal, tenant, action and resolved resource. Return only (tenant.Allow, nil) to grant access. Preserve cancellation in repository calls. Static supplies a copied scope policy for applications that do not need a remote engine.
Casbin(enforcer, mapping) retains a caller-owned enforcer. Nil mapping uses the principal ID, tenant, Type+":"+ID and action. Configure enforcer concurrency before serving. Its Enforce implementation must return promptly because the adapter cannot forcibly interrupt it.
This executable fixture checks both the default four-argument model and a custom three-argument mapping. It prints true <nil> twice. The local enforcer checks the mapped arguments; it does not load a Casbin policy file. Supply your application’s enforcer and test its model separately.
// exampleEnforcer supplies a local model fixture with Casbin's Enforce shape.// A deployed application supplies its own concurrency-safe Casbin enforcer.type exampleEnforcer func(...any) (bool, error)
func (f exampleEnforcer) Enforce(args ...any) (bool, error) { return f(args...) }
func ExampleCasbin() { principal, err := auth.NewPrincipal(auth.PrincipalConfig{ Namespace: "employees", Subject: "alice", Scheme: "oauth", Kind: auth.User, }) if err != nil { panic(err) } check := tenant.Check{Principal: principal, Tenant: "acme", Action: "read", Resource: tenant.Resource{Type: "invoice", ID: "inv-1", Tenant: "acme"}} // This model expects subject, domain, object and action in that order. model := exampleEnforcer(func(args ...any) (bool, error) { return len(args) == 4 && args[0] == "employees:alice" && args[1] == "acme" && args[2] == "invoice:inv-1" && args[3] == "read", nil }) policy, err := tenant.Casbin(model, nil) if err != nil { panic(err) } decision, err := policy.Authorize(context.Background(), check) fmt.Println(decision == tenant.Allow, err) // A different model accepts subject, a tenant-qualified object and action. model = exampleEnforcer(func(args ...any) (bool, error) { return len(args) == 3 && args[0] == "employees:alice" && args[1] == "invoice:acme/inv-1" && args[2] == "read", nil }) policy, err = tenant.Casbin(model, func(c tenant.Check) ([]any, error) { return []any{ c.Principal.ID(), c.Resource.Type + ":" + string(c.Tenant) + "/" + c.Resource.ID, c.Action, }, nil }) if err != nil { panic(err) } decision, err = policy.Authorize(context.Background(), check) fmt.Println(decision == tenant.Allow, err) // Output: // true <nil> // true <nil>}Test refusals before quota charges the request
Section titled “Test refusals before quota charges the request”Build own-tenant and foreign-tenant test tables for every resource type. Assert that resource mismatch, policy error and cancellation leave quota/store/handler counters unchanged. tenanttest.Authorizer and tenanttest.Refused provide those checks. For 403 forbidden, obtain the correct grant or select an allowed resource.
Automatic retries cannot fix a tenant mismatch.
This test checks the policy table and sends a foreign-owned invoice through Echo. Its counters show that the ownership check rejects the request before the authorizer or handler runs:
func TestSiteTenantIsolation(t *testing.T) { principal, err := auth.NewPrincipal(auth.PrincipalConfig{ Namespace: "employees", Subject: "alice", Scheme: "oauth", Kind: auth.User, Scopes: []string{"invoice:read"}, }) if err != nil { t.Fatal(err) } policy, err := tenant.Static([]tenant.Grant{{ Scope: "invoice:read", ResourceType: "invoice", Actions: []string{"read"}, }}) if err != nil { t.Fatal(err) } own := tenant.Check{Principal: principal, Tenant: "acme", Action: "read", Resource: tenant.Resource{Type: "invoice", ID: "inv-1", Tenant: "acme"}} foreign := own foreign.Resource.Tenant = "globex" tenanttest.Authorizer(t, policy, []tenanttest.Case{ {Name: "own invoice", Check: own, Want: tenant.Allow}, {Name: "foreign invoice", Check: foreign, Want: tenant.Deny}, }) pepper := make([]byte, 32) if _, err := rand.Read(pepper); err != nil { t.Fatal(err) } ring := auth.PepperRing{{Version: 1, Key: pepper}} credential, record, err := auth.IssueKey("test", ring, "alice", auth.KeyOptions{ Kind: auth.User, TenantRestriction: "acme", Scopes: []string{"invoice:read"}, }) if err != nil { t.Fatal(err) } keys := &auth.MemoryKeyStore{Capacity: 1} if err := keys.Put(t.Context(), record); err != nil { t.Fatal(err) } guard, err := authbind.Middleware(auth.Config{ Schemes: map[string]auth.Scheme{"key": auth.APIKey{ Namespace: "employees", Prefix: "test", Peppers: ring, Store: keys, }}, Fallback: auth.Require(map[string][]string{"key": {"invoice:read"}}), }, tenant.Config{Selectors: []tenant.Selector{ tenant.FromCredential(), }, Required: true}) if err != nil { t.Fatal(err) } // The middleware must refuse ownership before policy or business work. resourceCalls, policyCalls, handlerCalls := 0, 0, 0 check, err := (tenant.AuthorizeConfig{ Authorizer: tenant.AuthorizerFunc(func(context.Context, tenant.Check) ( tenant.Decision, error, ) { policyCalls++ return tenant.Allow, nil }), Action: "read", Resource: func(*echo.Context) (tenant.Resource, error) { resourceCalls++ return foreign.Resource, nil }, }).ToMiddleware() if err != nil { t.Fatal(err) } e := echo.New() e.GET("/invoices/inv-1", func(c *echo.Context) error { handlerCalls++ return c.NoContent(http.StatusNoContent) }, guard, check) r := httptest.NewRequestWithContext( t.Context(), http.MethodGet, "/invoices/inv-1", nil, ) r.Header.Set("Authorization", "ApiKey "+credential) w := httptest.NewRecorder() e.ServeHTTP(w, r) if w.Code != http.StatusForbidden || resourceCalls != 1 { t.Fatalf( "status=%d resource lookups=%d; want 403 and one lookup", w.Code, resourceCalls, ) } tenanttest.Refused(t, w.Code, policyCalls, handlerCalls)}