view release on metacpan or search on metacpan
# 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.
**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
* 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
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';