Skip to content

Releases: reyemb/painfree-ebics-middleware-python

painfree 0.8.0

Choose a tag to compare

@reyemb reyemb released this 04 Sep 15:07

A token's scope claim no longer nullifies a grant when it names none of this service's scopes.

Privilege is the union of a subject's grants, intersected with the scopes the token asked for. PAINFREE_OIDC_SCOPE_CLAIM defaults to scope, which is OIDC's own claim, and a compliant provider fills it with openid profile email. None of those is a scope of this service, so the intersection was with the empty set: a correct operator grant, a valid sign-in, and a caller holding nothing — with no line in the log stream saying which of the two decided it.

The intersection now applies only when the claim names at least one scope this service knows. A deployment that put this service's scope names into its provider narrows exactly as before; one that never asked to narrow is repaired. Nobody's privilege moves except back towards the grant they were given, which is what makes the change safe to take. Narrowing that does happen is now recorded rather than inferred: auth.scopes_narrowed names the surviving scopes, and the console says a token can narrow a grant.

If you had to create client scopes in your provider named after this service's privileges to make grants work, that was the workaround for this. You can remove them.

The four operator pages

Schedules lead with coverage. The failure this page exists to catch is a silent one: a run that finds nothing is a success, so a schedule whose window never advances stays healthy run after run while the days behind it pile up. That was a line of text in the seventh of eight columns. It is now a band per schedule — covered solid, outstanding hatched, not-yet-started in its own neutral state — drawn across the window the next run will ask for. Health moved to the row's leading edge and the BTF triplet folded into the schedule's own cell; eight columns became four.

Access is a matrix. People down, connections across, the level in the cell, so who can move money at this bank and what does this person hold are each one read. An oversight grant names no connection, so it spans the columns rather than sitting in one, and a column header links to that connection's own page. The page also says how each person signs in: a grant is inert without a way to use it, and a subject granted before their account exists could never sign in while looking exactly like a working grant.

Deleting an account is a page. Every lever used to be live on screen — four forms per row, with Delete beside Set password. The levers now sit behind a per-row disclosure with the two that stop somebody signing in separated and labelled, and deleting asks on a page of its own that names what survives it: the grants stay, so an account remade under the same name comes back holding what it held. Under an identity provider, the local accounts that authentication already refuses are named.

The audit trail reads as actions. One operator action writes several rows, and they already share a request_id. Consecutive rows sharing one are drawn under a single heading; three quick filters became links with the full form behind them, and the correlation ids sit under the link they belong to.

No migration, no new stored field, and no change to any contract.

painfree 0.7.1

Choose a tag to compare

@reyemb reyemb released this 04 Sep 08:38

Three figures that were being read down a column are now read across.

0.7.0 gave account statements and bank responses pages of their own. Two parts of those pages shipped as ordinary two-column tables of label and value, and both are numbers somebody reads at a glance rather than a list they read down.

An account statement leads with its four balances. Opening, money in, money out, closing, at headline size and with tabular-nums so they line up with each other and with the ledger below — each carrying its own date and the bank's own balance code. Whether the entries add up to the closing balance the bank sent moves to its own line beneath them, because that is a statement about the four figures rather than a fifth one.

A status report says on one line what the message carried and what the bank did with it. Sent, accepted and refused across a single row, one row per currency, rather than a second table whose one row read as though it were more data.

Each half of the statements index says how much is behind it. The index splits into account statements and bank responses, and neither half said whether the other had anything in it — so an operator with no responses yet and one with forty saw the same screen. The counts are taken under the same filters as the table below.

No new data on any of these pages, no migration, and no change to the normalised statement shape or to what any endpoint returns.

painfree 0.7.0

Choose a tag to compare

@reyemb reyemb released this 03 Sep 19:43

A bank's status report is now read against the payment it answers, instead of being printed as JSON.

/ui/statements/<id> rendered every downloaded document the same way: a key/value block about the file, then the normalised payload in a <pre>. For a pain.002 that is a screen of nulls with one status buried in it, and not one figure saying who was paid or how much.

The figures are not in the report, and that is not a defect in the report. Banks report by exception: the report for a file the bank took whole names no transaction at all, and a partially accepted one lists only what failed. A page rendered from the report is therefore empty in the ordinary case and carries no amounts in any case. The money is in the pain.001 that was sent, one OrgnlMsgId away.

So the rows are the message's and the report annotates them. Beneficiary, amount, execution date and reference on one side; the bank's status and its own reason code and text on the other, with the boundary between the two drawn on the page, because nobody should have to guess which document a number came from.

All three status levels are kept, and none is derived from another. GrpSts, PmtInfSts and TxSts are shown side by side, with the level that decided the order marked and a level the bank left out saying not reported rather than repeating the level above it. Where a transfer's status was inherited from its payment block or from the whole message, the page says so: "the bank accepted this payment" and "the bank accepted the file this payment was in" are different sentences. A listed rejection is never read across to the transfer beside it.

An account statement is drawn as an account statement. Two amount columns rather than one signed one, entries in the order the bank sent them, each opening into its transactions — which for a collected entry is the only place a payroll run's two hundred lines exist.

The running balance is arithmetic the console does, in Decimal, and the page says so: no camt message carries one. Three rules keep it honest. No opening balance means no column, because a column of figures all wrong by the same amount is worse than no column. Only booked entries move it, so a PDNG entry is grouped above the ledger with no balance beside it. And the total is checked against the closing balance the bank stated, with the page saying whether it adds up — not checked and does not add up being different answers.

An entry that names a MsgId this service generated links to the order it settles. That is the last link of the chain the status report starts: the report says the bank took the payment, the booking says the money left the account.

The statements index is split in two, because it holds two kinds of document and used to carry the union of both column sets — a closing balance column empty on every status report, an order column empty on every statement. It still lists no entries, and the responses half lists no amount and no counterparty either.

None of this needs JavaScript: the disclosures are <details> and the filters are links, so every state is a URL and the pages work with scripting off.

No migration, and nothing new is stored. Every value on these pages was already in the normalised payload, on the order, or in the status-code table the reconciler decides with. Reading the payment behind a status report needs payments:read on the connection; without it the page still renders what the report itself says.

painfree 0.3.2

Choose a tag to compare

@reyemb reyemb released this 02 Sep 15:19

The EBICS request a bank refuses is now kept, and checked against the official H005 schemas.

091113 EBICS_INVALID_REQUEST_CONTENT names no element, and the request was discarded the moment the exchange ended — so an operator holding that code had no next step inside the product, and the only party who could still see the document was the bank. Diagnosing one meant reading source and telephoning.

The request is now captured where it is still in hand, stored on the order, and validated at the same moment. The order page shows the verdict and offers the document as a file, because the next thing anybody does with it is put it somewhere else.

A clean result is a finding, not an exoneration, and the console says so. A schema can answer is this well-formed EBICS; nothing can answer will this bank accept it. What clean does tell you is that the disagreement is about content rather than shape — which is what to ask the bank about.

Nothing sensitive is stored: an upload's initialisation carries the electronic signature and a transaction key wrapped to the bank's own public half. The payment file travels in the transfer phase, which a refusal at initialisation never reaches — and there is a test asserting the stored bytes contain no CstmrCdtTrfInitn and no IBAN.

The nine H005 schemas are vendored for the same reason the pain.001 one is: they run at runtime, and a validator that reads a directory next to the repository is silently absent in a container. Verbatim from the same MIT source, with their sha256s recorded.

Also: the console uses the width of the window. The page carried a 1120px cap that kept prose legible and squeezed every table into it, so the orders table scrolled sideways while a third of the window sat empty. The reading measure now lives on the prose, where it belongs.

Migration 0018_refused_request adds two nullable columns. No backfill: the requests already refused are gone, and an existing deployment migrates to not captured, which the console says in those words.

painfree 0.3.1

Choose a tag to compare

@reyemb reyemb released this 02 Sep 14:27

A Swiss bank refused a pain.001 this service built. DbtrAgt/FinInstnId/Othr with Id of NOTPROVIDED"Das Element 'Othr' soll in diesem Kontext nicht verwendet werden". That is the EPC SEPA convention for "IBAN only, no BIC", and it is not a Swiss one.

The fix is a condition rather than a deletion, because under SEPA the element is exactly right and a receiving bank in the euro area looks for it. What decides is the declaration itself — SvcLvl/Cd of SEPA:

scheme profile FinInstnId when no debtor BIC is given
none, or Swiss empty
SvcLvl/Cd = SEPA Othr/Id = NOTPROVIDED

The same field that tells the bank which scheme a payment follows now decides which conventions the document is built under, so the two cannot disagree. A currency or an IBAN country would both have been guesses — wrong for a EUR payment at a Swiss bank, and wrong for a Swiss company paying into the euro area.

Worth knowing: the official XSD accepted the document the bank refused. Schema-valid and accepted are different questions, and only the first is answerable locally.

Also in this release: the payment form offers the debit account from HTD instead of asking you to retype an IBAN the bank already told us about — a datalist, not a closed list, since AccountInfo is optional in the schema and a catalogue goes stale. An account the bank did not publish is noted on the preview and not refused. And the running version now sits at the foot of the console's drawer, with the commit beside it when the build set one.

No migration. Upgrading from 0.3.0 changes what DbtrAgt contains for a Swiss connection with no debtor BIC, which is the point.

painfree 0.3.0

Choose a tag to compare

@reyemb reyemb released this 02 Sep 13:21

Ask the bank what it accepts, instead of reading its PDF. HAA, HTD and HPD are now fetched, stored and shown. HTD's PartnerInfo/OrderInfo list is the same table an operator otherwise transcribes off a parameter sheet — an admin order type, the BTF beside it, a description, how many signatures the bank wants — and the console draws it next to what the connection is configured to send. "Will this payment be accepted" is answerable without sending one.

The request shape was read off the official H005 schema, not recalled: OrderParams has no minOccurs, so it is mandatory, and StandardOrderParams substitutes into it for an administrative order. The whole exchange is an ordinary three-phase download, driven in tests against a stub bank over a socket with real crypto.

The bank's document is stored verbatim beside the parse, and a document that cannot be parsed is still stored — losing the evidence because the reader was surprised is the worst available response to a bank changing something.

The comparison is shown and never applied: a bank publishing an upload this connection is not configured for does not reconfigure anything.

Breaking: no instant profile ships by default any more. The shipped default was the EPC SEPA convention — the euro scheme — and it applied to every connection unasked. Against a bank with no instant row in its catalogue that cost a signed upload and a round trip on every payment before falling back to normal.

instant is now refused locally, before anything is signed, naming the reason; instant_or_normal goes out as an ordinary transfer first time. On upgrade, a connection that never named an instant profile loses the default one. A deployment actually sending instant configured its bank's real triplet and is unaffected — that configuration is stored and kept. If you relied on the default, set the triplet your bank publishes on the connection's edit page; HTD will now tell you what that is.

Also in this release: a payment can be raised by hand from the console — fill the form, see the exact pain.001 it built with the resolved scheme and BTF, then confirm to send. The idempotency key is minted at preview and carried, so a double-click on confirm replays rather than pays twice. The README carries both payment bodies, and the drawer shows the running version and commit.

Migration 0017_bank_catalogue adds one table. No backfill: nothing in a database knows a bank's catalogue until the bank is asked.

painfree 0.2.3

Choose a tag to compare

@reyemb reyemb released this 01 Sep 09:37

Three findings from a production deployment against an organisation-wide Keycloak realm, all about the gap between what an identity provider sends and what this service should be willing to keep.

A token carrying no roles at all was silent — and it is Keycloak's default. Browser sessions are established from the id_token, and Keycloak's built-in realm-roles mapper ships with "Add to ID token" off. So on a default realm a correctly assigned administrator signs in successfully, holding nothing, and the console is empty with no line to read. auth.no_roles_in_token now warns, quotes the configured claim path back so it can be compared against the mapper, and distinguishes an absent claim from an empty one.

Every provider role was persisted and logged, not just the ones that mean something. One sign-in on that realm carried 34 role names, of which painfree understands two — and all 34 went into the session row and onto the audit row that a deployment deliberately never prunes. A service whose purpose is custody of bank keys should not also become the archive of an organisation's authorization model. The intersection now happens once, at the door: only the names this deployment maps are kept anywhere, and the remainder survives as a count. Privilege is unaffected — an administrator name is one this deployment maps by definition.

That also makes the diagnostic worth reading again. auth.unmapped_roles is info with a count when recognised names travelled alongside others (a shared realm, the ordinary case) and warning only when none of what arrived was ours — which is the case that actually indicates a misconfigured role name.

The recovery page gave host paths with no host. Two relative paths, on a page reached in a browser, in a deployment that is containerised by definition — and the process serving that page is refused the custody secret, so the reader's first instinct fails in a way that looks like the file is missing. The page and the downloadable card now name the anchor (the directory holding compose.yaml, on the host), mark the command as a host shell command, and give the custody boundary as the reason rather than leaving it to be discovered.

production.env.example documents that roles must reach the id_token, and describes the client-role mapper that keeps an organisation's other role names at the provider entirely.

Upgrading: nothing to do. Existing sessions and audit rows are untouched; new ones carry only the names this deployment maps. If you set PAINFREE_OIDC_MEMBER_ROLE, note it now decides what is kept as well as what is not warned about — a member name belongs in it.

painfree 0.2.2

Choose a tag to compare

@reyemb reyemb released this 01 Sep 08:52

The INI letter quoted the wrong fingerprint. A bank telephoned about a letter whose hashes did not match the keys INI and HIA had just delivered. The keys were fine. Two fingerprints exist over the same key — SHA-256 over hex(exponent) + " " + hex(modulus), and SHA-256 over the X.509 certificate in DER — and this engine, which speaks EBICS 3.0 (H005) and nothing else, defaulted to the H004 one. DEFAULT_LETTER_DIGEST is now CERTIFICATE.

The letter itself gains nothing: same three keys, same exponent and modulus, one hash per key, computed over the certificate instead of over the two numbers. letter_digest still selects the other convention for a bank that asks for it.

The letter dated itself from the connection row. connection.updated_at moves on any edit, so a letter already signed and posted would silently re-date itself. The date is now the newest creation time of the three keys the letter quotes.

Two supports. Changing the convention after INI or HIA has been sent now asks for a confirmation — not a refusal, because a bank saying "wrong convention" is exactly when it has to be done, but no longer silent about the paper it invalidates. And the letter names its convention in words ("SHA-256 over the X.509 certificate, in DER form") rather than printing an enum value at a clerk comparing hex strings by eye. All six catalogues.

If you are on 0.2.1 or earlier and have already posted a letter: your keys are unaffected. Reprint the letter and post it again — it will now quote the certificate digest, which is what an EBICS 3.0 bank checks against.

painfree 0.2.1

Choose a tag to compare

@reyemb reyemb released this 31 Aug 15:07

Two fixes found by pointing a deployment at a real bank, and the second is the
one that cost an afternoon.

The header nobody chose

urllib supplies User-Agent: Python-urllib/3.x unless told otherwise, and at
least one Swiss bank's web application firewall blocks that string: HTTP 400 and
an HTML error page, before the EBICS connector sees the request. The same
endpoint answers a request carrying no User-Agent normally.

painfree now sends none. PAINFREE_EBICS_USER_AGENT sets one for a bank that
asks for a particular string; empty and unset both mean no header.

A body that is not a document is refused where it arrives

That HTML page was then handed to the XML parser, so the error an operator saw
was Opening and ending tag mismatch: link line 9 and head — a malformed
document, when the cause was an HTTP header and the document was never the
bank's. Nothing in that message could lead anybody to a firewall.

A response that does not open as XML now fails in the transport, naming the
status, the content type, and quoting the first 200 bytes, so the middlebox's
own words are in the error. A well-formed EBICS error with a 500 still comes
through untouched, because the return code inside it is the point.

Also

  • Local accounts get out of the way where an identity provider is configured:
    the console entry is hidden and creating one is refused, because a password is
    only accepted in basic mode. Accounts that already exist stay listed and
    removable.
  • Every PAINFREE_OIDC_* setting now reaches the container. AUDIENCE and
    SCOPE_CLAIM were declared, used, and passed to nothing, so a deployment
    serving machines from a second client could not name an audience.
  • deploy/production.env.example had the wrong callback: it is
    /auth/callback, not /ui/auth/callback.

Upgrading

Nothing to do beyond pulling 0.2.1. If you are still on 0.1.0, read the
0.2.0 notes first: the state moved from named volumes to bind mounts, and that
migration is not automatic.

painfree 0.2.0

Choose a tag to compare

@reyemb reyemb released this 31 Aug 13:05

Seven findings from the first real deployment, fixed, plus the custody secret
said where it matters.

Upgrading from 0.1.0

Two changes need a decision rather than a restart.

The state moved from named volumes to bind mounts under state/. That is
the fix for down -v deleting the sealed EBICS keys. An existing deployment
does not migrate itself: take a deploy/snapshot.sh before you upgrade, bring
the new stack up, and restore into it.

Generating a connection's first keys is now refused until somebody confirms
a copy of the custody secret exists, on /ui/recovery. Existing connections are
unaffected: their keys are already sealed, and the confirmation would not make
them safer. Take the copy with the new deploy/backup-secrets.sh.

Fixed

  • podman-compose down -v no longer destroys the database, and with it every
    sealed EBICS private key. No compose command can remove a bind mount.
  • The shipped ports work under a rootless engine: loopback and above 1024 by
    default, and the proxy takes its ports from .env, so the site address never
    has to carry one.
  • .localhost needs an /etc/hosts line under glibc, and now says so.
  • The local CA's root is served at /local-ca.crt, unauthenticated, so a
    browser can be taught to trust a .localhost deployment.
  • deploy/snapshot.sh dumps the database from inside the container that can
    read it, instead of a tar that silently skips it.
  • POST /v1/connections registers a bank connection over the JSON API, behind
    the same connections:write the console route checks.

Added

  • /ui/recovery: which custody key this deployment's keys are sealed under,
    where the file is, and a downloadable card. No secret is on that page and none
    can be: the process serving it is refused the custody secret.
  • deploy/backup-secrets.sh: the archive that makes a host recoverable. It
    excludes the database on purpose, because an archive holding both the key and
    the data it opens is a safe with its combination taped to the door.
  • PAINFREE_OIDC_ADMIN_ROLE and PAINFREE_OIDC_MEMBER_ROLE: what your
    directory calls these, comma-separated. The defaults are the names 0.1.0
    accepted, so nothing changes on upgrade, and both resolved values appear in
    the service.starting line.

Status

Early. Not in production, and not suitable for anyone else's money. No real bank
has seen this.