The operational constraint changes the design: a healthtech team migrating Google and GitHub sign-in cannot treat a directory listing as an admin shortcut. The safe choice is a brokered, tenant-scoped read path where every account row is authorized as if it were an individual request.
Short answer: keep provider migration separate from authorization, issue short-lived operator capabilities, and filter the directory in the service that owns the tenant policy. A successful OAuth exchange proves identity; it does not grant permission to enumerate somebody else's users.
I have been paged for missed jobs and duplicate deliveries, so I look for the same failure shape in auth work: one broad operation that quietly bypasses the guard used by the small operation. During a migration, that shortcut is usually a batch import with a service credential. It works in staging, then a support export crosses a tenant boundary in production. The invariant is simple: batch work may optimize transport, never policy. I keep a copy of the authorization decision beside the job because the later reviewer needs to see what was allowed, by whom, for which tenant, and under which policy version; a timestamp alone cannot reconstruct that context after a provider account is unlinked, a role is revoked, or an operator changes teams.
No shortcuts.
How should account listing preserve per-user authorization during a provider migration?
Start with an authorization decision for the requested tenant, operator, and purpose. Then construct the query from that decision. Do not fetch all accounts and filter in a handler, log, or spreadsheet; the unfiltered result has already escaped the boundary by then.
For Google and GitHub identities, store an internal subject identifier, provider name, and the tenant relationship you have verified. Do not use an email address as the durable identity key. Addresses can change, and two providers can report the same address for different subjects. Linking is an explicit, audited action that requires proof of control under your account-linking policy.
The read path should accept a cursor, a bounded page size, and a tenant identifier derived from the operator's capability. It should return only fields needed for the operational task. In a health record system, that generally excludes clinical data entirely; the directory job should not become a side door into patient records.
Here is the policy boundary I keep close to the database call. The endpoint names are intentionally internal: the important contract is the authorization order and the stable cursor, not a vendor-specific route.
package directory
import (
"context"
"database/sql"
"errors"
"fmt"
)
var ErrForbidden = errors.New("directory access denied")
type Capability struct {
OperatorID string
TenantID string
Action string
}
type Account struct {
ID string
Provider string
Subject string
}
type Store interface {
ListAccounts(ctx context.Context, tenantID, cursor string, limit int) ([]Account, string, error)
}
// ListAccounts makes the policy decision before constructing any account query.
func ListAccounts(ctx context.Context, db Store, cap Capability, cursor string, limit int) ([]Account, string, error) {
if cap.OperatorID == "" || cap.TenantID == "" || cap.Action != "directory:read" {
return nil, "", ErrForbidden
}
if limit < 1 || limit > 100 {
return nil, "", fmt.Errorf("invalid page size")
}
return db.ListAccounts(ctx, cap.TenantID, cursor, limit)
}
// A SQL implementation should bind tenantID in the WHERE clause and apply a
// deterministic order. Never interpolate cursor or tenant values into SQL.
func query(ctx context.Context, db *sql.DB, tenantID, cursor string, limit int) (*sql.Rows, error) {
return db.QueryContext(ctx, `
SELECT id, provider, subject
FROM identity_accounts
WHERE tenant_id = $1 AND id > $2
ORDER BY id
LIMIT $3`, tenantID, cursor, limit)
}
The database predicate is a second fence, not a replacement for the capability check. Defense in depth matters here because batch workers, exports, and interactive support tools tend to acquire different code paths over time.
What does a migration runbook need beyond the OAuth callback?
Treat migration as a replayable job. Take a snapshot of the source provider mapping, write an idempotency key for each internal subject, and record the decision for every row: linked, skipped, or sent to manual review. A retry must converge on the same state. It must not create a second account because a network response arrived late.
I once assumed a 200 response meant a batch step was complete. Then a worker was restarted after writing the first page and before advancing its cursor. The next run repeated that page. Nothing catastrophic happened because the unique constraint on (provider, subject) absorbed the duplicate, but the audit trail showed why the constraint belongs in storage rather than only in application code. That restart also exposed a less obvious ordering problem: the cursor had been advanced before the downstream identity link was committed, so a later retry would have skipped a row if the process had died a few milliseconds earlier. We changed the transaction boundary, made the cursor advance part of the commit, and added a reconciliation query that compares source subjects with internal subjects after every page. Iām not sure every team needs that exact query, but every team needs an invariant that can prove a page was neither lost nor applied twice. The short version: make the boring database rule carry the panic load.
Keep authorization context with the job payload: operator, tenant, purpose, policy version, and expiry. Re-check the capability when the worker claims a page. A capability that was valid at enqueue time may be revoked by the time a delayed job runs.
Observability should answer four questions without exposing identity data: how many rows were examined, how many decisions were made, how many retries occurred, and how many records went to review. Hash or tokenize provider subjects in metrics. Logs should carry a correlation ID, not an email address.
Choosing the boundary when the old managed directory is still live
During a staged cutover, keep one internal account model and make providers adapters behind it. The adapter translates a provider subject into the internal identity; it does not decide tenant access. This lets Google and GitHub coexist while reads move gradually, and it keeps rollback focused on provider exchange rather than authorization semantics.
The catch is that this design is not suitable when a team needs a vendor's built-in cross-tenant support console with prepackaged approval workflows. In that case, stick with the managed provider until those controls are reproduced and independently reviewed. A custom directory reader is also a poor fit for a tiny team that cannot operate key rotation, audit retention, and incident response. Migration is a boundary decision, not a badge of engineering maturity.
For a batch operation, I prefer a narrow worker identity plus per-tenant capabilities over a god-mode service account. It adds issuance and expiry work, but it makes the blast radius legible. If your mileage varies because your tenancy model is organization-wide rather than tenant-local, the same rule still applies: bind the query to the smallest policy scope you can prove.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OAuth 2.0 Authorization Framework (RFC 6749): https://www.rfc-editor.org/rfc/rfc6749
- OAuth 2.0 Security Best Current Practice (RFC 9700): https://www.rfc-editor.org/rfc/rfc9700
- OWASP Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
Top comments (0)