DBIO

 view release on metacpan or  search on metacpan

CONTEXT.md  view on Meta::CPAN

# DBIO Connection Credentialing & Replication

How DBIO obtains database credentials and how it routes work across a primary
and read replicas. Exists to keep two historically-independent strands —
credential provision and read/write routing — cleanly separated now that both
live in the same codebase.

## Language

### Credentials vs. topology

**AccessBroker**:
A source of database credentials for exactly one backend identity.
_Avoid_: connection manager, router, pool, DSN provider.

**CredentialSource**:
The contract a backend storage depends on to obtain (and refresh) its connect
info — satisfied by **AccessBroker** subclasses, owned by the consumer (Storage).
_Avoid_: provider, factory, credential service.

**Backend identity**:
One set of credentials (user / password / lease), independent of which host uses it.
_Avoid_: account, login, role.

**Host**:
The location of one database server (dsn / host / port) — topology, never identity.
_Avoid_: node, instance.

**HostBound view**:
An adapter that pairs one **CredentialSource** with one **Host** at connect time,
so a single credential can serve many servers without the broker knowing the host list.
_Avoid_: routing broker, multi-host broker.

CONTEXT.md  view on Meta::CPAN


**Replicant**:
A read-only backend in the pool; its **CredentialSource** never gates transactions.
_Avoid_: slave, secondary.

## Relationships

- An **AccessBroker** supplies exactly one **Backend identity**; it never holds a **Host** list.
- **Replicated** owns **Topology** and **Routing**; it assigns each **Host** to a **Backend**.
- A **HostBound view** pairs one **CredentialSource** with one **Host** — this is how one credential serves many servers.
- Only the **Master** backend's **CredentialSource** gates transaction safety; **Replicant** credentials never do.
- A **CredentialSource** seam is real on the source axis (two adapters: `Static`, `Vault`); on the consumer axis it has two real consumers in core — the synchronous `Storage::DBI` (connect/reconnect path) and the async, Future-returning `Storage::A...

## Example dialogue

> **Dev:** "If one Vault role issues a user/password valid on the primary and all replicas, do I hand each replica its own broker?"
> **Owner:** "No — that is one **Backend identity**, so it is one **AccessBroker**. Replicated owns the **Host** list; a **HostBound view** pairs that one credential with each **Host** at connect time. The broker never sees the list."
> **Dev:** "So a broker can point at multiple servers?"
> **Owner:** "A broker never represents multiple **Hosts** — that would be **Topology** in the wrong layer. It represents one credential that Replicated spans across **Hosts**."

## Flagged ambiguities

Changes  view on Meta::CPAN


    * Misc
        - Test suite is now officially certified to work under very high random
          parallelism: META x_parallel_test_certified set to true accordingly
        - Typo fixes from downstream debian packagers (RT#112007)

0.082821 2016-02-11 17:58 (UTC)
    * Fixes
        - Fix t/52leaks.t failures on compilerless systems (RT#104429)
        - Fix t/storage/quote_names.t failures on systems with specified Oracle
          test credentials while missing the optional Math::Base36
        - Fix test failures when DBICTEST_SYBASE_DSN is set (unnoticed change
          in error message wording during 0.082800 and a bogus test)
        - Remove largely obsolete test of SQLite view deployment (RT#111916)

    * Misc
        - Work around rare test deadlock under heavy parallelism (RT#108390)

0.082820 2015-03-20 20:35 (UTC)
    * Fixes
        - Protect destructors from rare but possible double execution, and

MANIFEST  view on Meta::CPAN

docs/adr/0003-apply-limit-replaces-limit-dialect.md
docs/adr/0004-sqlmaker-select-where-paren-restore-and-expand-op.md
docs/adr/0005-sqlmaker-fast-switching-via-rebase-sqlmaker.md
docs/adr/0006-native-deploy-owns-sql-generation.md
docs/adr/0007-native-introspect-and-diff-layer.md
docs/adr/0008-dbio-sql-util-cross-driver-helpers.md
docs/adr/0009-loader-folded-into-dbio-generate.md
docs/adr/0010-candy-integrated-and-cake-ddl-dsl.md
docs/adr/0011-timestamp-and-helpers-integrated-into-core.md
docs/adr/0012-replicated-storage-in-core.md
docs/adr/0013-accessbroker-credentialsource-seam.md
docs/adr/0014-async-storage-interface.md
docs/adr/0015-agent-skill-infrastructure-hardlink-and-model-pinning.md
docs/adr/0016-mssql-connector-registry-reblesses-to-sybase-storage.md
docs/adr/0017-native-escape-hatch-methods-on-storage.md
docs/adr/0018-introspect-subdir-helper-pattern-is-mandatory.md
docs/adr/0019-desired-state-diff-ignores-target-undef-fields.md
docs/adr/0020-temp-database-dsn-shape-hook.md
docs/adr/0021-constraint-name-optional-canonical-fk-key.md
docs/adr/0022-helper-classes-use-class-accessor-grouped.md
docs/adr/0023-relationship-info-source-accessor-public-contract.md

docs/adr/0012-replicated-storage-in-core.md  view on Meta::CPAN

replicated storage a peer of every other core storage and component — same OOP
substrate, same MRO, no parallel object system — and removes the optional-feature
gate so any schema can `load_components('DBIO::Replicated')` without dependency
gymnastics. The de-Moosing is the architecturally significant half: "in core" is
the headline, but "in core *without* the Moose stack" is the decision that made
it fit.

Putting the subsystem at top-level `DBIO::Replicated::*` (not
`DBIO::Storage::Replicated::*`) signals it is a first-class feature coordinating
storage backends, not a Storage-layer plugin. The clean separation of
*identity/credentials* from *topology/routing* — Replicated owns Topology and
Routing; the credential side is the AccessBroker seam (ADR 0013) — is the domain
boundary documented in CONTEXT.md.

Heritage records it (`lib/DBIO/Manual/Heritage.pod:96-115`): "C<DBIx::Class::
Replicated> was an external distribution. L<DBIO::Replicated> ships in the core
distribution," loaded as a Schema component; and the migration table
(Migration.pod) maps `DBIx::Class::Replicated → DBIO::Replicated (in core)`.

## Consequences

docs/adr/0013-accessbroker-credentialsource-seam.md  view on Meta::CPAN

# ADR 0013 — AccessBroker / CredentialSource seam

- Status: accepted
- Date: 2026-06-19
- Tags: accessbroker, credentialsource, storage, vault, replicated, backfill

## Context

DBIx::Class carried database credentials as raw `connect_info` — a DSN plus
user/password, or a single DSN string. There was no abstraction for *where*
credentials come from, no lifecycle (fetch / rotate / refresh), and no way to
plug in a secrets backend. With replicated storage now in core (ADR 0012) and
credential rotation (e.g. Vault-issued leases) a real operational need, DBIO
separates two historically-independent strands that now share a codebase:
credential provision and read/write routing. The domain language for this seam is
defined in `CONTEXT.md`; this ADR records the architectural decision, not the
vocabulary.

## Decision

Introduce a credential seam with two axes — a *source* axis (where credentials
come from) and a *consumer* axis (who needs them) — mediated by
`DBIO::AccessBroker`.

- **AccessBroker is the CredentialSource.** `DBIO::AccessBroker`
  (`lib/DBIO/AccessBroker.pm`) supplies the connect info for exactly *one backend
  identity*, and is storage-agnostic — it returns connection *parameters*, not
  handles. The abstract `connect_info_for` croaks until a subclass implements it
  (`AccessBroker.pm:54-55`); `needs_refresh`/`refresh` provide the rotation
  lifecycle. A broker does **not** route and does **not** own a host list
  (CONTEXT.md:12-19, 54).
- **Source axis: Static + Vault.** Two adapters subclass AccessBroker:
  `DBIO::AccessBroker::Static` (single fixed credential set, transaction-safe, no
  rotation) and `DBIO::AccessBroker::Vault` (TTL'd rotating credentials,
  not transaction-safe by default). `DBIO::AccessBroker::HostBound` is *not* a
  third source — it is a view that pairs one CredentialSource with one Host at
  connect time (CONTEXT.md:29-32), holding no credentials of its own and
  delegating every credential operation to the wrapped broker, so one credential
  can serve many servers in a Replicated topology (ADR 0012) without the broker
  learning the host list.
- **Consumer axis: Storage::DBI (today).** The broker-consumption machinery —
  the `access_broker` accessor, `set_access_broker`,
  `current_access_broker_connect_info` — lives on the *base* `DBIO::Storage`
  (`lib/DBIO/Storage.pm:25, 98, 134`), so any storage subclass *can* be a
  consumer; in core the one real consumer is `DBIO::Storage::DBI`, via the
  `DBIO::Storage::DBI::AccessBroker` mixin (detects a broker as connect_info,
  routes to it for current credentials at connect/reconnect).

## Rationale

Raw `connect_info` is fine when credentials are static and known at boot, and
nothing else when they rotate or come from a secrets manager. Modelling the
*source* as a pluggable CredentialSource (Static today, Vault for rotation,
anything tomorrow) lets credential lifecycle be a first-class concern without
touching the storage that consumes it. Keeping the broker to a single backend
identity — never a host list — is the deliberate boundary that keeps it from
re-becoming a router: topology and routing belong to Replicated (ADR 0012), and
the `HostBound` view is the explicit, minimal join between one credential and one
host. CONTEXT.md flags the historical `ReadWrite` broker (which bundled identity
+ hosts + routing) as exactly the category error this seam removes.

docs/adr/0013-accessbroker-credentialsource-seam.md  view on Meta::CPAN

HostBound built-ins. CONTEXT.md states the seam shape precisely
(`CONTEXT.md:58`): "real on the source axis (two adapters: `Static`, `Vault`); on
the consumer axis it currently has one real consumer (`Storage::DBI`), with
`Storage::Async` a planned-but-unwired second."

## Consequences

- Credentials are sourced through a pluggable lifecycle, not hard-wired. Adding a
  new source is a new `DBIO::AccessBroker` subclass implementing
  `connect_info_for` (+ rotation hooks); the consuming storage is unchanged.
- The seam keeps credentials and topology cleanly apart: a broker is one
  identity, Replicated owns the host list (ADR 0012), and `HostBound` is the only
  thing that pairs them — so one Vault lease can serve a master and every
  replicant without the broker knowing they exist.
- The consumer machinery sitting on base `Storage` (not `Storage::DBI`) is the
  load-bearing design choice for the open seam: it means async storage can become
  a consumer without moving the seam. See ADR 0014 — and the flag below.
- **Seam-status flag (code vs CONTEXT.md).** CONTEXT.md:58 calls async a
  "planned-but-unwired second" consumer. That is true of the **core abstract**
  `DBIO::Storage::Async`, which references no broker. It is **stale** for the
  concrete async *driver* dists: `dbio-postgresql-async` and `dbio-mysql-async`

lib/DBIO/AccessBroker.pm  view on Meta::CPAN

# ABSTRACT: Credential lifecycle for DBIO connections
package DBIO::AccessBroker;

use strict;
use warnings;
use Carp qw(croak);
use Scalar::Util qw(blessed);
use namespace::clean;

# A CredentialSource: supplies connect info for exactly one backend identity.
# It provides credentials — it does NOT route. Read/write routing and the
# host topology belong to DBIO::Replicated, never here.
#
# Storage-agnostic: works with both Storage::DBI and Storage::Async. The
# primary interface is connect_info_for_storage($storage), which returns
# storage-native connection parameters. Legacy connect_info_for() remains
# available for DBI-shaped broker subclasses.
#
# Subclasses must implement:
#   connect_info_for_storage($storage) — returns storage-native connect info
#   connect_info_for()      — legacy DBI-shaped connect info
#   needs_refresh()         — returns true if credentials need rotation
#   refresh()               — perform credential rotation
#
# The $mode argument ('read'/'write') is vestigial: under a single-identity
# CredentialSource there is nothing to route on. It is accepted for
# backward compatibility and ignored by all built-in brokers.

use Class::Accessor::Grouped;
use base 'Class::Accessor::Grouped';

# HostBound is a subclass of this class, so it can only be compiled once this

lib/DBIO/AccessBroker.pm  view on Meta::CPAN

# storage-native info from the legacy DBI-shaped form, so we provide that
# bridge here.
sub connect_info_for_storage {
  my ($self, $storage, $mode) = @_;
  $mode //= 'write';
  # Subclasses with storage-native formats override this.
  # Default: delegate to connect_info_for (DBI-shaped).
  return $self->connect_info_for($mode);
}

# Do credentials need rotation?
sub needs_refresh { 0 }

# Perform credential rotation
sub refresh { }

# Does this broker rotate credentials over time?
sub has_rotating_credentials { 0 }

# Can transactions safely run through this broker without an explicit override?
# A broker only supplies credentials, so the sole safety hazard is credential
# rotation mid-transaction. Routing is not a broker concern (see Replicated).
sub is_transaction_safe {
  my $self = shift;
  return $self->has_rotating_credentials ? 0 : 1;
}

# Check refresh and return connect info — legacy convenience for DBI-shaped
# callers or brokers already attached to a storage.
sub current_connect_info_for {
  my ($self, $mode) = @_;
  $mode //= 'write';
  if ($self->needs_refresh) {
    $self->refresh;
  }
  return $self->_storage
    ? $self->connect_info_for_storage($self->_storage, $mode)
    : $self->connect_info_for($mode);
}

# Pair this single credential identity with one host, returning a HostBound
# view. The view shares this broker's credentials and rotation lifecycle but
# reports the given host in its connect info, so one credential can serve many
# servers without this broker ever knowing the host list. Accepts a plain host
# string or a hashref ({ host => ..., port => ... }).
sub for_host {
  my ($self, @args) = @_;
  my %host_args =
      @args == 1 && ref $args[0] eq 'HASH' ? %{ $args[0] }
    : @args == 1                            ? (host => $args[0])
    :                                         @args;
  return DBIO::AccessBroker::HostBound->new(broker => $self, %host_args);

lib/DBIO/AccessBroker.pm  view on Meta::CPAN


    # Static — same as traditional connect, one DSN
    use DBIO::AccessBroker::Static;
    my $broker = DBIO::AccessBroker::Static->new(
        dsn => 'dbi:Pg:dbname=myapp',
        username => 'app', password => 'secret',
    );
    # Storage gets storage-native connect info
    my $info = $broker->current_connect_info_for_storage($schema->storage);

    # Vault — rotating credentials from OpenBao/Vault
    use DBIO::AccessBroker::Vault;
    my $broker = DBIO::AccessBroker::Vault->new(
        vault     => WWW::OpenBao->new(endpoint => 'http://vault:8200', token => $token),
        dsn       => 'dbi:Pg:dbname=myapp;host=db',
        cred_path => 'database/creds/myapp',
        ttl       => 3600,         # credentials valid for 1 hour
        refresh_margin => 900,     # refresh 15 min before expiry
    );
    # DBIO can now connect directly with a broker
    my $schema = MyApp::Schema->connect($broker);

See F<t/access_broker/> for a runnable example.

=head1 DESCRIPTION

AccessBroker is a B<CredentialSource>: it supplies the connect info for
exactly one backend identity (one set of credentials). It is
B<storage-agnostic> — it returns connection parameters, not handles —
so it works with both C<Storage::DBI> (sync) and C<Storage::Async>
(async/Future-based). It handles:

=over 4

=item * B<Credential lifecycle> — fetching, rotating, and caching database credentials

=back

A broker does B<not> route, and it does B<not> own a host list. Read/write
routing and the master/replicant topology belong to L<DBIO::Replicated>. One
credential can serve many servers via a L</for_host> view, which pairs this
single identity with one host at connect time.

=head1 NAME

DBIO::AccessBroker - Credential lifecycle for DBIO connections

=head1 TRANSACTION SAFETY

A broker only supplies credentials, so the sole hazard to a running
transaction is credentials rotating mid-flight. DBIO distinguishes:

=over 4

=item * C<has_rotating_credentials()> — new connections may need refreshed credentials

=item * C<is_transaction_safe()> — DBIO may start a transaction through this broker without an explicit override

=back

The default implementation treats brokers as transaction-safe unless they
rotate credentials.

This means:

=over 4

=item * L<DBIO::AccessBroker::Static> is transaction-safe

=item * L<DBIO::AccessBroker::Vault> is not transaction-safe by default

=back

lib/DBIO/AccessBroker.pm  view on Meta::CPAN

=head1 SUBCLASSING

Implement these methods:

=over 4

=item C<connect_info_for_storage($storage)> — Return storage-native connect info

=item C<connect_info_for()> — Optional legacy DBI-shaped connect info

=item C<needs_refresh()> — Return true if credentials should be rotated

=item C<refresh()> — Perform credential rotation

=item C<has_rotating_credentials()> — Return true if credentials rotate across connections

=item C<is_transaction_safe()> — Return true if DBIO may open transactions through this broker

=back

=head2 The C<$mode> argument

The broker methods accept a trailing C<$mode> argument (C<'read'> or
C<'write'>) for backward compatibility. It is B<vestigial>: a broker is a
single-identity B<CredentialSource> with nothing to route on, so all

lib/DBIO/AccessBroker/HostBound.pm  view on Meta::CPAN

  croak "HostBound requires 'host'" unless defined $args{host};

  my $self = $class->SUPER::new(%args);
  $self->_broker($broker);
  $self->_host($args{host});
  $self->_port($args{port}) if defined $args{port};
  return $self;
}

# The wrapped broker is the real CredentialSource. The view holds no
# credentials of its own; callers that need the underlying identity
# (e.g. to compare two views sharing one lease) read it here.
sub underlying_broker { $_[0]->_broker }

sub host { $_[0]->_host }
sub port { $_[0]->_port }

# Lifecycle delegates entirely to the wrapped CredentialSource: one lease,
# one rotation schedule, shared across every host this credential serves.
sub needs_refresh            { $_[0]->_broker->needs_refresh }
sub refresh                  { $_[0]->_broker->refresh }
sub has_rotating_credentials { $_[0]->_broker->has_rotating_credentials }
sub is_transaction_safe      { $_[0]->_broker->is_transaction_safe }

# Attaching the view to a storage must also reach the underlying broker, so
# storage-aware credential lookups and storage-tied rotation keep working.
sub set_storage {
  my ($self, $storage) = @_;
  $self->SUPER::set_storage($storage);
  $self->_broker->set_storage($storage);
  return $self;
}

lib/DBIO/AccessBroker/HostBound.pm  view on Meta::CPAN

See F<t/access_broker/06-replicated-passthrough.t> for a runnable example.

=head1 DESCRIPTION

A C<HostBound> view pairs one B<CredentialSource> (a wrapped
L<DBIO::AccessBroker>) with one host. It is how a single credential can serve
many servers: L<DBIO::Replicated> owns the host list and asks the broker for a
host-bound view per backend via C<< $broker->for_host($host) >>, while the
broker itself never learns the host list.

The view holds B<no credentials of its own>. Every credential operation —
C<needs_refresh>, C<refresh>, C<has_rotating_credentials>,
C<is_transaction_safe> — delegates to the wrapped broker, so all views built
from one broker share a single lease and a single rotation schedule. The view
adds exactly one thing: it injects its host (and optional port) into the
connect info the broker returns, handling both the hashref form
(C<< {host,port,dbname,...} >>) and the DBI-arrayref/DSN form.

=head1 AUTHOR

DBIO & DBIx::Class Authors

lib/DBIO/AccessBroker/Vault.pm  view on Meta::CPAN

  my $self = $class->SUPER::new(%args);

  $self->vault($args{vault})           // croak "Vault broker requires 'vault'";
  $self->dsn($args{dsn})               // croak "Vault broker requires 'dsn'";
  $self->cred_path($args{cred_path})   // croak "Vault broker requires 'cred_path'";
  $self->ttl($args{ttl}                // 3600);
  $self->refresh_margin($args{refresh_margin} // 900);  # 15 min before expiry
  $self->dbi_attrs($args{dbi_attrs}    // {});
  $self->_expires_at(0);

  # Fetch initial credentials
  $self->_fetch_credentials;

  return $self;
}

sub _fetch_credentials {
  my ($self) = @_;
  my $creds = $self->vault->read_secret($self->cred_path);
  croak "Vault returned no credentials for " . $self->cred_path unless $creds;
  $self->_current_username($creds->{username});
  $self->_current_password($creds->{password});
  $self->_expires_at(time() + $self->ttl);
}

sub connect_info_for {
  my ($self, $mode) = @_;
  return [$self->dsn, $self->_current_username, $self->_current_password, $self->dbi_attrs];
}

sub needs_refresh {
  my ($self) = @_;
  return time() > ($self->_expires_at - $self->refresh_margin);
}

sub refresh {
  my ($self) = @_;
  $self->_fetch_credentials;
}

sub has_rotating_credentials { 1 }

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

DBIO::AccessBroker::Vault - Credential rotation with TTL

=head1 VERSION

version 0.900002

=head1 DESCRIPTION

Vault-backed brokers rotate credentials over time and are therefore not
transaction-safe by default. See
L<DBIO::AccessBroker/TRANSACTION SAFETY>.

=head1 AUTHOR

DBIO & DBIx::Class Authors

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2026 DBIO Authors

lib/DBIO/Manual/Heritage.pod  view on Meta::CPAN

The storage calls C<connect_info_for_storage($storage)> on the broker
before each connection. One credential can serve many servers via a
C<< $broker->for_host($host) >> view.

Built-in broker classes:

=over 4

=item L<DBIO::AccessBroker::Static> - single DSN, drop-in replacement

=item L<DBIO::AccessBroker::Vault> - rotating credentials with TTL

=item L<DBIO::AccessBroker::HostBound> - one credential identity pinned to one host

=back

Build a custom broker by subclassing L<DBIO::AccessBroker> and implementing
C<connect_info_for_storage>, C<needs_refresh>, and C<refresh>.

=head2 DBIO::Moo and DBIO::Moose - OO framework bridges

lib/DBIO/Storage.pm  view on Meta::CPAN

sub _assert_transaction_safe_access_broker {
  my $self = shift;

  return if $self->{_access_broker_txn_safety_checked};
  return if $self->transaction_depth;

  my $broker = $self->access_broker or return;
  return if $broker->is_transaction_safe;

  my @reasons;
  push @reasons, 'credential rotation' if $broker->has_rotating_credentials;
  my $reason = @reasons
    ? join(' and ', @reasons)
    : 'broker-specific transaction safety constraints';

  if ($ENV{DBIO_ALLOW_UNSAFE_BROKER_TRANSACTIONS}) {
    carp sprintf(
      'Starting a transaction with unsafe AccessBroker %s via override: %s',
      ref($broker) || $broker,
      $reason,
    );

lib/DBIO/Storage/Async.pm  view on Meta::CPAN

      $self->_clear_access_broker;
      my ($conninfo, $pool_size, $opts) = $self->_normalize_async_connect_info($info);
      $self->{_conninfo}  = $conninfo;
      $self->{_pool_size} = $pool_size;
      $self->{_opts}      = $opts;
    }
  }
  return $self->{connect_info};
}

# Resolve the current connect info to normalise: fresh broker credentials
# when a broker is attached, otherwise the last static conninfo/opts pair.
sub _current_async_connect_info {
  my ($self, $mode) = @_;

  my $connect_info = $self->current_access_broker_connect_info($mode);
  return [$connect_info, {}] if $connect_info && ref $connect_info eq 'HASH';
  return $connect_info if $connect_info;

  return [ $self->{_conninfo}, $self->{_opts} || {} ];
}

lib/DBIO/Storage/Async.pm  view on Meta::CPAN

Detach any broker (via the inherited L<DBIO::Storage/clear_access_broker>)
and tear down the C<conninfo_provider>, so a subsequent non-broker
connect uses the static conninfo path. Call from a driver's
C<connect_info> on the non-broker branch.

=head2 _conninfo_provider

The per-spawn credential coderef installed by L</_setup_access_broker>, or
C<undef> when no broker is attached. Hand this to the pool as its
C<conninfo_provider> so every NEW pool connection is built from fresh
credentials (see L<DBIO::Storage::PoolBase/_spawn_connection>).

=head2 _async_broker_conninfo

  sub _async_broker_conninfo {
    my ($self, $mode) = @_;
    ...
    return $conninfo;  # one fresh, storage-native conninfo value
  }

Required driver seam hook when a broker is in use: return one fresh

lib/DBIO/Storage/Async.pm  view on Meta::CPAN

The async storage tier is the second consumer of the
L<DBIO::AccessBroker> credential seam (the first being L<DBIO::Storage::DBI>;
see L<CONTEXT.md> and the ADRs). The broker-management API itself
(C<set_access_broker>, C<clear_access_broker>,
C<current_access_broker_connect_info>) lives on the base L<DBIO::Storage>,
so it is inherited here unchanged.

What this class adds is the I<async> consumption wiring that was previously
re-implemented by every async driver: detecting a broker passed as connect
info, building the per-spawn C<conninfo_provider> coderef that pulls fresh
credentials, and feeding it to the pool so every NEW pool connection gets
freshly-refreshed connect info. Drivers supply only the one storage-native
seam hook, L</_async_broker_conninfo>.

=head1 POOL CONNECTION ACTIONS

Every physical pool connection is set up with the SAME C<on_connect_do> /
C<on_connect_call> the owning sync storage was configured with -- and torn down
with the matching C<on_disconnect_do> / C<on_disconnect_call> -- so that a
pooled async connection has identical session semantics (C<search_path>,
timezone, C<SET> variables, extension C<LOAD>s, ...) to the sync path on the

share/skills/dbio-core/SKILL.md  view on Meta::CPAN

  password => 'secret',
);

my $schema = MyApp::Schema->connect($broker);
```

### Interface

All brokers must implement:

A broker is a **CredentialSource**: one backend identity, one set of credentials. It does NOT route and does NOT own a host list — routing + topology belong to `DBIO::Replicated`. See `CONTEXT.md`.

| Method | Returns | Purpose |
|--------|---------|---------|
| `connect_info_for` | HASHREF | `{host, port, dbname, user, password, dbi_attrs}` |
| `connect_info_for_storage($storage)` | HASHREF | Storage-aware version |
| `needs_refresh` | Bool | True if credentials need rotation |
| `refresh` | - | Perform credential rotation |
| `has_rotating_credentials` | Bool | True if credentials rotate |
| `is_transaction_safe` | Bool | False if rotating (default) |
| `for_host($host)` | broker view | One credential, pinned to one host (HostBound) |

The trailing `$mode` ('read'/'write') arg is **vestigial** — accepted for back-compat, ignored. Routing decides read vs write, not the broker.

### Implemented Brokers

| Broker | File | Use Case |
|--------|------|----------|
| `DBIO::AccessBroker::Static` | Static.pm | Single DSN, transaction-safe |

share/skills/dbio-driver-development/SKILL.md  view on Meta::CPAN

Inherited for free: connection/disconnection, SQL gen via SQLMaker, txn_*, insert/update/delete/select, handle caching, prepared statements, DBH attrs.

## AccessBroker

All drivers support AccessBroker. Pass broker to `Schema->connect($broker)` instead of raw DSN. **Full broker interface lives in dbio-core skill.**

Storage detects via `_is_access_broker_connect_info([$broker])` (true if single blessed). Then:
1. `set_access_broker($broker, 'write')` attaches
2. `_current_dbi_connect_info($mode)` → `current_access_broker_connect_info($mode)`
3. Broker returns HASHREF, Storage normalizes
4. Connection proceeds with broker credentials

Rotating creds: storage re-fetches on next connect. Async pools refresh via `_conninfo_provider` calling `current_connect_info_for_storage($storage, $mode)`.

## Async Drivers (ADR 0030/0031)

Async is an explicit, **per-connection mode** (ADR 0030), not a separate storage
class chosen at schema-author time. A schema connected with
`connect(..., { async => $mode })` answers the six `*_async` storage methods and
the ResultSet/Row `*_async` helpers through an embedded async backend; without
`{ async => ... }` it stays sync (`*_async` croaks — no auto-fallback). An add-on

share/skills/dbio-perl-class-patterns/SKILL.md  view on Meta::CPAN

__PACKAGE__->mk_group_accessors(simple => qw/host port user password/);
```

Generierte Accessoren rufen intern `get_simple('host')` / `set_simple('host', $v)` auf — Logik steckt in `get_*`/`set_*` Hooks.

## Accessor-Gruppen

### `simple` — Instanzdaten

```perl
__PACKAGE__->mk_group_accessors(simple => qw(_storage _credentials _read_index));
```

Speichert direkt im Objekt-Hash. `$obj->host(1)` → `set_simple('host', 1)`.

### `inherited` — vererbbare Klassendaten

```perl
__PACKAGE__->mk_group_accessors(inherited => qw(sql_name_sep sql_quote_char));
__PACKAGE__->sql_name_sep('.');
```

share/skills/dbio-perl-class-patterns/SKILL.md  view on Meta::CPAN

`simple` für Objektzustand, `inherited` für vererbbare Konfig, `component_class` für austauschbare Implementationen.

## Keine Magie — explizit

Kein `has`, kein `with`, kein `requires`. `_build_*` (private Builder) statt `BUILD`.

```perl
package DBIO::AccessBroker::Credentials;
use base qw/Class::Accessor::Grouped/;
__PACKAGE__->mk_group_accessors(simple => qw(
  _storage _credentials _credentials_provider _base_params _read_index
));

sub new {
  my ($class, %args) = @_;
  my $self = bless {}, $class;
  # ... direkt arbeiten
  return $self;
}
```

t/access_broker/02-vault.t  view on Meta::CPAN

    refresh_margin => 15,
  );

  my $expires = $broker->_expires_at;
  cmp_ok $expires, '>=', $start + 60,
    '_expires_at set to at least start + ttl';
  cmp_ok $expires, '<=', $start + 60 + 1,
    '_expires_at set to at most start + ttl + 1s of clock drift';

  ok !$broker->needs_refresh,
    'fresh credentials do not need refresh (within margin)';

  # Force a state where needs_refresh must be true: rewind by hand so that
  # _expires_at is in the past by more than the margin. We poke the accessor
  # directly because the broker's rotation path is exercised separately below.
  $broker->_expires_at(time() - 1);
  ok $broker->needs_refresh,
    'expired credentials need refresh (past expiry)';

  # needs_refresh math: time() > (expires_at - margin)
  # = time() > (time() + 100 - 15) = time() > time() + 85 = FALSE
  $broker->_expires_at(time() + 100);
  ok !$broker->needs_refresh,
    'credentials with more than margin remaining do not need refresh';

  # = time() > (time() + 5 - 15) = time() > time() - 10 = TRUE
  $broker->_expires_at(time() + 5);
  ok $broker->needs_refresh,
    'credentials inside the refresh_margin need refresh';

  # Edge case: ttl=0 still produces a numeric _expires_at and does not die.
  $vault = StubVault->new(responses => [
    { creds => { username => 'u', password => 'p' } },
  ]);
  my $ttl0 = make_broker(
    vault          => $vault,
    ttl            => 0,
    refresh_margin => 0,
  );

t/access_broker/02-vault.t  view on Meta::CPAN

  ], 'connect_info_for returns the DBI-shaped tuple with stubbed creds';

  # $mode is vestigial — broker must not route on it.
  my $info_read = $broker->connect_info_for('read');
  is_deeply $info_read, $info,
    'connect_info_for returns the same info regardless of $mode (no routing)';
};

# -----------------------------------------------------------------------------
# 3. Credential rotation: a sequence of responses (expired -> fresh) is read
#    in order, the second set of credentials replaces the first, and the
#    refresh_margin bookkeeping updates accordingly.
# -----------------------------------------------------------------------------
subtest 'credential rotation' => sub {
  my $vault = StubVault->new(responses => [
    { creds => { username => 'old-user', password => 'old-pass' } },
    { creds => { username => 'new-user', password => 'new-pass' } },
  ]);
  my $broker = make_broker(
    vault => $vault,
    ttl   => 3600,
  );

  my $first = $broker->connect_info_for;
  is $first->[1], 'old-user', 'first fetch returned the initial credentials';
  is $vault->call_count, 1,    'vault was hit exactly once on construction';

  my $pre_refresh_expires = $broker->_expires_at;

  # Simulate the credential going stale — refresh now requires a new fetch.
  $broker->_expires_at(time() - 1);
  ok $broker->needs_refresh, 'after expiry, needs_refresh is true';

  $broker->refresh;
  is $vault->call_count, 2, 'refresh drove a second vault read';

t/access_broker/02-vault.t  view on Meta::CPAN

# -----------------------------------------------------------------------------
# 4. Transaction-safety: Vault is a rotating broker, so txn_begin must refuse
#    it by default, and the env override must let it through (with a warning).
# -----------------------------------------------------------------------------
subtest 'transaction-safety: refusal and override' => sub {
  my $vault = StubVault->new(responses => [
    { creds => { username => 'u', password => 'p' } },
  ]);
  my $broker = make_broker(vault => $vault);

  ok $broker->has_rotating_credentials,
    'Vault broker reports has_rotating_credentials';
  ok !$broker->is_transaction_safe,
    'Vault broker is not transaction-safe by default';

  my $schema = DBIO::Test->init_schema(no_deploy => 1);
  $schema->storage->set_access_broker($broker);

  # Default: txn_begin refuses with the expected reason.
  {
    local $ENV{DBIO_ALLOW_UNSAFE_BROKER_TRANSACTIONS} = 0;
    my $err = _caught { $schema->storage->txn_begin };

t/access_broker/02-vault.t  view on Meta::CPAN

#    exception takes (and the karr ticket can decide whether to convert it
#    to throw_exception per F21).
# -----------------------------------------------------------------------------
subtest 'error path: 5xx-shaped vault error surfaces from the broker' => sub {
  my $vault = StubVault->new(responses => [
    { error => "HTTP 500 Internal Server Error" },
  ]);

  my $err;
  {
    # The broker is constructed in `new` via _fetch_credentials, so the
    # exception fires at construction time. Catch it without letting it
    # kill the test.
    local $@;
    eval { make_broker(vault => $vault) };
    $err = $@;
  }

  ok defined $err,
    'broker construction propagates a 5xx-shaped error from the vault';

  # The broker's _fetch_credentials does not catch upstream errors — it lets
  # the vault's exception propagate. Document the path: the message is
  # preserved end-to-end, and the broker does NOT swallow or rewrite it.
  like "$err", qr/HTTP 500/,
    'error message reaches the caller verbatim';
  like "$err", qr/DBIO\/AccessBroker\/Vault\.pm/,
    'error trace points to the broker line that called read_secret';
};

# Variant: vault returns no creds at all (e.g. a path the vault knows nothing
# about). The broker's `unless $creds` guard fires.

t/access_broker/02-vault.t  view on Meta::CPAN


  my $err;
  {
    local $@;
    eval { make_broker(vault => $vault) };
    $err = $@;
  }

  ok defined $err, 'broker construction dies when vault returns no creds';
  like "$err",
    qr/Vault returned no credentials for database\/creds\/myapp/,
    'error names the cred_path that came back empty';

  # NOTE: per F21, all error paths should go through the exception taxonomy
  # (DBIO::Exception / throw_exception). The current broker uses bare `croak`,
  # which produces a plain die. This test will start failing once the broker
  # is migrated; the failure is intentional — it documents the gap.
  isnt blessed($err), 'DBIO::Exception',
    'still a plain croak (F21 migration to throw_exception pending)';
};

t/access_broker/06-replicated-passthrough.t  view on Meta::CPAN

}

# A broker that counts credential rotations, for the HostBound shared-lease test.
{
  package CountingBroker;
  use base 'DBIO::AccessBroker';
  sub new { bless { refreshes => 0 }, $_[0] }
  sub connect_info_for { { dbname => 'app', user => 'u', password => 'p' } }
  sub needs_refresh { 0 }
  sub refresh { $_[0]->{refreshes}++ }
  sub has_rotating_credentials { 1 }
}

my $schema = DBIO::Test->init_schema(
  no_deploy => 1,
  storage_type => {
    '+DBIO::Replicated::Storage' => {
      backend_storage_class => 'DBIO::Test::Storage',
      balancer_type         => 'DBIO::Replicated::Balancer::First',
    },
  },

t/access_broker/06-replicated-passthrough.t  view on Meta::CPAN

  is refaddr($view_a->underlying_broker), refaddr($cred),
    'view A wraps the shared CredentialSource';
  is refaddr($view_b->underlying_broker), refaddr($cred),
    'view B wraps the same CredentialSource';

  $view_a->refresh;
  is $cred->{refreshes}, 1, 'refresh through view A rotates the shared lease';
  $view_b->refresh;
  is $cred->{refreshes}, 2, 'refresh through view B rotates the same lease';

  ok $view_a->has_rotating_credentials, 'view reports the underlying rotation';
  ok !$view_a->is_transaction_safe, 'a rotating broker view is not transaction-safe';

  my $info_a = $view_a->connect_info_for;
  is $info_a->{host}, 'host-a', 'view A binds host-a into the connect info';
  is $info_a->{dbname}, 'app', 'credentials still come from the shared broker';

  my $info_b = $view_b->connect_info_for;
  is $info_b->{host}, 'host-b', 'view B binds host-b';
  is $info_b->{port}, 5433, 'view B binds its port too';
}

done_testing;

t/storage/async_access_broker.t  view on Meta::CPAN


use Test::More;

BEGIN { eval { require Future; 1 } or plan skip_all => 'Future not installed' }

use DBIO::Storage::Async;
use DBIO::Storage::PoolBase;

# A rotating credential source: each call yields a freshly-numbered
# storage-native conninfo hashref, so we can prove the pool pulls fresh
# credentials per spawn rather than reusing a snapshot.
{
  package TestBroker;
  use base 'DBIO::AccessBroker';

  sub new { bless { calls => 0 }, shift }

  # Async storages call current_connect_info_for_storage, which routes
  # through connect_info_for_storage. Return storage-native shape.
  sub connect_info_for_storage {
    my ($self, $storage, $mode) = @_;

t/storage/async_access_broker.t  view on Meta::CPAN


# --- wiring a broker installs the per-spawn provider ---

my $broker = TestBroker->new;
$storage->connect_info([ $broker ]);

is $storage->access_broker, $broker, 'broker attached via inherited set_access_broker';
ok $storage->_conninfo_provider, 'conninfo_provider installed by _setup_access_broker';
is ref($storage->_conninfo_provider), 'CODE', 'provider is a coderef';

# --- every NEW pool connection gets fresh credentials ---

my $pool = $storage->pool;
isa_ok $pool, 'DBIO::Storage::PoolBase', 'pool';

my $c1 = $pool->acquire->get;
my $c2 = $pool->acquire->get;
my $c3 = $pool->acquire->get;

is_deeply $c1->{conninfo}, { dbname => 'app', user => 'user_1' },
  'first spawn pulled fresh credentials via the inherited seam';
is_deeply $c2->{conninfo}, { dbname => 'app', user => 'user_2' },
  'second spawn pulled freshly-refreshed credentials';
is_deeply $c3->{conninfo}, { dbname => 'app', user => 'user_3' },
  'third spawn pulled freshly-refreshed credentials again';

is $broker->{calls}, 3, 'broker consulted once per pool spawn, not snapshotted';

# --- detaching the broker tears the provider down ---

my $plain = MockAsyncStorage->new(undef);
$plain->connect_info([ $broker ]);
ok $plain->_conninfo_provider, 'provider present while broker attached';
$plain->connect_info(['dbi:Pg:dbname=app']);
ok !$plain->access_broker, 'broker cleared on non-broker connect_info';



( run in 1.707 second using v1.01-cache-2.11-cpan-f4a522933cf )