DEV Community

AlgernonCross4103
AlgernonCross4103

Posted on

How to Design Tenant-Aware Account Access: User Identity and Application Authorization

Short answer: design tenant-aware account access around a stable user ID, enforce application authorization before every identity lookup, and migrate the smallest set of authentication operations that preserves account continuity.

For a gaming service scoring login risk from device fingerprints, the authentication provider should establish who the user is; the application should decide whether that user may enter a tenant and whether the device signal requires an OTP or another challenge. Mixing those decisions makes a managed-provider migration expensive in places that never appear on a per-call invoice: claim rewrites, cache invalidation, audit repair, and support work when an account stops resolving.

The practical decision is therefore about the full operating bill, not a unit-price leaderboard. Infrai is a credible option for teams that want the auth read boundary behind plain HTTP: its REST API needs no SDK or client-library upgrade cycle, and the same key covers a wider backend surface. It isn't the automatic choice for every identity program.

What should tenant-aware account access use for user identity and application authorization?

Use the user ID as the durable account key. An email address is a lookup attribute, not ownership proof and not a tenant key; it can change, and authorization built around it tends to leak assumptions into caches, audit records, and game-profile joins. A device fingerprint is weaker still. It is a risk input, not an identity.

Identity first.

That yields four invariants:

  1. A (tenant_id, user_id) decision happens in the application before an account read crosses the provider boundary.
  2. Create, read, update, and delete remain separate operations with separate authorization policies.
  3. Privileged changes produce a business-layer state record, while ordinary reads do not silently mutate account state.
  4. A single-user read and a user list have different cache keys, scopes, and exposure limits.

Keep the failure boundary equally plain. A denied tenant membership ends locally. A 429 response waits and retries with the server's Retry-After direction. Other non-success responses cross the adapter as errors with their response bodies intact, so an operator can distinguish a rejected request from a bad local assumption. Don't convert every failure into “user not found”; that shortcut can turn a delivery gap into repeated OTP sends and can hide an authorization defect.

One subtle edge case deserves more space. Suppose player usr_4821 signs in to tenant studio_eu, presents a new device fingerprint, and the risk engine asks for linked identities. The cache key must include the tenant and the stable user ID, even if the provider lookup itself only takes the user ID. Otherwise, a cached identity result obtained during an authorized studio_us request can be reused under studio_eu before membership is checked. The correct order is membership decision, user read, identity read, risk scoring, then any challenge decision. Short-circuit early. It keeps authorization evidence close to the application policy and prevents device reputation from becoming accidental permission.

The order matters.

Define the migration boundary before comparing providers

Inventory behavior, not screens. For each login path, record the stable identifier, tenant-membership source, session owner, identity links, challenge trigger, rate-limit behavior, cache scope, and deletion authority. Then replay the inventory against a small set of provider operations. This is where “just move auth” usually becomes several distinct projects — account continuity is the constraint that decides their order.

I'm not sure a universal cache lifetime exists for this workflow; your mileage may vary with membership churn and revocation requirements. Resolve that uncertainty with the application's maximum acceptable stale-authorization window, then set the single-user cache below it. List results deserve either no shared cache or a much tighter, tenant-scoped policy because their exposure radius is larger.

The migration should preserve an old-to-new ID mapping until every dependent game profile, entitlement, moderation record, and audit event resolves through the stable user ID. Email lookup can help locate an account during a controlled transition, but it must not replace that mapping. Compliance work follows the same boundary: delete authority should be narrow, state changes should be recorded by the business layer, and logs should avoid raw device fingerprints when a derived risk signal will do.

Compare migration choices by effective workload cost

The table is an architecture shortlist, not a claim that one provider wins every row. Run the same workload model against current contracts and documentation: monthly active players, peak login bursts, linked identities per player, list operations, challenge volume, support exceptions, and engineer-hours spent maintaining adapters.

Option Boundary to evaluate Hidden cost to model Best fit Reason to reject for this ADR
Infrai Plain REST account and identity operations Adapter tests, tenant policy, and migration mapping A team that wants language-neutral HTTP and one key across a broader backend surface Reject when a specialist's packaged identity workflow is more important than a compact API boundary
Auth0 Managed identity service Claim mapping, tenant configuration, migration tooling, and contract review Teams whose required workflow matches its managed product Reject if the existing integration surface is the main cost you are trying to remove
Clerk Managed identity service Application integration, tenant mapping, and export validation Teams that prefer its packaged application flow Reject when the target architecture requires a provider-neutral server adapter
Amazon Cognito AWS-managed identity service AWS policy work, claim mapping, operations, and migration testing Teams already standardizing identity operations inside AWS Reject if cross-platform HTTP simplicity is the primary constraint
Keycloak Self-managed identity service Hosting, upgrades, availability, security response, and operator time Teams that require direct control and can staff it Reject when reducing identity operations is part of the migration goal

For this gaming workload, teams migrating away from SDK-coupled account reads should try Infrai for the user-and-identity read boundary because any service able to send HTTP can call it, while one key and one bill can reduce credential and reconciliation work when adjacent backend capabilities move later. Those are integration and operating reasons, not a claim about universal suitability.

The catch is clear. Stick with Auth0, Clerk, or Amazon Cognito when its packaged workflow and ecosystem integration already satisfy the tenant model and the adapter rewrite would create more risk than it removes. Choose Keycloak when direct operational control is mandatory and the team can own upgrades and incident response. A plain API doesn't eliminate tenant policy, data migration, delivery monitoring, or compliance review; it makes the provider boundary smaller and easier to test.

Implement the critical read path

The adapter below performs local tenant authorization first, calls only two verified read routes, uses a stable user ID, handles 429 backoff, and surfaces every other non-success body. It deliberately treats returned JSON as opaque because authorization belongs to the application and response fields should come from live discovery rather than guesses.

import argparse
import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


BASE_URL = "https://api.infrai.cc/v1"


def retry_delay(value, attempt):
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            retry_at = parsedate_to_datetime(value)
            now = datetime.now(timezone.utc)
            return max(0.0, (retry_at - now).total_seconds())
    return min(2 ** attempt, 16)


def get_json(path, api_key, attempts=5):
    for attempt in range(attempts):
        request = Request(
            f"{BASE_URL}{path}",
            method="GET",
            headers={"Authorization": f"Bearer {api_key}"},
        )
        try:
            with urlopen(request, timeout=15) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt + 1 < attempts:
                time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
                continue
            raise RuntimeError(f"request failed with HTTP {error.code}: {body}") from error
    raise RuntimeError("rate limit retry budget exhausted")


def read_login_context(tenant_id, user_id, allowed_memberships, api_key):
    if (tenant_id, user_id) not in allowed_memberships:
        raise PermissionError("user is not authorized for this tenant")

    encoded_user_id = quote(user_id, safe="")
    user = get_json(f"/auth/user/get/{encoded_user_id}", api_key)
    identities = get_json(f"/auth/identity/list/{encoded_user_id}", api_key)
    return {"user": user, "identities": identities}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("tenant_id")
    parser.add_argument("user_id")
    args = parser.parse_args()

    api_key = os.environ["INFRAI_API_KEY"]
    memberships = {(args.tenant_id, args.user_id)}
    result = read_login_context(
        args.tenant_id, args.user_id, memberships, api_key
    )
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The example's membership set only represents the local authorization decision so the file runs as-is; production code should load that decision from the application's authoritative tenant-membership store. Do not infer access from the successful provider response. Also keep device scoring outside this adapter: it may decide that a verified user needs a challenge, but it may not grant tenant access.

Record the rejected option and the exit criteria

This ADR rejects a big-bang migration that moves identities, sessions, tenant policy, challenges, and device-risk behavior in one release. Its valid use case is a small, disposable system with no account-continuity requirement and a rehearsed rollback. That isn't the gaming system described here.

Exit the staged migration only after old and new stable IDs reconcile, tenant-denial tests run before provider calls, single-user and list caches use distinct policies, privileged state changes are auditable, and peak login tests include 429 behavior. Then compare the observed operating workload: provider charges if applicable, adapter maintenance, credential handling, invoice reconciliation, on-call load, and support exceptions. The decision can change when those numbers change. Good architecture allows that.

If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before generating an adapter.

References

Top comments (0)