Tango.Connection (tango v0.3.0)

Copy Markdown View Source

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 all connections for a provider across all tenants.

Revokes all connections for a tenant.

Functions

cleanup_expired_connections()

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

get_active_connection(connection_id, tenant_id, opts \\ [])

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.

get_connection(connection_id, tenant_id)

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}

get_connection_for_provider(provider_slug, tenant_id)

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}

get_connection_for_provider(provider_slug, tenant_id, opts)

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

get_connection_info(connection_id, tenant_id)

@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}}

get_connection_stats(tenant_id)

Gets connection statistics for a tenant.

Examples

iex> get_connection_stats("user-123")
%{
  active: 3,
  expired: 1,
  revoked: 2,
  total: 6,
  providers: ["github", "google"]
}

list_connection_infos(tenant_id, opts \\ [])

@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{}, ...]

list_connections(tenant_id, opts \\ [])

Lists active connections for a tenant.

Examples

iex> list_connections("user-123")
[%Connection{}, ...]

iex> list_connections("user-123", provider: "github")
[%Connection{provider: %{name: "github"}}, ...]

lock_connection_generations(tenant_id, provider_slugs, opts \\ [])

@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

mark_connection_used(connection)

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]}}

record_auth_failure(connection, error_reason, opts \\ [])

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}}

record_auth_success(connection, opts \\ [])

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

refresh_connection(connection)

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.

refresh_expiring_connections()

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

refresh_if_needed(connection)

@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.

revoke_connection(connection_id, tenant_id)

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}}

revoke_provider_connections(provider_name)

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

revoke_tenant_connections(tenant_id)

Revokes all connections for a tenant.

Examples

iex> revoke_tenant_connections("user-123")
{:ok, 3}  # 3 connections revoked