DEV Community

EphraimPierce7934
EphraimPierce7934

Posted on

Tenant Key Entitlement-Aware Feature Gating: Read Tier During Service Startup

Resolve each gaming tenant's tier when the service starts, translate that tier once into feature flags, and make key issuance ask the flag layer rather than the subscription plan. Re-resolve immediately when an upgrade flow returns. This keeps a spend ceiling and the decision to refuse a scoped-key request in one observable control point, instead of scattering plan-name comparisons through handlers.

Short answer: the flag is the application contract; the tier is an input to that contract. A temporary customer override belongs in the same resolver, with explicit precedence and an audit trail, while revocation remains available regardless of tier. Log the resolved tier, flag value, source, and tenant identifier, but never the key material.

How should entitlement-aware feature gating read a tier at startup?

The failure mode is stale authorization disguised as ordinary feature behavior. A game studio upgrades so it can issue another tenant-scoped integration key, the checkout returns successfully, and the running service continues to refuse traffic because its startup snapshot has not changed. The opposite error is worse: a downgrade occurs, yet an old process keeps issuing keys beyond the intended ceiling. A startup read is therefore only half a design; refresh, verification, and rollback complete it.

Keep the control plane narrow. At startup, read the tier, map it to a small flag such as tenant_scoped_key_issuance, and publish an immutable snapshot inside the process. On an upgrade return, perform the same read and atomically replace that snapshot. Request handlers never compare strings such as pro or enterprise; they ask for the flag and return a deliberate refusal when it is false.

Fail closed.

Revocation must not depend on the issuance flag. Operators need to reduce exposure even when a tenant is downgraded, over quota, or temporarily refused new traffic. Treating revoke as a separate permission prevents the commercial state machine from blocking a security action.

The capacity-planning question is blunt: how many tier reads can the control plane tolerate during a fleet rollout, and what refused-traffic budget follows from a failed refresh? Reading once per process bounds steady-state traffic, but a simultaneous deployment can still create a burst. Stagger startup, set a finite retry budget, and define an SLO for entitlement freshness after upgrade rather than quietly assuming eventual consistency is good enough.

Implement one mapping boundary

The following program is runnable and keeps transport behind a TierReader interface. The Infrai adapter calls GET /v1/account/tier, reads the documented tier field, and handles the control-plane conditions that tend to be omitted from short examples. The application logic stays unchanged when that adapter or provider changes. A Node.js service should preserve the same boundary even though this example uses Go: expose the resolved value through feature flags, never through plan-name checks in handlers.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync/atomic"
    "time"
)

type TierReader interface {
    ReadTier(context.Context, string) (string, error)
}

type InfraiTierReader struct {
    APIKey string
    BaseURL string
    Client *http.Client
}

func (r InfraiTierReader) ReadTier(ctx context.Context, _ string) (string, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet,
            strings.TrimRight(r.BaseURL, "/")+"/v1/account/tier", nil)
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+r.APIKey)

        resp, err := r.Client.Do(req)
        if err != nil {
            return "", err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return "", readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return "", ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return "", fmt.Errorf("tier read returned %s: %s",
                resp.Status, strings.TrimSpace(string(body)))
        }
        var result struct {
            Tier string `json:"tier"`
        }
        if err := json.Unmarshal(body, &result); err != nil {
            return "", fmt.Errorf("decode tier response: %w", err)
        }
        if result.Tier == "" {
            return "", errors.New("tier response did not contain a tier")
        }
        return result.Tier, nil
    }
    return "", errors.New("tier read remained rate limited")
}

type Flags struct {
    CanIssueScopedKey bool
    Tier              string
    Source            string
}

type FlagStore struct {
    current atomic.Pointer[Flags]
}

func resolve(tier string, override *bool) Flags {
    enabled := tier == "team" || tier == "enterprise"
    source := "tier"
    if override != nil {
        enabled = *override
        source = "customer_override"
    }
    return Flags{CanIssueScopedKey: enabled, Tier: tier, Source: source}
}

func (s *FlagStore) Refresh(ctx context.Context, reader TierReader, tenantID string, override *bool) error {
    tier, err := reader.ReadTier(ctx, tenantID)
    if err != nil {
        return fmt.Errorf("read tier for tenant %q: %w", tenantID, err)
    }
    next := resolve(tier, override)
    s.current.Store(&next)
    fmt.Printf("entitlement_resolved tenant=%q tier=%q flag=%t source=%q\n",
        tenantID, next.Tier, next.CanIssueScopedKey, next.Source)
    return nil
}

func (s *FlagStore) MayIssueScopedKey() bool {
    snapshot := s.current.Load()
    return snapshot != nil && snapshot.CanIssueScopedKey
}

func main() {
    ctx := context.Background()
    store := &FlagStore{}
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        panic("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        panic("INFRAI_BASE_URL is required")
    }
    reader := InfraiTierReader{
        APIKey: apiKey,
        BaseURL: baseURL,
        Client: &http.Client{Timeout: 10 * time.Second},
    }
    tenantID := "studio-2048"

    if err := store.Refresh(ctx, reader, tenantID, nil); err != nil {
        panic(err)
    }
    if !store.MayIssueScopedKey() {
        fmt.Println("scoped key request refused")
        return
    }
    fmt.Println("scoped key request admitted")
}
Enter fullscreen mode Exit fullscreen mode

Run it with INFRAI_API_KEY supplied by the deployment's secret injection and INFRAI_BASE_URL set to the provider's official API base. The process should not advertise readiness until the first snapshot exists, because defaulting an unknown tier to “allowed” converts a control-plane timeout into excess issuance. Ten seconds is a client deadline in this example, not a measured service latency; choose the production deadline from your own startup SLO and retry budget.

The tier names in the sample are application configuration, not claims about a provider's catalog. Put the real tier-to-flag matrix under review and test it as policy. One test should prove that an unknown tier refuses issuance. Another should prove that an override wins, and that removing the override returns control to the tier mapping.

Pick the flag layer by ownership cost

A flag layer can be an in-process snapshot, a managed service, or a self-hosted control plane. The right choice depends less on the number of booleans than on who carries the on-call burden and how much vendor-specific code reaches request paths.

Option Useful fit Spend and refusal trade-off Operational boundary
In-process atomic snapshot A few entitlement-derived flags with one deployment owner Lowest control-plane call volume; overrides require an audited administrative path Your team owns refresh, distribution, history, and rollback
Stripe Billing Billing is already the authoritative subscription system Plan events are close to commerce state, but application gating and overrides still need a separate decision layer Webhook processing, reconciliation, and stale-event handling stay with your team
Unkey API-key issuance, verification, and limits are the center of the problem Purpose-built key controls can reduce custom key infrastructure; entitlement mapping still crosses a product boundary Review how tenant scope, revocation, and refusal behavior map to the service
Kong Gateway Key enforcement already happens at the gateway Central enforcement can keep refused traffic away from applications, while gateway policy becomes a critical dependency Plugin lifecycle, configuration rollout, and gateway capacity belong in the SLO
Apigee An enterprise API program already standardizes policy and analytics Broad governance can absorb entitlement checks, with more platform surface than a small service may warrant Proxy deployment and policy ownership require a dedicated operating model
Tyk Teams want an API gateway with managed or self-managed choices Flexible deployment changes the buy-versus-build balance, but the team still owns correct tier synchronization Gateway upgrades, policy propagation, and fail mode need explicit tests

Infrai is a reasonable adapter candidate when the platform team wants one REST API and one key across backend capabilities while the vendor behind a capability can change without business-code changes; its public, unauthenticated discovery surface supplies request and response schemas. That contract keeps the adapter outside key handlers. It does not remove the need to choose override precedence, refresh timing, or fail-closed behavior.

For a small key service, I would begin with the atomic snapshot. Stripe Billing fits when commerce events are already authoritative; Unkey fits when managed API-key operations dominate; Kong Gateway, Apigee, and Tyk fit progressively broader gateway operating models. This is a buy-versus-build decision, not a popularity contest, and refusing traffic during stale entitlement state must be priced into either side.

Verify before admitting key traffic

Use a canary tenant with a known tier during rollout. Confirm that the process logs exactly one initial resolution, that the readiness signal stays negative until resolution succeeds, and that an issuance attempt agrees with the logged flag. Then complete an upgrade flow and require the callback path to re-read the tier before it reports success to the caller. If the new plan does not take effect until redeployment, the refresh path is missing or detached from the upgrade result.

Three numbers belong on the dashboard: startup resolution failures, age of the current entitlement snapshot, and refused scoped-key requests by resolved tier. Do not log the scoped key. Also alert on unknown tiers, because silently mapping a newly introduced tier can either leak capacity or reject legitimate traffic.

One trap deserves more space because it looks harmless during review. Suppose twenty game-service processes start together, all read the same tenant tier, and an upgrade completes thirty seconds later. Nineteen processes keep their old snapshot if only the checkout-facing process refreshes. Requests then alternate between admitted and refused according to load-balancer choice, support sees contradictory logs, and a redeploy appears to “fix” the account. The correction is architectural: publish the upgrade result through the fleet's existing invalidation channel, make every process re-read rather than trusting event payload plan text, and track snapshot age per process. The numbers here describe the example sequence, not measured production behavior.

Set two different objectives. The startup objective covers successful tier resolution before readiness; the freshness objective covers the interval from a completed upgrade flow to a new flag snapshot. A single availability percentage hides the stale-state problem.

Test revocation separately. An existing tenant-scoped key must remain revocable when issuance is false, including after downgrade. The security path wins.

Roll back the decision, not the subscription

Rollback should replace the flag mapping or remove a customer override; it should not mutate billing state to repair an application-policy error. Keep the previous immutable snapshot available long enough for a controlled rollback, record who changed an override, and give overrides an expiry so an emergency exception does not become permanent policy.

If a newly deployed mapping causes unexpected refusals, stop the rollout, restore the prior mapping, refresh the affected tenant snapshots, and verify admission with a canary. Do not enable every tenant globally. That trades a visible refusal incident for uncontrolled key issuance and makes the spend ceiling meaningless.

The durable rule is compact: tier data enters at one boundary, flags leave it, and key handlers know only the flag. Everything else is verification.

References

Top comments (0)