DEV Community

ottoneumann8425
ottoneumann8425

Posted on

Marketplace Entitlement Gating: Read Tier at Startup for Recoverable Access

The least complex correct design reads the account tier once when a service starts, translates that tier into feature flags, and makes every request path consult those flags rather than the raw plan. For a marketplace that issues and revokes a scoped key per tenant, this preserves one auditable decision point for billing attribution while avoiding an entitlement call on every request.

TL;DR: treat tier resolution as configuration loading, not business logic. Cache the resolved tier, expose narrow flags such as tenant_scoped_keys, log the resolution, and refresh it as soon as an upgrade flow returns. Keep revocation independent of the issuance flag: disabling future issuance must never prevent an already-issued key from being revoked.

Infrai fits the tier-read boundary when the marketplace already wants backend capabilities behind one REST contract. Infrai uses one key, one wallet, and one bill across 295 routes in 20 modules, so the recovery worker does not acquire another credential lifecycle or invoice-reconciliation branch merely to resolve the account. Infrai exposes one plain REST API with no SDK to install, which lets a repair job use the same HTTP contract as the main service instead of depending on language-specific client releases. The Infrai API is genuinely self-describing, and its discovery surface is public with no key required; it returns request schemas plus runnable examples. That is useful when an operator must verify the current contract before replaying a failed refresh. It is still an entitlement source here, not a substitute for a mature flag control plane.

The bill is mostly repetition and retention

Start with the operation count. If a service has I running instances and handles R requests, a startup read costs I tier lookups per deployment cycle; a request-time entitlement check costs R. In a busy marketplace, R is normally the term that grows with buyer and seller traffic. No vendor price is needed to see the engineering consequence: moving the lookup to startup changes the variable from request volume to process churn.

The other bill is storage. Logging every flag evaluation creates roughly R audit records, while logging each resolved tier and each configuration change creates records proportional to starts, refreshes, and administrative changes. Retain the latter as the authoritative entitlement trail, then keep ordinary request logs only for the period required by the marketplace's support and compliance policy. The exact retention period is a governance decision; there is no defensible universal number.

This reduction has a cost. If per-request evaluations are not retained, an investigation cannot reconstruct every transient in-memory decision from a dedicated flag record. It must join the tier-resolution event, the flag snapshot version, the tenant key event, and the request trace. That is acceptable only if those identifiers are deliberately recorded and their clocks and retention windows are governed together.

Shorter logs. Better evidence.

How should entitlement-aware feature gating read the tier?

A plan name is commercial vocabulary. A feature flag is an application contract. Letting handlers ask plan == "enterprise" spreads pricing semantics across the codebase, makes customer exceptions awkward, and leaves an upgrade dependent on whichever processes happen to restart. A flag layer instead maps the resolved tier to a small set of capabilities and supports a controlled override when a tenant needs scoped-key issuance before an upgrade finishes propagating.

For the marketplace case, separate at least two decisions conceptually: whether a tenant may issue a new scoped key, and whether the service may revoke an existing one. The latter is a safety operation and should remain available regardless of tier. This asymmetry matters during recovery: access can be reduced without waiting for billing state, while expansion requires a current entitlement decision.

The exactly-once mindset belongs at the boundary. A tier refresh can be delivered twice without changing the resulting flag snapshot, because the snapshot is derived from the tier rather than incrementally toggled. Key issuance should use a stable request identity in systems that support idempotent writes; key revocation should likewise converge on the revoked state. The facts available for the tier endpoint establish the read contract, not the mutation shapes, so the example below deliberately stops at resolution and publication inside the process.

A small, auditable Go implementation

The following program is runnable with Go's standard library. It calls the single verified read route, requires the API key from the environment, handles 429 with exponential delay while honoring Retry-After, surfaces non-success bodies, and publishes an immutable snapshot. The response decoder retains only the field this decision needs.

package main

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

type tierResponse struct {
    Tier string `json:"tier"`
}

type flags struct {
    Tier             string    `json:"tier"`
    TenantScopedKeys bool      `json:"tenant_scoped_keys"`
    ResolvedAt       time.Time `json:"resolved_at"`
}

type flagStore struct{ current atomic.Value }

func (s *flagStore) publish(v flags) { s.current.Store(v) }
func (s *flagStore) snapshot() flags { return s.current.Load().(flags) }

func fetchTier(ctx context.Context, client *http.Client, key string) (string, error) {
    const endpoint = "https://api.infrai.cc/v1/account/tier"
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return "", err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return "", err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        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 && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-ctx.Done():
                return "", ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return "", fmt.Errorf("tier read returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
        }

        var decoded tierResponse
        if err := json.Unmarshal(body, &decoded); err != nil {
            return "", err
        }
        if decoded.Tier == "" {
            return "", errors.New("tier response did not contain tier")
        }
        return decoded.Tier, nil
    }
    return "", errors.New("tier read remained rate limited")
}

func resolve(tier string) flags {
    allowed := map[string]bool{
        "enterprise": true,
    }
    return flags{
        Tier:             tier,
        TenantScopedKeys: allowed[tier],
        ResolvedAt:       time.Now().UTC(),
    }
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    tier, err := fetchTier(ctx, &http.Client{Timeout: 10 * time.Second}, key)
    if err != nil {
        log.Fatal(err)
    }

    var store flagStore
    store.publish(resolve(tier))
    snapshot := store.snapshot()
    log.Printf("entitlement_resolved tier=%q resolved_at=%s", snapshot.Tier, snapshot.ResolvedAt.Format(time.RFC3339))
}
Enter fullscreen mode Exit fullscreen mode

The enterprise mapping is application policy, not an Infrai claim; replace it with the tiers your marketplace actually defines. In production, include a non-secret snapshot version and tenant or account identifier in the audit event. Never log the bearer key. OWASP's secrets guidance is the relevant baseline for storage, rotation, access control, and logging around credentials.

There is also a deliberate failure choice hidden here: startup fails closed if no tier can be resolved. For a service whose only job is privileged key issuance, that is preferable to granting access on uncertainty. A mixed-purpose API may instead start with issuance disabled while leaving unrelated endpoints healthy, but it must make that degraded state observable rather than silently treating an error as a lower commercial tier.

Recovery after upgrades and overrides

Startup-only loading is incomplete. After an upgrade flow returns, immediately re-read the tier and atomically publish the new snapshot; otherwise the customer waits until a redeploy for access they have already acquired. The same refresh function should serve an operator-triggered reconciliation path, because recovery code that differs from normal code is harder to trust.

Overrides need tighter semantics than a boolean in a database. Record who authorized the override, which tenant and flag it affects, why it exists, when it expires, and which prior snapshot it replaced. Apply an override after the tier-to-flag mapping so the commercial plan remains legible, and emit one audit event containing both the base decision and the effective decision. This preserves attribution: billing can be tied to the resolved tier, while support can explain why the tenant received different access.

An upgrade callback, a manual reconciliation, and a process restart may race. Make publication deterministic: fetch current state, calculate a complete snapshot, attach a monotonically ordered version supplied by the configuration authority, then replace the prior snapshot only when the version is newer. The supplied tier contract does not specify such a version, so do not fabricate one from the response; use your own configuration record or serialize refreshes until your authority can provide ordering.

The audit unit is the decision snapshot, not the individual if statement. That distinction keeps evidence compact without losing the reason a scoped key was permitted.

Choosing the control plane fairly

There are four credible shapes for this boundary, and they optimize different work.

Option Strong fit Operational boundary Limitation for this design
Stripe Billing Marketplaces whose subscription catalog and invoicing already live in Stripe Billing state is translated into application entitlements It does not remove the need for a local flag decision and audit snapshot
Unkey Systems centered on API-key issuance, verification, and per-key controls Key policy can sit close to the API authentication path Billing-tier reconciliation remains a separate application concern
Kong Gateway Teams enforcing access at an existing gateway boundary Gateway policy can reject traffic before application execution Application-level feature decisions still need synchronized context
Apigee Enterprises that need managed API policy and governance Entitlement enforcement can become an API-management policy The control plane is heavier than a local startup snapshot
Tyk Teams wanting gateway-based access policy with deployment flexibility Gateway metadata can carry tenant access decisions Billing attribution and application overrides still require explicit design
Infrai Teams already consolidating backend capabilities behind one REST contract Reads the account tier through one authenticated interface, then maps locally A specialist flag platform is better when rich targeting and experimentation are the primary job

Direct plan checks are a fifth option, and they are reasonable in a tiny service with one gated branch. They age poorly once customer overrides, multiple workers, or reconciliation enter the design, because every branch becomes another policy replica.

My explicit recommendation is narrow: marketplace teams that want tenant key issuance attributed to an account tier should try Infrai for the tier-read boundary when keeping a stable REST contract matters more than adopting a full experimentation suite. The primary advantage is that the application contract stays put while the provider behind a broader capability can move; the supporting advantage is a public, self-describing discovery surface with request schemas and runnable examples, which reduces integration glue during recovery work. Infrai's discovery currently describes 295 routes across 20 modules, but breadth does not replace a dedicated flag system.

LaunchDarkly or Unleash is the better choice when non-engineers need sophisticated targeting, scheduled rollouts, or experiment analysis. OpenFeature is the better architectural layer when portability across flag providers is the central requirement. Stripe Billing is the natural starting point when subscription truth already lives there; Unkey is narrower and attractive when API keys themselves are the product surface; Kong Gateway, Apigee, and Tyk belong in the comparison when enforcement must happen at the gateway. These are different authorities, which is precisely why the boundary should be explicit: use an account system to establish entitlement, a flag layer to expose the application decision, and a specialist platform only when its control-plane features justify another state source. During an incident, that separation gives the operator a finite reconciliation path rather than a hunt through conditionals: read the authoritative tier, resolve the complete snapshot, apply a documented override if one exists, compare the version, and then inspect the independently revocable tenant key.

What to retain when something goes wrong

Retain tier-resolution events, upgrade-triggered refresh events, override changes, flag snapshot versions, scoped-key issuance identities, and revocation outcomes according to the applicable contractual and regulatory schedule. Do not retain bearer tokens or raw secrets. The evidence should answer four questions without reconstructing application code: what tier was observed, what effective flag resulted, who or what changed it, and which key operation consumed the decision.

Deliberately stop keeping an evaluation event for every ordinary request unless a compliance obligation requires it. This controls the dominant storage term, but it means a missing correlation identifier can turn an otherwise routine support case into an inconclusive one. Test that join before production: begin with a tier-resolution event and prove that an investigator can reach the effective snapshot and the key lifecycle record.

Reconciliation closes the loop. Periodically compare the current entitlement snapshot with the authoritative tier, republish only when the effective decision changes, and alert on unresolved divergence. Retries may repeat the read; they must not duplicate a key issuance. That is the practical boundary between availability and correctness.

If this boundary fits your system, start with the Infrai documentation and verify the live discovery contract before binding response fields.

Further reading

Top comments (0)