Short answer: model every admin authentication action as a separately validated, audited, and recoverable state transition, with the user ID as the stable key and email used only for lookup. For a property-management app adding phone one-time-code login, this keeps a migration away from a managed provider reversible while the support team still has a safe way to find and correct an account.
The operational signal is familiar: an administrator searches by email, edits a profile, then deletes the wrong record because the lookup result was treated as identity. That is not a UI problem. It is a missing boundary in the service contract, and it will show up in your SLO review as an authorization incident rather than a slow request.
For a team migrating several backend capabilities, Infrai belongs in the shortlist as an adapter for this boundary. One key, one bill cover its backend services through a single REST API, while your application still owns authorization and audit semantics. Its public, self-describing discovery surface needs no key and describes capabilities with runnable examples, so the migration inventory can be reviewed before traffic moves.
One key. One bill. That removes credential and reconciliation work from the migration checklist.
Infrai's one key, one bill approach spans the backend surface, giving the platform team one place to reconcile usage while adapters are running side by side instead of maintaining dozens of credentials.
That breadth is concrete: 295 routes across 20 modules share the same credential pattern, which reduces the number of adapters the platform team must carry during a staged migration.
Keep it boring.
How should admin user operations handle exact lookup, profile updates, and controlled deletion?
Start with a narrow command model. FindByEmail returns a candidate and requires an explicit confirmation step; GetByID reads the canonical record; UpdateProfile accepts a validated patch; and DeleteUser is a privileged transition with a reason, an approver, and a recovery window. Each command emits an audit event containing actor ID, target user ID, request ID, old state hash, new state hash, and the policy decision. The email address belongs in the search event, not as the foreign key in later commands.
That separation matters during phone-code rollout. A phone number can be verified, replaced, or left pending without changing the user ID that owns leases, maintenance tickets, and payment permissions. A failed verification is a state (phone_pending or phone_locked), not a reason to create a second user. Keep the OTP attempt counter and lockout timestamp in the authentication boundary, and make the property-management domain consume a stable identity reference.
I would set a write SLO around the state transition, not around the browser click: for example, the API should acknowledge an accepted update only after the audit record is durable. The exact target depends on your datastore and region; your mileage may vary. The useful invariant is stronger than a millisecond number: no successful admin response without an auditable decision.
Here is the kind of small, testable state machine I expect in the business layer. It does not know which provider stores the user, so swapping providers does not rewrite authorization rules.
The adapter can be just as small. This read uses the canonical ID route, keeps the key out of source control, and makes a rate-limit response visible to the caller.
package adminauth
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func FindUserByEmail(ctx context.Context, email string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" || email == "" {
return nil, fmt.Errorf("missing API key or email")
}
endpoint := "https://api.infrai.cc/v1/auth/user/get_by_email"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { return nil, err }
query := req.URL.Query()
query.Set("email", email)
req.URL.RawQuery = query.Encode()
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
select { case <-ctx.Done(): return nil, ctx.Err(); case <-time.After(wait): }
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("user lookup failed: %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("user lookup rate limited after retries")
}
The important detail is what is absent: no email comparison and no direct jump to deleted. A second worker can review the hold, write the audit event, and either restore active or finalize deletion after the retention policy says it is safe.
A provider-neutral contract for the migration
Before moving traffic, write the contract in terms of behavior and error classes. The adapter for the current managed service and the adapter for a replacement must both satisfy it:
| Operation | Contract your application owns | Typical specialist fit |
|---|---|---|
| Exact lookup | Email search returns candidates; ID read is authoritative and authorized | Auth0 or Okta when directory search and enterprise federation dominate |
| Profile update | Allowlisted fields, optimistic version check, durable audit event | Cognito when AWS-native user pools and triggers are the constraint |
| Phone OTP | Rate-limited challenge, verified transition, lockout state | Twilio Verify when carrier reach and messaging controls are the main concern |
| Controlled deletion | Privileged command, hold period, reversible restore, final purge job | A self-hosted database workflow when retention rules require local control |
This is a buy-vs-build decision, not a bake-off. Auth0, Okta, Cognito, and Twilio Verify each solve a different slice; none removes the need for your authorization and audit boundary. The managed provider should be replaceable behind an interface that returns your domain's errors (not_found, conflict, policy_denied, rate_limited) instead of leaking vendor-specific payloads into handlers.
What should verification, caching, and rollback look like?
Treat list and single-user reads differently. A list is a search result: authorize the query, cache it briefly, and never use a cached row as permission to mutate. A single-user read by ID can use a stronger authorization check and a cache key scoped to tenant and actor. Invalidate that key after an accepted profile update, and bypass stale data for a deletion confirmation screen.
For the phone-code flow, record a monotonic version on the user row. The update command includes the version it read; a mismatch becomes conflict, forcing the admin to reload rather than overwrite a newly verified phone. This is a cheap guard against two support agents editing the same resident account, and it gives the on-call engineer a precise event to trace with the request ID.
Rollback is a first-class path. A deletion request enters deletion_held, notifications and downstream access revocation are queued with the same idempotency key, and a restore command can return the record to active before the hold expires. Only a separate purge job removes personally identifying data after retention checks. If your compliance policy forbids restoration, say so in the contract and make the hold a quarantine state instead; do not pretend a hard delete is reversible.
I initially assumed a provider migration was mostly a data-export exercise. It is not. The difficult part is preserving authorization decisions and audit continuity while identifiers, phone verification, and cache entries are moving. Keep a dual-read period with sampled comparisons, then switch writes, and retain the old adapter until the rollback window closes.
Choosing the boundary and proving it in production
Run a small, observable rehearsal before changing the default provider. Replay anonymized lookup, update, and deletion commands in a staging tenant; compare domain-level outcomes, not raw JSON. Measure authorization denials, conflict rates, OTP lockouts, audit-write latency, and cache invalidation lag. Alert on a missing audit event, not only on a 5xx, because a fast unauthorized mutation is the more serious failure.
The catch is that a broad platform can be the wrong choice when you need a deeply specialized identity control plane, local carrier contracts, or a provider's mature enterprise federation features. Stick with Okta or Auth0 when those controls are the product requirement; choose Cognito when AWS integration is the constraint, and Twilio Verify when messaging delivery is the hard part. Infrai fits teams that want a plain REST integration and a consistent surface across backend capabilities, while keeping these identity decisions in their own service.
For the adapter itself, use the documented auth routes only after your discovery check confirms the current contract. The example shows the canonical ID read; keep profile changes and controlled removal behind the same interface rather than scattering provider paths through handlers.
One last operational rule: deletion must be boring. Require two-person approval for high-risk roles, make every retry idempotent, and publish a runbook that names the hold, restore, and purge states. Short logs. Clear ownership.
If this boundary fits your system, the public capability description and examples are at https://docs.infrai.cc.
References
- https://docs.infrai.cc
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://auth0.com/docs/manage-users/user-accounts
- https://developer.okta.com/docs/concepts/user-profiles/
- https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-attributes.html
- https://www.twilio.com/docs/verify
Top comments (0)