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

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.