7 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 434a1fd7cc Build on Go 1.27
Build and vet clean on 1.27.1.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 13:54:17 -07:00
zeekayandHanzo Dev 8a5106d19e go 1.26.8
One toolchain across the estate. The tree carried 1.26.0, .1, .3, .4 and .5
at once, so which compiler built a binary depended on which repo it came
from.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 23:59:07 -07:00
zeekayandHanzo Dev 11a44f6010 tenant: a tenant is a brand and an org, and the vault is keyed by both
The tenant key was the bare org, and an org name is unique within an issuer
rather than across issuers. `acme` on lux.id and `acme` on zoolabs.id are two
unrelated financial institutions, and every store keyed them as one — including
the tokenisation vault, where the org WAS the HKDF salt (pkg/token New). Two
brands therefore derived the same keys: one institution's customer names
tokenised to the other's pseudonyms, and its sealed records opened under the
other's tenant. That is a plaintext cross-brand disclosure of retained personal
data, against the purpose limitation retention exists under (Directive (EU)
2015/849 Art. 41(2)) and against HIP-0302. It is fixed now, before any record
exists, because doing it later re-keys every vault.

The tenant is now `<brand>/<org>`, minted in one place (api/tenant.go qualify)
and checked where it crosses into the store, the history column and the salt.
Both Identity implementations produce the same key: the brand comes from the
request's own Host in both, never from a header a client can write.

The auth path is now the caller that refuses rather than defaulting:

  - brand.ForHost is gone, with brand.Default and brand.IssuerFor. While a
    defaulting resolver existed the auth path used it, so hanzo.id
    authenticated callers on every Host nobody claimed — a pod IP, a
    *.svc.cluster.local name, localhost through a port-forward, an absent Host,
    a misrouted vhost. brand.For reports ok, and there is no fallback to reach.
  - `aud` must contain this deployment's IAM clientId (AML_CLIENT_ID, no
    default, refuses to start without it). IAM stamps aud = the app's clientId,
    so with no pin any first-party token from the same issuer was a credential
    here, including one minted for a marketing site. RFC 9068 §4.
  - `tokenType` must be "access-token". An id_token carries the same iss and
    aud and is issued to a browser, not to an API caller.
  - `owner` must be an org the caller is a member of, when the token carries a
    membership set. IAM stamps the APP's org as `owner` (internal/oidc/jwt.go
    Sign), so a shared or org-choice application would make every one of its
    users one tenant. IAM refuses a cross-org login for a dedicated app
    (internal/oidc/login.go), which is what makes the claim correct for the
    shape this supports: one application per institution.
  - a bearer that is not a compact JWS is refused before any keyset is
    resolved, so an unauthenticated caller cannot spend a network round trip.

Keys, in api/jwks.go with the algorithms that accept them, because the two
lists have to agree:

  - one lock per issuer, taken outside the map, so an issuer that does not
    answer holds up only itself. One process-wide mutex held across the HTTP
    fetch meant a single unreachable issuer — selected by a caller's Host —
    serialised every request in the process behind it.
  - a failed fetch is remembered briefly, so an unreachable issuer costs one
    attempt per window instead of one per request.
  - EC (ES256/384/512) and ML-DSA-65 verify, which is what IAM publishes
    besides RSA (internal/oidc/jwks.go signingAlgs). A dropped key type is a
    silent lockout of a whole brand, so a key that cannot be loaded now names
    its kty and alg in the log.

Also here: caseOf returned refuse()'s value as an error, and refuse() returns
nil once it has answered the request — so an unauthenticated POST to a case
route dereferenced a nil case. It reports errNoCase and writes nothing.
Transaction.TenantID is deleted: a second tenant field nothing reads is an
invitation to scope data by a value the caller supplies.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 22:31:29 -07:00
zeekayandHanzo Dev e1103e15cb brand: the host says which brand, and the token has to come from it
One amld serves every brand's surface, so which brand a request belongs to is a
property of the request. pkg/brand is the one place the engine answers that — a
leaf holding the same registry as the fleet's canonical white-label table
(HIP-0111, hanzoai/cloud brand/brand.go): the same brands, issuers and Host
resolution.

The Identity that follows from it: the tenant is the `owner` claim of a bearer
token the request's OWN brand issued. A Lux token on a Zoo host is refused even
where both issuers publish their signing certificates in one JWKS, which is the
difference between white-label tenancy and white-label decoration. Signature
against the issuer's published keys, issuer pinned, expiry required, `kid` bound
to the key it names, and a refusal on every uncertainty — a control that
authenticates when unsure answers with another tenant's records and looks healthy
doing it.

/v1/aml/config reports the brand of the request's Host so a white-label console
renders its own identity. It is the one route here that takes no tenant: it names
the issuer a caller needs in order to get a token at all.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 21:29:00 -07:00
zeekayandHanzo Dev 039fa7e3d6 engine: a rule is admitted only if its evidence exists
The previous evaluator registered a stub for every term the language offered.
Absent a real implementation, sanctions screening answered false, geographic
matching answered true, the local hour answered noon, travel-rule completeness
answered complete, and chain risk answered zero. Six of the terms were later
replaced by store-backed versions; eleven never were, which left thirteen of the
twenty-three shipped rules structurally unable to fire. Measured on the shipped
code: a 50,000 dollar crypto payment at 3 a.m. from a politically exposed
customer in a listed jurisdiction, to a fresh address, naming a designated
person, raised four alerts out of twenty-three.

There are now no stubs. Terms are methods on the evaluation scope, each term is
mapped to the provider that answers it, and a rule naming a term whose provider
is absent is refused when the rule set is installed. The refusal names the term.
A rule set containing one such rule installs nothing, because a partially
installed set is a control surface nobody can describe.

Rules must yield a boolean. The previous evaluator accepted a number and read it
as truthy, so a velocity rule written as a bare sum fired on any activity at all.

Evidence failures at evaluation time — an unknown currency, an absent subject, an
unreachable store — are recorded on the alert and routed to review rather than
cleared, so an outage cannot become an approval.

Windows are fetched once per subject and lookback for the whole rule set rather
than once per rule.

Aggregation is over the transaction's own timestamp, not the row's insertion
time, and history queries are parameterised and scoped to the tenant. The
previous store interpolated the customer identifier into the filter string and
filtered by neither organisation nor transaction time.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:55:48 -07:00
zeekayandHanzo Dev a6d95ca678 base v1.1.0 -> v1.5.8
Clears the checksum mismatch left by a force-moved v1.1.0 tag, which made
go mod why fail with a SECURITY ERROR and blocked dependency analysis.

Drops four prometheus modules from the requirement set; current base is
ZAP-native. No prometheus package remains in the linked build.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 23:03:31 -07:00
zeekayandHanzo Dev 07e69e3ee2 luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence
scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored
alerting. Single binary, embedded SQLite per org, embedded UI.

Surface (all /v1, org-scoped):
  /v1/aml/transactions        ingest and score
  /v1/aml/rules  /rules/test  the rules DSL and its dry-run
  /v1/aml/sanctions/search    list screening
  /v1/aml/cases               investigation workflow
  /v1/aml/health

Build: make build produces one static binary with the UI embedded (embedui tag);
bare Go tooling needs no Node. Third-party license and notice texts are generated
from the module graph the linker actually walks and ship beside every artifact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 22:51:25 -07:00