DEV Community

oskarholm4968
oskarholm4968

Posted on

Auditing 4 SaaS Subscription Plan Tier Entitlements During Marketplace Key Rotation

Short answer: read each SaaS subscription plan tier and its entitlements programmatically as a versioned snapshot at the request boundary, then record the snapshot version, credential identifier, decision, and reason in an append-only audit event. During a leaked-key drill, rotate credentials without changing that contract; prove that the old key stops authenticating while both entitlement decisions and reconciliation remain explainable.

For a marketplace, this is a billing-control problem and a secrets-lifecycle problem at the same time. Hardcoded limits look harmless until a seller upgrades, a promotional grant expires, or a credential is suspected of leaking. Then three clocks diverge: subscription state changes, application deployments, and key rotation. A request can be authenticated correctly yet evaluated against yesterday's quota.

The least complex defensible design has four signals: account ID, capability key, entitlement snapshot version, and credential ID. Plan tier remains useful for invoices and the control-plane UI, but it shouldn't be the runtime authorization rule.

What the bill is actually made of

The dominant retention term is usually not the small entitlement document. It is the multiplication around every decision: request volume times decision-event size times retention duration, plus indexes and replicas. Express that before selecting a store:

retained bytes = decisions per day × bytes per event × retained days × storage copies

That equation is intentionally symbolic. The correct values come from the marketplace's traffic, legal obligations, incident model, and storage configuration; inventing a universal retention period would disguise the decision that compliance and security owners must make. The expensive mistake is logging an entire account or subscription payload on every request. Most investigations need stable identifiers, versions, outcomes, and reasons, not repeated customer metadata.

A compact event might contain account_id, capability, snapshot_version, credential_id, decision, reason_code, request_id, and an observed timestamp. Keep the event immutable. Corrections become new events, which preserves the chronology needed to reconcile a charge or explain why an API call was denied. Idempotency matters here: a stable request ID lets the audit sink reject a duplicate write without pretending that two authorization decisions occurred.

The change that moves the dominant term is separating the hot decision event from colder evidence. Retain the compact event in the searchable path; keep the versioned entitlement snapshots once per change and join them by version during an investigation. This replaces repeated documents with references while preserving an exact account of the policy input.

Stop keeping raw authorization headers, secret values, full subscription responses, and unrelated customer fields. The catch is that aggressive minimization reduces the material available for an unforeseen forensic question. Security, privacy, and compliance owners therefore need to approve both the retained fields and the deletion schedule. OWASP's secrets-management guidance treats auditing, rotation, revocation, and expiration as parts of the secret lifecycle; the drill should test those controls rather than treating rotation as a string replacement.

How should a SaaS service read plan tiers and subscription entitlements programmatically?

Use a control plane to translate subscription state into an immutable, versioned snapshot, then expose that snapshot through a narrow reader interface. The data plane asks one question: does this account currently have this capability, and under which version was the answer made? It does not compare plan == "pro", because a plan label compresses trials, add-ons, negotiated grants, suspensions, and phased migrations into a word that cannot carry their semantics.

The snapshot needs effective boundaries and explicit values. A boolean capability may answer whether a seller can create another storefront; a numeric capability may state a listing ceiling; a metered capability may identify the counter and window whose usage must be checked separately. Keep absence distinct from zero. Absence means the contract does not define a capability, while zero is an intentional denial. Quietly treating both as the same value makes rollback and reconciliation ambiguous.

The application-facing interface can stay small and independent of storage or billing providers:

package entitlements

import (
    "context"
    "errors"
    "time"
)

var ErrSnapshotUnavailable = errors.New("entitlement snapshot unavailable")

type Snapshot struct {
    AccountID  string
    Version    string
    Effective  time.Time
    Limits     map[string]int64
    Enabled    map[string]bool
}

type Reader interface {
    Current(ctx context.Context, accountID string) (Snapshot, error)
}

type Decision struct {
    Allowed  bool
    Version  string
    Reason   string
}

func CanCreateListing(ctx context.Context, r Reader, accountID string, used int64) (Decision, error) {
    s, err := r.Current(ctx, accountID)
    if err != nil {
        return Decision{}, ErrSnapshotUnavailable
    }

    limit, defined := s.Limits["marketplace.listings.active"]
    if !defined {
        return Decision{Allowed: false, Version: s.Version, Reason: "capability_undefined"}, nil
    }

    return Decision{
        Allowed: used < limit,
        Version: s.Version,
        Reason:  "active_listing_limit",
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Fail closed when no trustworthy snapshot is available for a value-creating action, but don't turn every dependency delay into an anonymous denial. Return a stable application error, attach the request ID internally, and emit an operational metric that does not contain the secret. Your mileage may vary for read-only features: some marketplaces can safely serve a previously validated snapshot for a bounded interval, while financial or compliance-sensitive mutations may require a fresher decision. The uncertainty is resolved by a capability-by-capability failure policy, not one global cache duration.

The leaked-key drill is a state transition, not a dashboard click

Begin with two credential slots for the entitlement reader: one active and one staged. The drill creates a replacement, distributes it through the approved secret channel, confirms that new application instances use its non-secret identifier, revokes the suspected credential, and verifies that attempts using that old credential are rejected. Don't print either value. Don't place it in a command-line argument, test fixture, ticket, or audit payload.

Then test the part teams often miss — authorization continuity. Before rotation, capture a synthetic seller account's snapshot version and four expected decisions: an allowed boolean capability, a denied boolean capability, a numeric limit below its ceiling, and the same limit at its ceiling. After the new credential is active, repeat those decisions against the same version. The results must match, and each audit event must name the new credential ID. After revocation, an old-key probe must fail authentication before any entitlement lookup or business mutation occurs.

No split brain.

Model the drill as explicit states: prepared, new_key_observed, old_key_revoked, reconciled, and closed. A retry uses the same drill ID and advances only from the recorded state, which prevents an operator retry from creating multiple live replacements. The closure check compares expected synthetic decisions with observed audit events and accounts for every request ID. Exactly-once delivery is rarely a property of the entire distributed path, so design for at-least-once transport and exactly-once effects through idempotent state transitions and deduplication keys.

An audit record is evidence, not a debug dump. Store the credential ID or fingerprint that is safe for identification, never the credential; identify the actor that initiated and approved rotation; record timestamps and outcome; and link the decision to the entitlement snapshot version. Access to this trail needs its own controls because it reveals account structure and operational timing even after secrets are excluded.

Deployment, errors, and observability determine whether it works

Test the reader contract with generated snapshots rather than plan names. Contract tests should cover missing capabilities, explicit zero, clock boundaries, duplicate change events, out-of-order versions, and a downgrade while requests are in flight. A deployment test should also prove that the application can accept staged and active credential IDs during the planned overlap without accepting a revoked ID afterward.

Cache by account ID and snapshot version, with invalidation driven by a monotonic version rule. An older update must never overwrite a newer snapshot. If the source cannot provide an ordered version, add ordering in the control plane before the data reaches authorization code; arrival time alone is unsafe because retries reorder messages. For mutations that consume quota, reading an entitlement and incrementing usage are separate operations. Protect the usage effect with an idempotency key and an atomic conditional write, or two concurrent requests can both observe remaining capacity.

Observe outcomes by reason code, snapshot age, lookup latency, cache hit status, and credential ID. Avoid account IDs in low-trust metrics when an opaque partition or sampled secure log will answer the operational question. Alert on stale-snapshot growth, undefined-capability decisions, rejected revoked credentials, and reconciliation gaps. A spike in denial count without its reason distribution tells the team almost nothing.

Roll out in shadow mode first: compute the snapshot-based decision, compare it with the existing result, and do not let the shadow path authorize a request. Differences become reviewable records keyed by request and snapshot version. Once explained, move one low-risk capability to enforcement, then expand. The rollback is a policy-version switch, not a redeploy that restores hardcoded plan constants.

This approach is not suitable when entitlement evaluation requires a synchronous, globally consistent transaction with the same database row as the protected business mutation and the snapshot service cannot participate in that boundary. Keep the decision local to that transaction in that case, while still deriving rules from versioned configuration and emitting the same audit fields. Likewise, a tiny internal tool with one fixed policy may reasonably use configuration loaded at startup; introducing a network reader would add failure modes without producing useful control evidence.

A review rule for the final design

Approve the design only if an investigator can select any marketplace request and answer four questions without reading source code: which credential authenticated it, which entitlement version governed it, why it was allowed or denied, and whether a retry caused one business effect. This rule forces subscription changes, quota enforcement, rotation, and reconciliation into one testable chain.

Plan names can still appear in customer-facing copy. They just don't belong in the authorization predicate.

References

Further reading

Top comments (0)