NAME
Langertha::Skeid::CustomerSource - Customer keys from a database relation - the section, the row contract, the fetch loop
VERSION
version 0.004
SYNOPSIS
# In the Skeid config file:
customer_source:
dsn: dbi:Pg:dbname=console;host=db
user: skeid_reader
password_env: SKEID_CUSTOMER_DB_PASSWORD
relation: skeid.customer_keys
listen: skeid_customer_keys
# What Skeid runs against it, and nothing else:
my $sql = Langertha::Skeid::CustomerSource->statement('skeid.customer_keys');
# SELECT key_id, name, policy, models FROM "skeid"."customer_keys"
DESCRIPTION
The caller side of the config -- which customer key ids may come in (client_auth) and which policy each takes (keys:) -- can come from a database relation as well as from the file, so that an application that issues and revokes customer keys does not have to write Skeid's YAML (ADR 0026). This module holds the parts of that which do not depend on the rest of the config: the customer_source section, the statement, the row contract, the merge into the file's config, and the loop that fetches on Mojo::IOLoop.
The fetch itself sits behind a fetcher: an object with fetch($cb), which calls $cb->($error) or $cb->(undef, \@rows) later, from the event loop, and optionally on_notify($cb) (call $cb when the source says something changed) and release (let go of every connection). Langertha::Skeid::CustomerSource::DBI is the implementation, on DBI in child processes; tests give "customer_source_fetcher" in Langertha::Skeid a fake.
The row contract
Skeid runs one statement, "statement", and reads four columns per row: key_id (k_ + 40 lowercase hex, unique in the relation), name (a label, may be NULL), policy (a profile in the file's policies:; NULL takes default_policy) and models (text[]; NULL keeps the profile's list, '{}' is no models). A row present means the key is active. See "check_rows" for what is rejected.
normalize_config
my $cfg = Langertha::Skeid::CustomerSource->normalize_config($section,
usage_store => $config->{usage_store});
Checks a customer_source section and returns it normalized: backend (postgresql, the only one), dsn, user, password, relation, refresh_interval_s (default 30), listen (default none), max_stale_s (default 0) and connect_timeout_s (default 5).
The connection is normalized by "normalize_config" in Langertha::Skeid::UsageStore, so dsn, or host / port / dbname (database), user and password_env mean what they mean in usage_store. With from_usage_store: true the connection is the usage store's, which must be postgresql; user and password_env given here override its own.
Croaks on a section that is not a hash, an unknown key, a password (only password_env: a password never sits in the file, ADR 0003), a backend other than PostgreSQL, a dsn that is not dbi:Pg:, a missing or malformed relation, a listen that is not an identifier, from_usage_store without a PostgreSQL usage store or together with connection keys of its own, a refresh_interval_s or connect_timeout_s that is not a number above 0, and a max_stale_s below 0. No message carries a password.
statement
my $sql = Langertha::Skeid::CustomerSource->statement('skeid.customer_keys');
The one statement Skeid runs against the relation: its four columns by name, the relation quoted as an identifier (each part of a schema-qualified name on its own). Quoting keeps the case as written: Skeid.Keys reads "Skeid"."Keys", not skeid.keys. Croaks on a relation that is not one identifier or two joined by a dot -- letters, digits and underscores, not starting with a digit -- so nothing in it is ever read as SQL.
check_rows
my $checked = Langertha::Skeid::CustomerSource->check_rows(\@rows, policies => \%policies);
# { accepted => [ { key_id, name, policy, models, label }, ... ],
# rejected => [ { row => "'alice'" | 'row 3', reason => '...' }, ... ] }
Validates a snapshot on its own, before anything is merged. A row is rejected when it is not a hash, when its key_id is not a full key id (k_ + 40 lowercase hex: a short id, uppercase, or anything else -- a key pasted where its id belongs among them), when another row has the same key_id (both are rejected), when policy is not NULL and names no key of %policies, or when models is not NULL and not a list of non-empty names. A rejected row grants nothing.
A row is labelled by its name when it has one, else by its position in @rows (from 1). Neither a label nor a reason carries any other value of the row.
snapshot_digest
my $digest = Langertha::Skeid::CustomerSource->snapshot_digest(\@rows);
A digest of a snapshot that does not depend on row order: what decides whether a fetch changed anything. Two fetches of the same rows in another order digest alike.
merge_into
my $merge = Langertha::Skeid::CustomerSource->merge_into($config, \@rows);
# { config => \%merged, allow => [ key ids ], accepted => N,
# rejected => [ { row, reason }, ... ], shadowed => [ labels ] }
Merges a snapshot into a file's config (ADR 0026, decision 3). The rows are checked first ("check_rows", against the file's policies). The file wins per key id: an accepted row whose id the file's keys: names -- directly, through names:, or by a short id that is its prefix -- or that is itself a names: name, is shadowed: its policy and models are ignored. Every other accepted row with a policy or models becomes a keys: entry of the returned config, which is a shallow copy; the file's own config is not changed.
allow lists the id of every accepted row, shadowed ones included, for the client_auth allowlist; the caller adds them only when the file declares client_auth. Nothing a row contains can make the merged config fail to load.
THE FETCH LOOP
An object of this class runs one fetcher on Mojo::IOLoop: a fetch at start, then one every refresh_interval_s; after a failure the next one waits refresh_interval_s doubled per failure in a row, at most 60 seconds. A fetch that has not answered within connect_timeout_s counts as failed, and its answer, should it still come, is ignored. A notification from the fetcher schedules a fetch notify_debounce_s later (default 0.25), however many arrive in between. Only one fetch is in flight at a time. Nothing here ever blocks: every wait is a loop timer.
config
The normalized section ("normalize_config"). Required.
fetcher
The fetcher object. Required.
on_rows
Called with the rows of every successful fetch, unchanged or not.
on_failure
Called with the error of every failed fetch.
notify_debounce_s
How long after a notification the fetch runs (default 0.25).
last_success_at
last_failure_at
failures
last_error
When the last fetch succeeded, when the last one failed (epoch seconds), how many have failed in a row, and why the last one did (undef after a success). The error never carries a password=.
completed
How many fetches have finished, either way.
start
Schedules the first fetch for the next loop turn and hands the fetcher its notification callback. Returns the object.
stop
Stops for good: no fetch is scheduled any more, an answer still in flight is ignored, and the fetcher is released.
release
Lets the fetcher close its connections (its release, when it has one) without stopping the loop: the next fetch opens them again. skeid serve calls it before the prefork manager forks, so no worker inherits a connection.
fetch_now
Runs a fetch at once, unless one is in flight -- then one more runs when it finished.
retry_delay
The wait before the next fetch after the failures so far: refresh_interval_s doubled per failure in a row after the first, at most 60 seconds -- the back-off of a failing config reload.
status
my $status = $source->status;
The loop's own state, for "customer_source_status" in Langertha::Skeid: ok, last_success_at, failed_at (both ISO 8601 UTC or undef), failures and error. ok is true once a fetch succeeded and the last one did.
SEE ALSO
"customer_source" in Langertha::Skeid -- the section in the config, and how its rows merge
Langertha::Skeid::CustomerSource::DBI -- the fetcher: DBI in a child process
ADR 0026 in the distribution repository
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-skeid/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.