Context module for OAuth connection management with multi-tenant isolation.
Provides connection lifecycle management, token refresh, and multi-tenant isolation with comprehensive audit logging and automatic token refresh.
Summary
Functions
Cleans up expired connections.
Gets an active tenant-owned connection with optional token refresh.
Gets a connection by ID with tenant isolation.
Gets an active connection for provider and tenant.
Gets an active connection for provider and tenant with optional auto-refresh.
Gets credential-free connection info for any status.
Gets connection statistics for a tenant.
Lists credential-free connection info for a tenant.
Lists active connections for a tenant.
Acquires generation locks for one tenant's providers.
Updates connection usage timestamp.
Records the provider rejecting a connection's credentials.
Clears a connection's rejection streak after its credentials authenticate again.
Refreshes an OAuth connection's access token.
Refreshes connections that are about to expire.
Refreshes a connection's access token when it is about to expire.
Revokes a connection.
Revokes all connections for a provider across all tenants.
Revokes all connections for a tenant.
Functions
Cleans up expired connections.
Removes connections that have been expired for more than 30 days.
Examples
iex> cleanup_expired_connections()
{:ok, 12} # 12 connections cleaned up
Gets an active tenant-owned connection with optional token refresh.
Without auto_refresh: true this is a plain tenant- and status-filtered read
of the last committed row, so it never waits behind an in-flight refresh.
With it, the tenant and active status are checked while holding the same row
lock used for refresh, so callers cannot receive a connection that changes
state between lookup and refresh.
Gets a connection by ID with tenant isolation.
Examples
iex> get_connection("conn-123", "user-123")
{:ok, %Connection{}}
iex> get_connection("conn-123", "wrong-tenant")
{:error, :not_found}
Gets an active connection for provider and tenant.
Provider is identified by slug, not name.
Examples
iex> get_connection_for_provider("github", "user-123")
{:ok, %Connection{}}
iex> get_connection_for_provider("google", "user-123")
{:ok, %Connection{}}
iex> get_connection_for_provider("nonexistent", "user-123")
{:error, :not_found}
Gets an active connection for provider and tenant with optional auto-refresh.
Provider is identified by slug, not name. When auto_refresh is true,
automatically refreshes the token if it's about to expire (within 5 minutes).
Returns {:error, {:refresh_failed, reason}} when an expired token cannot
be refreshed.
Examples
iex> get_connection_for_provider("github", "user-123", auto_refresh: true)
{:ok, %Connection{}} # Token automatically refreshed if needed
iex> get_connection_for_provider("google", "user-123", auto_refresh: false)
{:ok, %Connection{}} # Token returned as-is, even if expired
@spec get_connection_info(integer(), String.t()) :: {:ok, Tango.ConnectionInfo.t()} | {:error, :not_found}
Gets credential-free connection info for any status.
Missing and wrong-tenant ids both return {:error, :not_found}. The read
does not refresh tokens or take locks. :active does not promise the access
token is usable.
Examples
iex> get_connection_info(123, "user-123")
{:ok, %Tango.ConnectionInfo{id: 123, status: :active}}
Gets connection statistics for a tenant.
Examples
iex> get_connection_stats("user-123")
%{
active: 3,
expired: 1,
revoked: 2,
total: 6,
providers: ["github", "google"]
}
@spec list_connection_infos(String.t(), keyword()) :: [Tango.ConnectionInfo.t()]
Lists credential-free connection info for a tenant.
Results are ordered by ascending connection id. provider_slug matches the
provider slug and includes inactive providers. status defaults to
:active. Pass :any, one of :active, :expired, or :revoked, or a
list of those statuses. An empty status list returns []. Invalid options
raise ArgumentError before a query runs.
The read does not refresh tokens or take locks. :active does not promise
the access token is usable.
Examples
iex> list_connection_infos("user-123")
[%Tango.ConnectionInfo{}, ...]
iex> list_connection_infos("user-123", provider_slug: "records", status: :any)
[%Tango.ConnectionInfo{}, ...]
Lists active connections for a tenant.
Examples
iex> list_connections("user-123")
[%Connection{}, ...]
iex> list_connections("user-123", provider: "github")
[%Connection{provider: %{name: "github"}}, ...]
@spec lock_connection_generations(String.t(), [String.t()], keyword()) :: :ok | {:error, :not_in_transaction | {:provider_not_found, String.t()}}
Acquires generation locks for one tenant's providers.
The caller must already be inside a transaction on Tango's configured repo.
A sandbox checkout, or a transaction on a different repo, returns
{:error, :not_in_transaction} before any lookup, including for an empty
slug list.
Slugs resolve before any lock, and inactive providers are included.
missing: :error (the default) returns
{:error, {:provider_not_found, slug}} for the first unknown slug in input
order and takes no locks. missing: :skip ignores unknown slugs. Invalid
options raise ArgumentError.
Pairs are locked by provider id, then Elixir tenant binary order. Repeating a held pair does not block. Locks last until the outer transaction commits or rolls back; a nested commit does not release them. Acquire the tenant's full provider set once, before nested Tango operations. Adding a lower key after a higher key can deadlock. Separate transactions are required for several tenants unless the complete global order is established first.
A lock does not authorize the caller or replace a later state check.
Examples
iex> lock_connection_generations("user-123", ["records"])
:ok
Updates connection usage timestamp.
Should be called when connection is used for API requests.
Examples
iex> mark_connection_used(connection)
{:ok, %Connection{last_used_at: ~U[2023-01-01 12:00:00Z]}}
Records the provider rejecting a connection's credentials.
Consumers call this when a provider answers an API call with HTTP 401 or 403.
Tango issues tokens but never sees the calls made with them, so a connection
holding credentials the provider has revoked stays :active here forever
unless the consumer reports it. Once the rejections reach the threshold the
connection is expired, get_connection_for_provider/2 stops handing it out,
and the consumer sees {:error, :not_found} — its cue to treat the
integration as disconnected and prompt the tenant to reconnect.
opts[:max_failures] sets the expiry threshold (default 3), per call rather
than library-wide: one Tango deployment serves many providers, each called on
its own cadence, and the count is a number of rejections rather than a
duration. Callers hitting a provider every few seconds pass a higher ceiling
than one sweeping twice an hour, or the same transient outage expires far
more of their connections.
This is not a refresh failure. A connection carrying no refresh token or no
expiry is never eligible for refresh, so refresh_connection/1 never runs and
its counters never move, no matter how long the credentials have been dead.
Only active connections accept a report: a connection revoked or expired
between the failing API call and the report is left exactly as it is —
:revoked and :expired age out on different schedules, so a late report
must not convert one into the other. The count is read from a freshly loaded
row locked for the update, never from the caller's copy, so concurrent
reporters serialize instead of clobbering each other or raising
Ecto.StaleEntryError on a stale struct.
Accepts a %Connection{}, or a connection id together with opts[:tenant_id]
(a bare id is not proof of access, so id-based reports carry the tenant scope
every other lookup in this module requires). Returns {:error, :not_found}
when the connection does not exist, is not active, or belongs to another
tenant, and {:error, :tenant_id_required} for an id without a tenant.
Examples
iex> record_auth_failure(connection, "http_401")
{:ok, %Connection{auth_failures: 1}}
iex> record_auth_failure(connection_id, "http_401", tenant_id: "user-123", max_failures: 10)
{:ok, %Connection{auth_failures: 1}}
Clears a connection's rejection streak after its credentials authenticate again.
Keeps record_auth_failure/3 counting consecutive rejections: without this,
isolated failures spread over months would accumulate to the threshold and
expire a connection that works. The streak is checked in the database rather
than on the caller's copy — a struct loaded before another process recorded a
failure would otherwise skip the clear and leave a phantom strike behind. When
there is no streak the update matches no row and the call is a single no-op
statement, so reporting success after every provider call stays cheap.
Accepts a %Connection{}, or a connection id together with opts[:tenant_id].
Returns :ok whether or not anything needed clearing (an inactive or already
clean connection has no streak worth reporting), and
{:error, :tenant_id_required} for an id without a tenant.
Examples
iex> record_auth_success(connection)
:ok
iex> record_auth_success(connection_id, tenant_id: "user-123")
:ok
Refreshes an OAuth connection's access token.
Attempts to refresh the access token using the refresh token. Updates connection with new token data or marks as expired on failure.
Examples
iex> refresh_connection(connection)
{:ok, %Connection{access_token: "new_token"}}
iex> refresh_connection(expired_connection)
{:error, {:oauth_error, :invalid_grant}}A failed refresh returns {:error, reason} even when the current access
token still has time left. On-demand callers use
refresh_if_needed/1, which wraps the same reason as
{:error, {:refresh_failed, reason}} unless the active token may still
be served.
Refreshes connections that are about to expire.
Should be called periodically by a background job.
Examples
iex> refresh_expiring_connections()
{:ok, 3} # 3 connections refreshed
@spec refresh_if_needed(Tango.Schemas.Connection.t()) :: {:ok, Tango.Schemas.Connection.t()} | {:error, {:refresh_failed, term()}} | {:error, :not_found}
Refreshes a connection's access token when it is about to expire.
The connection row is locked while checking its expiry and requesting a token, so concurrent callers reuse the first caller's result. A transient or non-terminal failure still serves an active unexpired access token. Credential expiry, a terminal failure, and a persistence or transaction failure do not.
Revokes a connection.
Accepts the connection struct or its integer id. Missing, wrong-tenant, and
already-revoked ids return {:error, :not_found}. A struct whose tenant does
not match returns {:error, :not_authorized}. Active and expired connections
can be revoked. The row is locked and checked again inside the transaction.
Examples
iex> revoke_connection(connection, "user-123")
{:ok, %Connection{status: :revoked}}
iex> revoke_connection(123, "user-123")
{:ok, %Connection{status: :revoked}}
Revokes all connections for a provider across all tenants.
Should be used when a provider is deactivated or has security issues.
Examples
iex> revoke_provider_connections("github")
{:ok, 15} # 15 connections revoked
Revokes all connections for a tenant.
Examples
iex> revoke_tenant_connections("user-123")
{:ok, 3} # 3 connections revoked