跳到正文
Hacker News· villson·· 6 天前AI 评分20

OpenClear:面向小型银行的自托管支票与 ACH 欺诈筛查平台

Show HN: OpenClear – Self-hosted check and ACH fraud screening for small banks

AI 导读

前身为 PosPay 的 OpenClear 是一个多租户 positive pay 平台,支持支票与 ACH 欺诈筛查,可扩展至其他支付网络,并具备可插拔 OCR 与 ML 辅助的异常复核反馈闭环。该项目由一位拥有 20 年银行系统与支票处理经验、但非程序员的人使用 Claude Code 构建,支持 SQLite 一键启动与 Docker 部署。

正文 · AI 翻译

译文尚不完整,完整内容请切换到原文。

OpenClear

Try OpenClear in a free sandbox: your own bank in about a minute, with sample checks, ACH payments and exceptions, and an API client. Start a sandbox bank at openclear-sandbox.fly.dev

原名 PosPay。更名后现有安装仍可继续使用:参见 CHANGELOG.md。

Tests

多租户正支付(positive pay)平台(目前支持支票 + ACH,可扩展到其他支付 网络——参见 networks/),具有可插拔的 OCR 和 ML 辅助的异常复核 反馈闭环。

本 README 仅涵盖本地设置和部署。关于架构(数据模型、 网络适配器模式、匹配引擎规则、ML 流水线设计),参见 docs/ARCHITECTURE.md。关于 JSON API(/api/v1/*——端点、认证、权限、 模式),参见 API.md。关于每个版本的变更内容,参见 CHANGELOG.md。关于接入新银行或新客户—— 前提条件和分步设置,也可在应用内引导式清单中查看,位于 /ui/wizard/bank 和 /ui/customers/{id}/wizard——参见 RUNBOOK.md。

目录

作者的话

这是使用 Claude Code 构建的。我不是开发者/程序员。但我有 20 年的银行系统、支付和支票处理经验。这就是我想要的正支付系统。我还加入了一些我在商业正支付系统中从未见过的安全增强功能。我的目标是证明银行软件需要现代化。

截图

仪表盘 Dashboard

异常队列 Exceptions queue

复核异常 Exception review

已签发项目 Issued items

ACH 交易 ACH transactions

ML 模型(管理员) ML models

用户 Users

组织设置 Organization settings

计费 Billing

客户的账单 Billing statement

更多界面(账户、客户、安全组、止付、支票影像批量上传、审计日志、费用表、登录)

Accounts 账户 Customers 客户
Security groups 安全组 Stop payments 止付
Check image bulk upload 支票影像批量上传 Audit log 审计日志
Fee schedule 费用表 Login 登录

截图由预置的演示租户生成,而非手动截取——如果你对 UI 的改动 足以使这些截图过时,请参见 scripts/generate_screenshots.py:

pip install -e ".[dev]"
playwright install chromium
python scripts/generate_screenshots.py

这会针对一个一次性的 SQLite 数据库启动应用(绝不会使用你真实的 .openclear-run/ 或 openclear.db),向一个演示银行填充逼真的账户、已发行项目、 异常、ACH 活动和用户,并驱动一个真实的无头浏览器遍历每个 界面,以(重新)写入 docs/screenshots/ 下的 PNG 文件。

快速开始

一键启动(SQLite,零手动配置):

  • macOS:双击 run_openclear.command(会打开 Terminal.app 并在其中运行)。
  • Windows:双击 run_openclear.bat(会打开一个命令提示符窗口)。
  • Linux:在终端中运行 ./run_openclear.sh —— 大多数文件管理器默认不会在终端中 运行双击的 .sh 文件(因发行版/桌面环境而异,通常需要先在 文件属性中启用“允许作为程序执行”),所以这个文件不打算通过双击运行。

首次运行时,它会创建虚拟环境、安装所有内容、运行迁移、 提示你创建组织 + 管理员登录,然后启动服务器并打开 浏览器。之后的每次重新运行都只是启动服务器 —— 可以安全地运行任意 次数。这三个包装脚本都只是找到 Python 解释器,然后交给同一个 与操作系统无关的 scripts/launcher.py;如果你想跳过包装脚本,可以在任何平台上 直接运行它:

python3 scripts/launcher.py

它创建的所有内容 —— 虚拟环境、SQLite 数据库、上传的支票 图像以及训练好的 ML 模型 —— 都位于项目旁边的 .openclear-run/ 下,绝不会放在 源代码树本身中。要完全重置并让检出目录保持与克隆时完全一致,只需 删除那一个文件夹:

rm -rf .openclear-run

手动设置,如果你更愿意自己控制每一步(并且不介意 .venv、 openclear.db、data/ 和 ml_artifacts/ 直接放在项目根目录中作为 持久化的本地开发状态,而不是放在一个可丢弃的文件夹中):

python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

alembic upgrade head
uvicorn openclear.main:app --reload

Web UI 位于 http://localhost:8000/ui/login。JSON API 文档位于 http://localhost:8000/docs。 Tesseract(brew install tesseract / apt install tesseract-ocr)必须在 PATH 上, 支票图像 OCR 才能工作 —— 它是一个系统二进制文件,无法通过 pip 安装;启动器会在 它缺失时发出警告,但仍会运行。

文档的 PDF 导出(/ui/docs/end-user 和 /ui/docs/admin 上的“Download PDF”链接)需要可选的 pdf 附加组件,以及 WeasyPrint 依赖的 系统库 —— Pango、Cairo 和 GLib。run_openclear.command/scripts/launcher.py 会自动安装 pdf 附加组件;如果是手动设置,请自行安装:

pip install -e ".[pdf]"
brew install pango          # macOS
apt install libpango-1.0-0 libcairo2  # Debian/Ubuntu

在 macOS 上,Homebrew 的 lib 目录不在动态链接器的默认搜索路径中,因此 即使安装了 Pango,import weasyprint 否则也会抛出 OSError (services/doc_pdf_service.py 会自动绕过此问题 —— 无需手动 DYLD_FALLBACK_LIBRARY_PATH)。如果完全没有安装 pdf 附加组件,其他 所有页面仍然可以工作 —— 只有 PDF 下载路由会显示“不可用”消息, 而不是文件。

管理员 PDF 的 ER 图(/ui/docs/admin/data-dictionary)是预渲染的 PNG,而不是 在请求时生成的 —— WeasyPrint 不执行 JavaScript,因此交互页面使用的实时 Mermaid+平移/缩放无法在那里渲染。如果你更改了该页面的图表内容,请参阅 scripts/render_schema_diagrams.py:

pip install -e ".[dev]"
playwright install chromium
python scripts/render_schema_diagrams.py

这会针对一个一次性的 SQLite 数据库启动应用,驱动一个真实的无头 浏览器访问 Data Dictionary 页面,并禁用平移/缩放,以便每个图表以 完整自然尺寸渲染,并(重新)写入 src/openclear/static/generated/schema-diagrams/ 下的 PNG 文件。

运行测试

pytest

.github/workflows/tests.yml 会在每次推送到 main 的 push/PR 时自动运行同一套测试(Python 3.11 和 3.13,并安装了 tesseract-ocr/Pango/Cairo,以便 依赖 OCR 和 PDF 导出的测试真正运行而不是跳过),并会再针对 Postgres 运行一次。

要自己针对 Postgres 运行它,请将 OPENCLEAR_TEST_DATABASE_URL 指向一个空数据库。它会被清空:schema 会用 alembic upgrade head 重建,因此迁移也会被测试。tests/test_postgres/(否则会跳过)随后用非超级用户角色检查行级安全性:

pip install -e ".[dev,postgres]"
docker run -d --name openclear-pg-test -e POSTGRES_USER=openclear -e POSTGRES_PASSWORD=openclear \
  -e POSTGRES_DB=openclear_test -p 127.0.0.1:55432:5432 postgres:16-alpine
OPENCLEAR_TEST_DATABASE_URL=postgresql+psycopg://openclear:openclear@127.0.0.1:55432/openclear_test pytest

要获取性能数据,scripts/perf/seed_volume.py 会以选定的规模向一个一次性数据库填充 banks、checks、exceptions 和 decisions,然后 scripts/perf/measure.py 针对它计时分页、摄取、训练、导出和关键查询(见 SCALE_PLAN.md)。

批量文件上传

Accounts(/ui/accounts/bulk)、issued items(/ui/issued-items/bulk)、presented checks(/ui/paid-items/bulk)和 ACH transactions(/ui/ach/transactions/bulk)除了 JSON API 的 /bulk 端点(这些端点接收预先构建的 JSON 数组,而不是文件)之外,还接受批量上传:

  • 分隔符或 Excel(bulk_import/tabular.py):任何逗号/制表符/分号/竖线文件或 .xlsx/.xls,必须有表头行,列名不区分大小写/空格,顺序任意。分隔符通过统计表头行中的候选字符来检测——刻意不使用 csv.Sniffer/pandas 的通用自动检测,因为在短样本上它可能会选中文本本身中出现频率最高的字符,而不是真正的分隔符。
  • NACHA(bulk_import/nacha.py,仅限 ACH):标准的 94 字符 ACH 文件,带或不带换行符,以及用 9 填充的行等等。提取创建交易所需的批头字段(公司 id/名称、SEC 代码、生效日期)和明细字段(DFI 账号、金额、个人 id、交易代码、追踪号),包括 IAT(国际)条目,并检查每个批次和文件的控制总计(条目加附加项、条目哈希、借记和贷记总计)。总计对不上的文件会被整体拒绝;不支持 ADV(accounting advice)文件。已在其示例文件(tests/fixtures/moov-ach/)上逐条对照 Moov 的开源 ACH 实现进行检查。

每一行/条目都带有自己的账号(而不是为整个文件只选一个账号)——会针对你现有的账号进行解析,因此一个文件可以跨越多个账号。未匹配的账号以及任何其他错误行都会被单独报告,而不会导致文件其余部分失败。

文件在后台导入(services/bulk_import_service.py)。上传会存储文件并检查它能否被解析;无法解析的文件会在表单上被拒绝,且不会导入任何内容。否则,后台任务(见“后台任务”)会导入它,浏览器会转到该上传的页面(/ui/bulk-uploads/<id>),该页面显示进度、自动刷新,然后显示结果:有多少行成功、每个失败的行及其原因,以及每一行结果的 CSV。该任务每次提交 1,000 行(OPENCLEAR_BULK_IMPORT_CHUNK_ROWS),每一行都在自己的保存点中,因此错误行会自行失败,而被中断的任务会从它尚未提交的第一行恢复。上传产生的新异常会向每位审核者发送一条汇总通知,而不是每个异常一条。自动 dropbox 导入(services/dropbox_import_service.py)使用相同的路径。

两个上传表单都有一个“创建缺失账号”复选框:勾选后,文件中任何尚不存在的账号都会被自动创建,而不是让该行失败。分隔符/Excel 文件可以包含一个可选的 account_name 列,用作新账号的名称;NACHA 文件没有这样的字段,因此它创建的账号会被命名为 Account <number>。同一个新账号出现在一个文件的多个行中时只会被创建一次。

每个提交的文件都会被保存并签名(bulk_import/file_storage.py、 bulk_import/signing.py、services/bulk_upload_file_service.py)——已签发项目、ACH (表格和 NACHA)以及用户批量上传(见下文)都会将原始字节保留在 本地磁盘上(OPENCLEAR_BULK_UPLOAD_STORAGE_DIR,默认 ./data/bulk_uploads,由快速启动器整合到 .openclear-run/ 下),外加 一个 SHA-256 指纹和一个使用服务器持有的私钥生成的 ECDSA P-256 签名 (OPENCLEAR_FILE_SIGNING_PRIVATE_KEY_PATH;参见“签名密钥”)。文件被分块复制到磁盘 并在写入时进行哈希,因此大文件上传永远不会被保存在内存中。即使文件 被直接拒绝(格式错误、无数据行),也会被保存——格式错误的 提交仍然是实际发送内容的证据,因此它会在解析运行之前 就被记录,并在错误页面上直接显示指向它的链接。每个结果/错误页面都会链接 到一个详情视图(/ui/bulk-uploads/<id>),该视图在每次访问时都会针对文件的 最新读取重新验证签名——而不是缓存的标志——因此上传后的篡改(即使是 直接编辑磁盘上的文件)也会显示为不匹配,并且可以从那里逐字节下载 原始文件。受与原始上传相同的权限控制 (account:write、issued_item:write、paid_item:write、ach_transaction:write、 check_image:write 或 user:manage)。

批量加载支票图像

/ui/check-images/bulk(受 同时 paid_item:write 和 check_image:write 控制—— 比此应用中的其他所有批量导入都更严格,因为此导入在单一步骤中创建两种不同的 资源类型,而不像上面的单图像上传,后者只 链接到已存在的付费项目)接受两种格式。两者都会为每张支票创建一个新的 paid_item——通过与其他任何 提示项目相同的匹配引擎运行它——并且在同一步骤中附加其图像,而不是要求 付费项目已经存在:真实的图像现金函件就是提示,而不是 后续步骤。

  • ZIP 文件(bulk_import/zip_import.py):一个 zip,其中恰好包含一个清单文件 (CSV/TSV/Excel——列 account_number、check_number、amount、presented_date、 front_image_filename,可选 back_image_filename)以及它 按文件名引用的图像文件(按基本名称匹配,不区分大小写,位于 归档中的任何位置)。支持的图像格式:单页或双页 TIFF、JPG、PNG (bulk_import/images.py)——双页 TIFF 会自动拆分(第 1 页 = 正面,第 2 页 = 背面),除非显式给出 back_image_filename,后者始终优先。
  • X9.37 图像现金信函(bulk_import/x937.py):一种采用标准布局(DSTU X9.37)的 Check 21 现金信函文件,银行、处理机构和联邦储备系统之间以此格式交换:采用长度前缀记录,编码为 EBCDIC 或 ASCII,也支持按行分隔或拼接的记录。图像保存在 Image View Data(Type 52)记录中,保持二进制形式。会校验 Bundle(Type 70)、现金信函(Type 90)和文件(Type 99)的控制总计,若不一致则拒绝整个文件。退回现金信函不会被导入(包含退回记录的文件会被拒绝并给出明确提示)。字段位置已逐字节对照 Moov 的开源 X9 实现及其示例文件(tests/fixtures/moov/)进行双向核对。现金信函通过内存映射读取,每张支票的图像仅在该支票被导入时读取,因此大文件不会全部驻留内存;zip 中的图像同样在每行需要时才读取。账号是 MICR on-us 符号之前的 On-Us 字段(/),不含其间的分隔短横线(若某银行存储的账号仍带短横线,也仍能匹配);支票号是 Auxiliary On-Us 字段(对公支票),若无则为符号之后的 On-Us 部分(个人支票)。各银行对 on-us 数据的布局方式不同,因此上文所述的 ZIP+CSV 格式是显式列的兜底方案。

无论来源格式如何,每一张传入的图像在存储前都会被解码并重新编码为 PNG(bulk_import/images.py)——这正是支票图像详情页上的正面/背面下载链接(/ui/check-images/<id>/front 和 /back)以及下游 OCR 能够无论原始文件以何种格式到达都表现一致的原因。与其他所有批量导入器相同的逐行/逐项事务隔离(一张坏支票不会回滚整批),以及上文所述的相同的已签名原始文件审计追踪(BulkUploadKind.CHECK_IMAGES)。

撤销批量上传

每一次批量上传(已签发项目、ACH 交易、用户、账户、支票图像)都可以从其详情页撤销(/ui/bulk-uploads/<id>,“撤销此次上传”),复用上文所述的相同的已签名文件/审计基础设施。这仅对在该功能发布之后处理的上传可行——bulk_upload_created_record(新增)将 BulkUploadFile 持久地链接到它成功创建的每一行,这是该应用此前任何版本都未追踪的;较早上传的详情页会显示“无法自动撤销任何内容”,而不是该按钮。

撤销是单向的(与所有其他作废/取消/撤销操作自身的单向设计一致——此处不支持“撤销撤销”),并且按资源类型,要么真正逆转所创建的内容,要么仅作记录性标注,取决于该应用对该资源是否有任何可逆的概念:

  • 已签发项目:作废(与手动作废使用相同的 void_issued_item)——若已作废或已支付则跳过,不算错误(作废已支付项目会使其匹配的已支付项目处于不一致状态)。
  • 用户:所创建的成员资格会停用(与手动按钮使用相同的 deactivate_membership)——若已停用则跳过。仅追踪由批量文件直接创建的成员资格;之后通过单独的跨租户确认步骤确认的成员资格属于管理员有意为之的独立操作,不会被自动撤销(它仍可像任何其他成员资格一样被单独停用)。
  • 账户 / ACH 交易:仅记录。 此应用中两者都完全没有停用或撤销的概念(没有 Account.is_active,没有 AchTransaction 状态字段)——撤销这些操作只会标记跟踪记录并记录一条审计条目,而不会引入未被要求的新的账户生命周期或 ACH 撤销行为。
  • 支票影像:最困难的情况,因为批量支票影像行的真实对象是它创建的 paid_item(见上文),而该对象可能已经改变了其他行。撤销它会将匹配的 PaidItem.settlement_status 翻转为新的 REVERSED 值(从重复付款检测中排除,与 RETURNED 相同);如果它曾将关联的已签发项目翻转为 PAID,则该项会恢复为 OUTSTANDING——但仅当此后没有其他操作改变该已签发项目的状态时(否则保持原样并记录说明)。如果它曾在审核队列中生成一个仍为 OPEN/PENDING_APPROVAL 的异常,该异常会被自动撤回(新的 ExceptionStatus.WITHDRAWN——显示在异常队列中,并且推荐/决定路由会以明确的消息拒绝,而不是通用的“已决定”消息);已被人工支付/退回/升级的异常则保持原样并记录说明,绝不静默覆盖。

需要与原始上传相同的权限(issued_item:write/ach_transaction:write/user:manage/account:write),此外,对于支票影像,还需要同时具备 paid_item:write 和 check_image:write——与上传路由自身更严格的双权限门控相匹配,因为撤销一个操作可能触及两种资源类型。

用户、安全组和跨租户访问

访问控制是一组按租户划分的安全组(auth/permissions.py、services/security_group_service.py),而非固定的角色枚举——每个组是一组命名、可编辑的权限键,这些权限键来自一个涵盖应用中所有操作的单一目录(按资源的读/写,加上 exception:recommend/exception:decide、admin:manage、user:manage、security_group:manage)。每个新租户都会预置 4 个默认组,复现旧的固定角色——Admin(所有权限)、Preparer(除决定异常或管理用户/组/管理员外的读/写)、Approver(只读加 exception:decide)、Viewer(只读)——此后可通过 /ui/security-groups 完全编辑。权限在每次请求时从数据库解析(auth/deps.py::decode_and_build_context),而非固化在 JWT 中,因此编辑组的权限——或停用用户——会在下一次请求时立即生效,而不是在令牌过期后。

用户(/ui/users)可以逐个添加,或通过 CSV 批量上传(/ui/users/bulk,列:email、security_group、password),复用与已签发项目/ACH 相同的 bulk_import/ 基础设施。User 是全局登录身份(电子邮件在整个平台范围内唯一,而非按租户),带有零个或多个 TenantMembership 行,每行指向一个租户和该租户下的一个安全组——这正是实现跨租户访问的原因:同一个人、同一密码,可以在多个租户中拥有成员资格(使用不同的安全组)。登录仍然像以前一样需要组织 slug + 电子邮件 + 密码;登录后,导航中的“切换组织”会为你是活跃成员的任何其他租户重新签发令牌,无需重新输入密码或 WebAuthn(见下文)。

通过一个已经属于不同租户中某个身份的电子邮件添加用户时,绝不会静默地将其关联——无论是单个添加表单还是批量 CSV,都会在创建成员关系之前显示一个明确的确认步骤(“授予此现有用户访问此组织的权限?”),并且该确认会在服务器端通过电子邮件重新解析身份,而不是信任客户端提交的任何内容。

WebAuthn 凭据仍然是按租户成员关系注册的,而不是为整个身份注册一次——目前,在两个租户中拥有成员关系的用户需要在每个租户中分别注册一个安全密钥。将其统一为一套身份范围的凭据集是一个合理的后续改进,但有意排除在跨租户成员关系工作范围之外。

查询和导出用户(访问权限重新认证)

GET /api/v1/users(由 user:manage 控制)返回与 /ui/users 相同的数据——每个 TenantMembership 一行(电子邮件、安全组、客户范围或“全行”、活动/停用状态、成员关系创建时间以及 last_login_at)——通过直接复用 user_service.list_tenant_users 构建,因此 API 和网页永远不会出现偏差。在一个租户中拥有多个成员关系(全行加上按客户范围)的用户会正确地按每个成员关系出现一次,与实际授予访问权限的方式一致;这旨在用于访问审查/重新认证流程,该流程需要回答“谁对什么拥有访问权限,以及他们是否实际使用过它。”

User.last_login_at 现在实际上已被填充——它之前作为一列存在,但从未被设置过。它会在登录完成的时刻(仅密码,或 WebAuthn MFA 完成后)打上时间戳,在 Web 和 API 渠道上都是如此;令牌刷新和“切换组织”不算作新登录,也不会触及它。

同一列表可以直接从 /ui/users 导出为 CSV 或 JSON(/ui/users/export.csv / .json,相同的权限,相同的底层数据——没有单独的过滤,它导出的正是页面显示的内容)。

客户:隔离租户自己的业务客户

Tenant 是银行;Customer(/ui/customers,由专用的 customer:manage 权限控制)是该租户内银行自己的一个业务客户——例如“Acme Corp”,一家其账户和支票/ACH 活动由银行处理的的公司。客户是可选的:未指定客户而创建的账户是“内部”账户,仅对租户范围的工作人员可见,与引入此功能之前完全一样,因此没有客户的现有安装的行为与现在完全相同。

一套机制服务两种用例。TenantMembership 获得一个可选的 customer_id:NULL 是今天的确切行为(租户范围内的员工,能看到租户内的一切),而一个真实值则把该成员的安全组限定到仅那一个客户的数据。同一个字段既表达“被限制为服务特定客户的银行员工”,也表达“客户自己的员工登录后只能看到自己公司的数据”——没有单独的门户或权限目录,只是在普通成员身份上加了一个更窄的范围。一个人可以在同一租户中持有多个成员身份(一个租户范围的,加上按客户覆盖的,或者多个客户范围的且完全没有租户范围的成员身份)——“切换组织”(/ui/switch-tenant)兼作“切换客户范围”,列出每个成员身份以及各自被限定到哪个客户(或“全行范围”)。登录仍然只需要 slug + 邮箱 + 密码;如果该租户存在多个成员身份,登录默认选择租户范围的那个(如果存在),否则选择最早创建的客户成员身份,而切换器可以到达任何其他成员身份——针对(罕见的)多成员身份情况在登录时提供选择器是一个合理的快速跟进项,此处有意不在范围内。

隔离是反规范化存储的,而不仅仅是连接。customer_id 被标记到 account 以及挂在账户下的每一张表上——issued_item、stop_payment、paid_item、ach_authorization_rule、ach_transaction——就像 tenant_id 已经做的那样,始终在创建时由服务端从所引用的账户派生,绝不信任来自请求的值。repositories/base.py::CustomerScopedRepository 是执行点:当会话的 customer_id 被设置时,对这六张表的每次读取都会额外按它过滤;当它是 None(租户范围)时,行为与普通的租户范围限定完全一致。客户范围的调用者直接引用另一个客户的 account_id——而不仅仅是通过过滤后的下拉列表——会得到一个干净的 404,与跨租户隔离相同的防枚举姿态,因为每个创建子记录的服务都通过这同一个仓库来解析父账户,而不是原始查找。

没有新的“什么”权限——只有“谁的”。安全组的权限(Preparer、Approver 等)无论成员身份是租户范围还是客户范围,含义都相同;范围限定只是缩小了它们适用于谁的数据。一个硬编码的例外:每当成员身份是客户范围时,user:manage、security_group:manage、tenant:manage、customer:manage、admin:manage 和 audit_log:read 都会无条件地从解析后的权限集中剥离(auth/permissions.py::CUSTOMER_SCOPE_MASKED_PERMISSIONS,在 auth/deps.py::decode_and_build_context 中强制执行)——无论底层安全组名义上包含什么,因此即使客户范围的成员身份使用完整的“Admin”组,也永远无法触及像 /ui/users 或 /ui/audit-log 这样的租户管理界面。

批量加载扩展现有的 CSV 基础设施,而不是新增一套:账户(/ui/accounts/bulk,列 account_number、name,可选 customer_number,可选 ach_debit_block_mode)和用户(/ui/users/bulk,现有列加上一个可选的 customer_number)都像已发放项目/ACH 批量上传已经解析账号那样,把人类可读的客户编号解析到租户的内部客户记录。客户范围的上传者只能在自己的范围内创建行——一个指定了不同客户的 customer_number 列会被拒绝,而不是被静默重新分配。

尚未完成,记录为 v1 可接受的限制:六张客户范围表上的 Postgres RLS 策略仅强制执行 tenant_id,而不强制执行 customer_id——上方的仓库层是主要的、经过测试的执行点,与今天租户隔离自身的 RLS 姿态相同。

按租户的品牌定制

每个租户都可以从/ui/settings自定义 logo、favicon、强调色和显示名称(由专用的tenant:manage权限控制,不并入admin:manage,因此可以将某个安全组仅限定于品牌定制)。上传的图片存储在本地磁盘的OPENCLEAR_TENANT_ASSET_STORAGE_DIR下(默认为./data/tenant_assets,由快速启动器统一归并到.openclear-run/下,与 check-image 存储相同)——数据库只存储路径和内容类型,从不存储二进制数据块。强调色复用了单一的--accent CSS 自定义属性app.css,该属性已经贯穿每个按钮/链接/导航高亮,通过一小段内联<style>覆盖来应用,因此无需重写 CSS。

品牌信息会显示在整个登录后的应用外壳中(导航 logo/名称、页面标题、浏览器标签页 favicon、强调色)以及登录页面本身——通过 URL 中的租户 slug(/ui/login/<slug>)实现,而不是 Host 头/子域名路由。基于 slug 的登录链接不需要 DNS 或反向代理基础设施,并且在本地和生产环境中表现完全一致,不像每个租户一个子域名的方式——鉴于本项目本地优先、一键启动器的设计,它有意不朝那个方向构建。未知或未激活的 slug 会回退到普通的通用登录表单,而不是报错。Logo/favicon 字节由两个以 slug 为键的公开(无需认证)路由提供,即/ui/branding/<slug>/logo和/ui/branding/<slug>/favicon——公司 logo 并不敏感,而且登录页面需要在任何会话存在之前就显示它,因此预认证登录页面和已认证的应用外壳访问的是完全相同的服务路由。

不可变的操作日志

每一个改变状态的操作——无论通过Web UI 还是 JSON API——都会记录到每个租户的防篡改操作日志中(/ui/audit-log,由专用的audit_log:read权限控制,该权限默认仅限 Admin,不同于其他所有*:read权限,因为“谁做了什么”比任何单个资源自身的数据都更敏感)。涵盖已发行项目、止付、已支付项目、支票影像、ACH 授权/交易、异常/决策、账户、用户、安全组和租户设置中的创建/作废/取消/撤销/上传/推荐/决策——对于批量上传,也是每个成功创建的行一条记录,而不是整个文件一条记录(文件本身已有自己的签名审计记录——见上文“批量文件上传”)。

条目构成一条哈希链,而不是独立的逐行签名:每个条目的entry_hash是对其自身字段加上前一个条目的哈希所做的 ECDSA P-256 签名(使用专用密钥对audit_log_signing_private_key_path/audit_log_signing_public_key_path生成,与本应用中所有其他签名密钥不同)。这里需要的是链——而不是独立签名——因为操作日志面临的真正威胁是删除或重排序,而不仅仅是编辑某一行:独立的逐行签名无法检测某一行被直接删除,但断开的链环可以。services/audit_log_service.py::verify_chain(在审计日志页面上以“Verify chain”呈现)会遍历某个租户的每个条目,并从头重新计算每个哈希——证明自写入以来没有任何内容被编辑、删除或重排序,而不是一个缓存标志。这是防篡改证据,而不是操作系统级的防篡改阻止(没有撤销任何数据库触发器/授权)——与批量上传文件签名采取相同的姿态。

每个银行的条目都有编号(sequence,每个银行唯一),链就是按这个顺序验证的。同一时刻对同一银行的两个写入请求不能都链接到同一个条目上:在 Postgres 上它们会在每个银行的锁上排队,在任何数据库上,唯一编号都会让失败的一方重试,而不是让链分叉。验证按批次读取日志,所以无论日志多长,内存占用都保持平稳。

日志调用位于路由处理程序中(web/routers/*.py 和 api/v1/*.py 都是),紧跟在变更服务调用成功之后、该请求的 db.commit() 之前——这样审计条目和业务变更会原子性地一起提交,而且每个路由都已经拥有它所需的操作者/租户/渠道,无需任何共享服务函数承担新的必需参数。

合规与控制测试

面向银行的合规人员、审计师和检查员(COMPLIANCE_PLAN.md;docs/compliance 将这些映射到 FFIEC、Nacha、SR 11-7 和第三方风险指南):

  • 职责分离(设置 → 职责分离)。提交条目的人不能决定它们,管理用户的人不能定义组授予什么权限。这会在组和每个条目上进行检查:提交条目的人不能决定它。

    • 在 warn 模式(每个银行的初始模式)下,违规会被允许并记录。
    • 在 enforce 模式下,它会以 ERR_SOD_VIOLATION 被拒绝。
    • 在 enforce 模式下,开启或关闭匹配规则以及激活模型都需要等待第二位管理员(管理员 → 变更)。
  • 模型风险(管理员 → 模型风险):

    • replay 用今天的规则运行过去的 X9.37 或 NACHA 文件,并将结果与实际发生的情况进行比较;
    • anomaly capture 检查合成欺诈是否被捕获。

    两者都是试运行,会回滚。用 OPENCLEAR_ENABLE_CONTROL_TEST_SCHEDULER=true 开启每日异常捕获。

  • 控制(管理员 → 控制,GET /api/v1/controls/status,openclear controls):每个控制的实时 PASS / WARN / FAIL 及其证据。在 OPENCLEAR_STORAGE_ENCRYPTION_ATTESTATION 中记录主机如何加密磁盘和数据库。

  • 证据导出(GET /api/v1/audit/export,openclear audit export):针对某个时间段的签名 JSON 或 PDF 包。

  • 数据库中的机密使用 AES-256-GCM。要轮换密钥,请将旧值移到 OPENCLEAR_SSO_ENCRYPTION_KEY_PREVIOUS,设置新的 OPENCLEAR_SSO_ENCRYPTION_KEY 并重启:存储的值会在启动时重新加密。

计费

OpenClear 生成银行向客户收取的 positive pay 费用(组织菜单中的计费,API.md 中的 /api/v1/billing)。银行从指标目录构建费率表:按客户、按用户、按账户、按签发的条目或文件、按评估的条目、按例外、按退回的条目、按止付、按图像、按通知等等,每项都有单价、可选的阶梯层级、月度最低费用及其自己的 AFP 服务代码。“添加初始费用”会以零价格填入常见的 positive pay 服务。

每个客户被标记为 账户分析(费用进入银行的分析系统,用收益信用抵扣)、硬性收费(从其计费账户扣款)或 豁免,并有全银行范围的默认值。银行还可以为每个客户设置折扣、协商价格或对任何费用的豁免。层级和最低费用适用于该客户的所有账户。

任何期间的报表都会实时计算。定稿某个月会将其冻结;再次定稿会保存一个替换版本。从定稿的月份起,银行可下载账单报告(CSV)、分析数据源(CSV)、用于核心系统的硬性扣费过账文件(CSV),以及一条 ISO 20022 camt.086 银行服务账单消息——这是 X12 822 账户分析文件的标准继任格式。拥有 billing:read 的客户用户可以看到该客户自己的对账单和 PDF。OpenClear 本身不计算收益信用额,也不自行过账扣款。

平台运营方也可以向每家银行收费,依据其自己的费率表(包括每银行月度费用),通过 /api/v1/platform/billing 使用一个 billing 范围的平台密钥进行。BILLING_PLAN.md 中有相关研究和决策。

身份验证

银行的系统(其核心系统、其网上银行提供商)不使用个人密码:管理员创建一个API 客户端(管理 → API 客户端),带有安全组,并可选地关联一个客户,然后系统在 POST /api/v1/oauth/token 处用其客户端 ID 和密钥换取一个短期令牌(OAuth 2.0 客户端凭证;见 API.md)。它通过一个无人能登录的服务身份行事,权限恰好等于其所属安全组的权限;撤销它会立即生效。

个人使用用户名 + 密码(bcrypt 哈希)登录,签发 JWT(api/v1/auth.py),并可选使用 FIDO2/WebAuthn 第二因素(api/v1/webauthn.py、auth/webauthn_service.py):

  • POST /auth/webauthn/register/options + /register/verify — 注册安全密钥(使用普通访问令牌进行身份验证)
  • GET /auth/webauthn/credentials、DELETE /auth/webauthn/credentials/{id} — 管理已注册的密钥
  • 一旦用户拥有至少一个已注册密钥,POST /auth/login 在密码检查后不再返回真实令牌——而是返回 mfa_required: true 和一个短期 mfa_token(5 分钟,不携带任何权限)。该令牌用于验证 POST /auth/webauthn/login/options + /login/verify,一旦断言验证通过,它们会签发真实的访问/刷新令牌对。
  • 没有注册密钥的用户仍像以前一样登录(mfa_required: false);这是增量式的,对现有账户不是破坏性变更。

WebAuthn 需要真实浏览器来驱动 navigator.credentials.create()/get()——没有仅后端的方式能针对真实认证器测试完整流程。测试套件(tests/test_auth/webauthn_helpers.py)改为手工构造密码学上有效的注册/认证响应(COSE 编码的 EC 密钥、CBOR 证明/认证器数据、DER ECDSA 签名),以在没有浏览器的情况下演练真实的服务器端验证路径。webauthn_rp_id/webauthn_origin 默认值为 localhost / http://localhost:8000——部署前请将两者设置为你的真实域名,否则每个已注册的凭证都会因源不匹配而验证失败。

单点登录连接可以为整个银行或某一个企业客户设置。

  • 协议:OIDC(Okta、Entra ID、Ping、Google、Keycloak)或 SAML 2.0(auth/saml_service.py)。
  • 组映射:每次登录时,个人的组会映射到安全组。
  • SCIM 2.0 位于 /scim/v2,从银行目录中配置和停用人员。
  • 在身份提供商处登出:它会结束该人员的 OpenClear 会话,通过 OIDC 反向通道登出或 SAML 单点登出实现。
  • 在身份提供商处发起登录:例如,门户磁贴。使用 SAML 时,每个连接需选择启用。
  • 网上银行登录配置文件:银行范围连接上的一个客户声明会指定每个人的客户。有了它,网上银行平台的服务器可以将某人的 ID 令牌换取一个代表其行事的令牌(RFC 8693;见 API.md)。

SSO (OIDC) 颁发者地址必须是公开的 https:// URL。OpenClear 拒绝保存 http://、localhost 或私有/内部 IP 地址的颁发者,并且每次联系身份提供者(发现、签名密钥、令牌交换)时,它都会重新检查主机名解析到的地址,不会连接到非公开地址(auth/outbound_http.py)。否则,任何能够编辑 SSO 连接的人都可以让服务器向自己的网络发送请求。要针对本地身份提供者(例如运行在 http://localhost:8080 上的 Keycloak)进行测试,请设置 OPENCLEAR_OIDC_ALLOW_PRIVATE_HOSTS=true。这仅供本地使用,生产部署在开启它时会拒绝启动。服务器也不会为这些请求使用代理环境变量,因此它需要直接向身份提供者发起出站 HTTPS 连接。

关于完整的 /api/v1/* 端点集合(已签发项目、停止付款、已付款项目、支票图像、ACH、异常/决策、管理员、用户)、每个端点所需的权限以及请求/响应模式,请参阅 API.md。

签名密钥

有四类内容会进行加密签名:JWT(登录会话)、批量上传文件(防篡改)、不可变审计日志(其哈希链——见下文“不可变操作日志”)以及已签署的未授权借记书面声明(WSUD)证明。每一类都使用自己的 ECDSA P-256(ES256)密钥对,而不是共享的秘密字符串,因此泄露的密钥无法同时用于伪造其他密钥;而且——与可猜测的字符串不同——真正的密钥对不会意外地作为可用的默认值发布。

对于本地开发和测试套件,dev_keys/ 是一组已检入的、故意公开的密钥对(见 dev_keys/README.md)——无需任何设置。切勿将其用于真实部署。在部署前生成你自己的密钥:

python scripts/generate_keys.py --output-dir keys

这会打印出需要设置的八个 OPENCLEAR_*_PRIVATE_KEY_PATH/OPENCLEAR_*_PUBLIC_KEY_PATH 环境变量。更倾向于使用 openssl?四对密钥(jwt、file_signing、audit_log_signing、wsud_signing)各自的等效做法是:

openssl ecparam -genkey -name prime256v1 -noout -out keys/<name>_private.pem
openssl ec -in keys/<name>_private.pem -pubout -out keys/<name>_public.pem

然后设置 OPENCLEAR_ENVIRONMENT=production——这才是真正开启检查的开关(config.py::assert_production_safe,在应用启动时运行):生产部署如果仍指向 dev_keys/,或仍使用默认的 OPENCLEAR_SSO_ENCRYPTION_KEY(一个单独的、普通的随机秘密——它用于加密存储的 SSO 客户端密钥,而不是签名任何内容,因此它保持为字符串,而不是密钥对),就会拒绝启动。本地开发(scripts/launcher.py)和测试套件都显式设置了 OPENCLEAR_ENVIRONMENT=development,因此两者都不受此检查影响。

之后轮换密钥对是一次性的预期成本,而不是 bug:轮换 JWT 密钥会使所有活动会话登出;轮换文件签名或审计日志密钥意味着用旧密钥签名的任何内容都将无法再通过验证(对于尚无真实历史记录的预发布系统来说没问题——对于运行中的系统,其密钥轮换策略是另一个有意超出本次初始加固范围的设计问题)。

assert_production_safe 在生产环境中还会因另外两个仍处于检入默认值的条件而拒绝启动,这些条件与签名密钥无关:

  • OPENCLEAR_OCR_PROVIDER 被设置为桩实现。只有 tesseract(默认值)是能工作的 OCR 提供者——textract 和 azure_document_intelligence 都作为真实、可安装的附加组件存在(pip install openclear[textract]/openclear[azure-di]),但它们的 .extract() 只是一个裸的 NotImplementedError(关于接通真实云调用需要什么,请参阅 ocr/textract_provider.py / ocr/azure_di_provider.py 各自的文档字符串)。如果没有此检查,在生产环境中选择其中任何一个,只会在有人实际上传支票图像的那一刻失败,而不是在启动时失败。
  • WSUD 电子签名文本仍处于占位符默认状态。 OPENCLEAR_WSUD_CONSENT_ DISCLOSURE_TEXT/OPENCLEAR_WSUD_ATTESTATION_TEXT 默认使用占位符法律措辞,仅实现联邦 E-SIGN 法案要求的结构性要素——未经律师审阅。在依赖此内容进行真实的未授权借记书面声明(Written Statement of Unauthorized Debit)认证之前,请让您自己的法律顾问审阅并通过这两个环境变量提供真实文本。更改此文本不会影响任何已签署的声明——每份声明都会精确快照当时展示并签署的内容。

反向代理 / WAF 部署

速率限制。每个请求都受到速率限制(web/rate_limit.py):

  • 已登录的个人或 API 客户端以自身身份:rate_limit_per_principal_per_minute,默认 600;
  • 其他任何人按 IP 地址:rate_limit_per_minute,默认 120;
  • 少数路由有更严格的限制(markdown 预览、OAuth 令牌端点)。

计数保存在每个进程的内存中。若有多个进程,请通过 Redis 共享(OPENCLEAR_RATE_LIMIT_BACKEND=redis;参见“运行多个实例”)。

调用方的地址。速率限制、操作日志、安全事件、JSON 日志以及 WSUD 签署者的 IP 都使用同一个地址,每个请求计算一次(web/request_context.py)。默认情况下它是 TCP 连接自身的地址,客户端无法伪造。在代理或边缘网络之后,每个连接都来自代理,因此需要告诉 OpenClear 如何找到调用方:

在 OpenClear 前面 设置
无 保留默认值。
追加到 X-Forwarded-For 的反向代理或负载均衡器(nginx、AWS ALB、Fly) OPENCLEAR_TRUSTED_PROXY_COUNT:代理跳数,通常为 1。OpenClear 从标头的右侧取相应数量的条目(即您的代理添加的那些),绝不采用更左侧由客户端提供的值。
拥有自己的客户端 IP 标头的边缘网络(Cloudflare CF-Connecting-IP、Akamai True-Client-IP) OPENCLEAR_CLIENT_IP_HEADER(标头)和 OPENCLEAR_TRUSTED_PROXY_CIDRS(边缘的地址范围,以逗号分隔)。仅当连接来自这些范围时才信任该标头。

锁定源站。确保只有边缘可以访问 OpenClear,例如:

  • Cloudflare Tunnel;
  • 来自边缘的双向 TLS;
  • 仅允许边缘范围的防火墙。

然后,任选其一:

  • 在 OPENCLEAR_EDGE_ATTESTATION 中记录其实现方式;或
  • 让边缘添加一个秘密标头:OPENCLEAR_EDGE_SECRET,轮换期间使用 OPENCLEAR_EDGE_SECRET_PREVIOUS,以及 OPENCLEAR_EDGE_SECRET_HEADER,默认 X-OpenClear-Edge-Secret。不带该标头的请求会被拒绝,/health 除外。

当边缘标头到达但 OpenClear 未为其配置时,管理 → 控制 → “边缘网络设置”会失败,并在没有锁定的情况下发出警告。

请求 ID。每个响应都带有 X-Request-ID:受信任边缘自己的 ID(CF-Ray、X-Akamai-Request-ID)或一个新生成的 ID。它也会出现在日志、错误页面、操作日志和安全事件中,因此 WAF 拦截或错误可以与其背后的请求对应起来。

Keep-alive 与缓存。

  • Docker 镜像将空闲连接保持 OPENCLEAR_KEEPALIVE_SECONDS(默认 75)。请将其提高到负载均衡器或边缘的空闲超时之上,否则用户会看到零星的 502。
  • 带版本号的静态文件(?v=)以一年期不可变缓存发送。
  • 页面和 API 响应从不缓存(no-store)。

长时间请求。边缘会在源站缓慢约 100 秒(Cloudflare)或 120 秒(Akamai)后放弃。因此以下操作作为后台任务运行,并提供轮询运行状态和下载:

  • 对超过 OPENCLEAR_LONG_REQUEST_SYNC_BYTES(1 MB)的文件进行重放;
  • 超过 OPENCLEAR_LONG_REQUEST_SYNC_DAYS(32)的证据导出;
  • 验证操作日志。

错误页面。网页上的每个错误都会显示 OpenClear 自己的页面,配有贴合该错误的提示信息、返回方式以及可供引用的支持编号:404、405、表单过期(403)、地址损坏(422)、文件超出大小限制(413)、请求过多(429,附带 Retry-After)。API 对所有这些错误都返回 JSON。当无法访问数据库时,OpenClear 返回 503 并附带 Retry-After,而 /health 也返回 503,这样负载均衡器就会停止向它发送流量。

当 OpenClear 完全无法响应时(重启中、过载),它前面的组件会显示自己的页面。src/openclear/static/errors/ 自带独立的 502、503 和 504 页面可供展示:不含脚本或其他文件,因此在任何环境下都能正常工作。它们也可通过 /static/errors/503.html 等地址访问。

  • Cloudflare:自定义错误页面。
  • Akamai:故障转移页面。
  • nginx:error_page 502 503 504。

WAF_PLAN.md 中包含了在边缘运行的其余计划。

Docker

两个镜像,Dockerfile(生产)和 Dockerfile.demo(公开演示)——演示镜像叠加在生产镜像之上,而不是复制其构建过程,因此安装依赖的地方只有一个:

docker build -t openclear:latest .
docker build -t pospay-demo:latest -f Dockerfile.demo .   # only if you want the demo image too

两者都内置了 OPENCLEAR_ENVIRONMENT=production——包括演示镜像,因为演示租户承载真实的公开流量,并运行与真实租户完全相同的 OCR/ML/存储代码路径(参见上文“反向代理 / WAF 部署”以及 services/demo_tenant_service.py 自身的模块文档字符串)。这意味着两者都需要上文 签名密钥 中描述的所有内容,在部署时提供,绝不烘焙进镜像:

  • 四对签名密钥(python scripts/generate_keys.py --output-dir keys,将 keys/ 挂载到容器中,例如挂载到 /secrets/keys,并将全部八个 OPENCLEAR_*_KEY_PATH 环境变量设置为指向该位置)
  • 一个随机的 OPENCLEAR_SSO_ENCRYPTION_KEY
  • 真实的、经法律顾问审核的 OPENCLEAR_WSUD_CONSENT_DISCLOSURE_TEXT / OPENCLEAR_WSUD_ATTESTATION_TEXT
  • 将 OPENCLEAR_WEBAUTHN_RP_ID / OPENCLEAR_WEBAUTHN_ORIGIN 设置为你的真实域名
  • 仅针对演示镜像:OPENCLEAR_DEMO_TENANT_ENABLED=true(已由 Dockerfile.demo 设置)和 OPENCLEAR_DEMO_TENANT_PASSWORD(一个真实的密钥——请自行设置,它没有默认值)

缺少前四项中任何一项的容器会完全拒绝启动(assert_production_safe),而不是静默地使用本仓库自带的公开 dev_keys/ 来提供服务。

该镜像声明了一个卷 /data——应用写入磁盘的所有内容(默认的 SQLite 数据库、支票图像、批量上传、ML 模型产物、租户品牌资源、数据导出)都存放在其中,因此请在那里挂载一个真实的卷,否则每次重置/重启都会丢失所有内容:

docker run -d \
  -p 8000:8000 \
  -v pospay_data:/data \
  -v /path/to/your/keys:/secrets/keys:ro \
  -e OPENCLEAR_JWT_PRIVATE_KEY_PATH=/secrets/keys/jwt_private.pem \
  # ...the other seven OPENCLEAR_*_KEY_PATH vars, same pattern...
  -e OPENCLEAR_SSO_ENCRYPTION_KEY=... \
  -e OPENCLEAR_WSUD_CONSENT_DISCLOSURE_TEXT=... \
  -e OPENCLEAR_WSUD_ATTESTATION_TEXT=... \
  -e OPENCLEAR_WEBAUTHN_RP_ID=your-domain.example.com \
  -e OPENCLEAR_WEBAUTHN_ORIGIN=https://your-domain.example.com \
  openclear:latest

像任何其他部署一样,将 OPENCLEAR_DATABASE_URL 指向 Postgres 而不是默认的 SQLite(参见下文“Postgres”)——先用 --build-arg EXTRAS=.[postgres,pdf] 重新构建,以便驱动程序真正安装好。

创建第一个租户并不是此应用有意通过 HTTP 暴露的路由(参见上文“用户、安全组和跨租户访问”)——对于生产镜像,这是容器启动后的一次性手动步骤:

docker exec -it <container> python -c "
from openclear.db.session import get_session_factory
from openclear.services.provisioning_service import create_tenant_with_admin
session = get_session_factory()()
create_tenant_with_admin(session, tenant_name='Your Bank', tenant_slug='your-bank', admin_email='admin@example.com', admin_password='...')
session.commit()
"

演示镜像不需要这样的步骤——只要 demo_tenant_enabled=true,main.py 的启动过程就会自动播种演示租户(services/demo_tenant_service.py::ensure_demo_tenant)。

运行单个容器,除非你使用 Postgres 并已阅读下文“运行多个实例”:速率限制器是按进程的,而在 SQLite(镜像的默认值)上,每个容器还会运行每个计划任务。

仓库根目录下的 docker-compose.yml 是一个独立的、范围更窄的东西——一个本地便利工具,用于针对 Postgres 而不是 SQLite 进行测试(以开发模式运行,使用已检入的 dev_keys/,零配置),而不是生产部署描述符。不要将它用作真实部署的模板;请改用上面的 docker run 示例。

演示租户

一个持久、功能完备的销售演示组织(services/demo_tenant_service.py) ——真实账户、已开具/已支付项目、异常、ACH 活动、用户,以及其自己训练的 按客户区分的 ML 模型,可以安全地交给潜在客户或放到公开网络上,因为它会自行重置。 这并非 Docker 专属——下面的设置也适用于普通的一键启动器,只需在运行前将它们设为环境变量:

  • OPENCLEAR_DEMO_TENANT_ENABLED=true —— 使应用启动时在演示租户尚不存在时进行初始化(main.py 的生命周期,在之后每次重启时幂等)。
  • OPENCLEAR_DEMO_TENANT_PASSWORD=... —— 上述项所必需;没有默认值,因为对于如此公开的东西,没有安全的硬编码密码。它有意设计为可共享,而非保密——这正是演示租户的全部意义。
  • OPENCLEAR_DEMO_TENANT_SESSION_MINUTES(默认 60)—— 演示在重置前可以空闲多长时间。
  • OPENCLEAR_DEMO_TENANT_RESET_INTERVAL_MINUTES (default 60, 0 to turn off) — the demo also resets on this fixed schedule, since a demo that visitors keep using is never idle.

Resets happen three ways: on the fixed schedule above (anyone signed in at that moment is sent back to the sign-in page); automatically, the moment anyone next tries to log into the demo tenant after it's sat idle past the session window (before credentials are even checked, so a prospect never lands mid-reset); or manually, via a "Reset now" button an admin:manage user sees on /ui/admin — useful right before a scheduled demo rather than waiting out the idle window. Either way, a reset wipes every DB row belonging to the demo tenant and purges everything it wrote to disk (check images, bulk uploads, ML model artifacts, branding assets) before reseeding from scratch — real content (including whatever an OCR run extracted from an uploaded check image) never outlives one idle window. Scoped tightly to whichever tenant is actually flagged is_demo in the database — looked up fresh on every reset, never caller-supplied, so this can't be pointed at a real tenant. A reset also puts the organization's own settings (banner and login messages, colors, dual control, password rules) back to a new demo's values. More on this from the demo tenant's own perspective in the in-app Admin Documentation once you have one running (/ui/docs/admin).

What's locked in the demo: everyone shares the same published credentials, so actions that would let one visitor lock out or disrupt the others are refused (web and API alike). These are SSO and security-group changes; editing or granting other users' access; the shared account's password, security keys, and "sign out other devices"; branding and session timeouts; data exports; and ML retraining/activation. The full list lives in one place, web/demo_guard.py. Everything else, including the whole positive-pay workflow, works normally.

Sharing a link: /ui/login/{tenant_slug} is a tenant-branded login page (any tenant, not demo-specific) — pre-fills the slug and shows that tenant's own name/accent color, so https://your-demo-host/ui/login/your-demo-slug is a cleaner link to hand someone than the generic /ui/login form.

Deploying one publicly: see Docker above for Dockerfile.demo — it's the production image with OPENCLEAR_DEMO_TENANT_ENABLED=true layered on, needing everything a real deployment needs (real signing keys, OPENCLEAR_SSO_ENCRYPTION_KEY, WSUD text — the demo tenant serves real public traffic and runs the exact same code path a real tenant would, so it gets no shortcuts). fly.toml at the repo root is a working, minimal-cost example for Fly.io specifically — one machine on the smallest VM size this app runs reliably on, a persistent volume for /data, and OPENCLEAR_TRUSTED_PROXY_COUNT=1 already set for Fly's own edge proxy (see "Reverse proxy / WAF deployment" above). The demo runs on SQLite, so whatever host you use, keep it to one instance (see "Running more than one instance").

Postgres

pip install -e ".[dev,postgres]"
docker compose up -d postgres
OPENCLEAR_DATABASE_URL=postgresql+psycopg://openclear:openclear@localhost:5432/openclear alembic upgrade head

Or bring up the whole stack (app + Postgres) with docker compose up --build.

Row-Level Security: on Postgres, migrations enable RLS (FORCE ROW LEVEL SECURITY) on the single-tenant operational tables (account, issued_item, stop_payment, check_image, paid_item, ach_authorization_rule, ach_transaction, security_group, tenant_membership, bulk_upload_file, audit_log_entry, ach_return_reason, wsud_statement, wsud_statement_transaction) as defense-in-depth alongside the primary tenant-isolation mechanism (the repository-layer filter in repositories/base.py, which is what's actually under test in tests/test_api/test_cross_tenant_isolation.py). exception_item/decision (the shared ML model trains across every organization that chose it, see ml/train.py) and user (a global login identity with no single-tenant row-ownership story — see "Users, security groups, and cross-tenant access" above) are deliberately excluded.

Connect the app as a restricted role so RLS is actually enforced. A superuser, or a role with BYPASSRLS, skips every policy, which leaves only the repository filter (as on SQLite and SQL Server). Run migrations as the owner, and the app as a separate role:

CREATE ROLE openclear_app LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
GRANT USAGE ON SCHEMA public TO openclear_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO openclear_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO openclear_app;
-- tables added by later migrations need the same grants (or ALTER DEFAULT PRIVILEGES)

How it works: each request, scheduled job and platform-operator call runs its database work bound to one bank (db/tenancy.py), re-applied at the start of every transaction. Switching organization can also see the signed-in user's own memberships in other banks. The full test suite runs against Postgres in CI, and tests/test_postgres/ exercises the web UI, the API, switching organization, the disposition sweep, the dropbox import, usage metrics and creating a bank as a restricted role, with other banks' data present.

MSSQL

Requires the Microsoft ODBC Driver (17 or 18) installed at the OS level — not available via pip alone (see Microsoft's docs for your platform). Also requires a running SQL Server instance; a Linux container (mcr.microsoft.com/mssql/server) is the easiest way to get one for local dev.

pip install -e ".[dev,mssql]"
OPENCLEAR_DATABASE_URL="mssql+pyodbc://openclear:<password>@localhost:1433/openclear?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes" alembic upgrade head

Known friction points, not yet exercised against a live instance in this build:

  • UNIQUEIDENTIFIER type mapping for UUID primary/foreign keys — SQLAlchemy's generic Uuid type should handle this, but hasn't been verified against real MSSQL here.
  • The Postgres RLS migration is a no-op on MSSQL (dialect-gated) — MSSQL deployments rely solely on the repository-layer filter for tenant isolation, same as SQLite.
  • Alembic's autogenerate has known rough edges on MSSQL around identity columns and server-side defaults; review generated migrations before applying against MSSQL.

Running more than one instance

By default, run one app process. scripts/launcher.py, the Docker image and fly.toml all do. Running several processes or containers against one database (uvicorn --workers N, several replicas behind a load balancer) is only supported on Postgres, and two things change:

  • Scheduled jobs (ML retrain, dropbox import, notification sending, expired-disposition sweep, demo reset) run on whichever instances enable their OPENCLEAR_* flags. On Postgres, each run first takes a per-job advisory lock (workers/leader_lock.py), so one instance runs each tick and the rest skip it. If an instance dies mid-job, Postgres releases its lock with the connection. On SQLite and SQL Server there is no such lock: every instance runs every job, so those deployments must stay a single process. (You can also enable the scheduler flags on just one instance and leave them off on the rest.)

  • Rate limits (web/rate_limit.py, see "Reverse proxy / WAF deployment" above) are counted per process by default, so with N processes a client can make up to N times the configured limit. To share one count, run Redis and set OPENCLEAR_RATE_LIMIT_BACKEND=redis and OPENCLEAR_RATE_LIMIT_REDIS_URL=redis://... (pip install 'openclear[redis]'). If Redis can't be reached, requests are allowed and the error is logged, so a Redis outage doesn't take the app down. Either way, enforce the real limit at your proxy or WAF too.

  • Files (check images, bank logos, bulk-upload originals, data export archives and trained ML models) go wherever OPENCLEAR_STORAGE_BACKEND says (storage.py):

    • local (the default): the *_storage_dir and ml_artifact_dir directories in config.py. Every instance must see the same files, so point them at one shared volume.
    • s3: an S3-compatible bucket shared by every instance, with no shared disk needed (pip install 'openclear[s3]'). Set OPENCLEAR_S3_BUCKET, and optionally OPENCLEAR_S3_PREFIX, OPENCLEAR_S3_REGION, OPENCLEAR_S3_ENDPOINT_URL (for MinIO, Cloudflare R2 and other providers) and OPENCLEAR_S3_ACCESS_KEY_ID / OPENCLEAR_S3_SECRET_ACCESS_KEY (leave them empty to use boto3's own credentials: the environment, or an instance role). Give the credentials read, write, list and delete on that bucket (or prefix) only.

    The database stores a reference to each file: a local path, or s3://bucket/key. Reads go by the reference, so switching to s3 doesn't break files already on disk. To move them, run python scripts/move_files_to_object_storage.py --dry-run, then without --dry-run, then with --delete-local once you've checked. It's safe to run again. The dropbox inbox (auto_import_dropbox_dir) is still a directory: put it on the instance that runs the dropbox scan, or on a shared volume.

  • Database connections: each web process and each openclear-worker keeps a pool of up to OPENCLEAR_DB_POOL_SIZE (5) plus OPENCLEAR_DB_MAX_OVERFLOW (10) connections. Postgres must allow at least that many times the number of processes, plus headroom for migrations and admin tools. For many processes, put PgBouncer in front in transaction pooling mode for the web processes and workers. Row-level security works through it, because the bank is re-applied with set_config(..., true) at the start of every transaction, and the audit log's lock is transaction-scoped. The one exception is the scheduler's leader lock (workers/leader_lock.py), which holds a session-level advisory lock for the length of a job. Give whichever instances run scheduled jobs a direct connection to Postgres, or session pooling. OPENCLEAR_DB_POOL_RECYCLE_SECONDS (1800) and OPENCLEAR_DB_POOL_TIMEOUT_SECONDS (30) are there too.

  • Background jobs (OCR, data exports, dropbox scans, retraining) are rows in the database's job table, claimed by workers, so any instance's worker can run any job, and a job whose worker dies is picked up again. By default each web process runs one worker thread (OPENCLEAR_WORKER_MODE=in_process). To size workers separately from web traffic, set OPENCLEAR_WORKER_MODE=external on the web processes and run openclear-worker as its own process or container (as many as you need; on Postgres they claim jobs with SKIP LOCKED, so they never run the same job twice).

Everything else a request depends on (sessions, CSRF, WebAuthn challenges, the demo tenant's idle-reset clock) lives in the database or in signed cookies, so a request can land on any instance. Only the OIDC discovery and signing-key caches are per process, and each instance simply fills its own.

Logs and security events

Logs. OPENCLEAR_LOG_FORMAT=json writes one JSON object per line to stdout, with:

  • the time (UTC), level, logger and message;
  • during a request, its id, the caller's address, the bank and who is signed in;
  • an access line per request (method, path, status, duration).

Request bodies, query strings, tokens and cookies are never logged. A container log collector (Splunk's OpenTelemetry Collector, Fluent Bit, Datadog, CloudWatch) can ship them as they are. The default, text, is unchanged.

Security events. Sign-ins and their failures, lockouts, second factors, tokens, refusals (missing permissions, rate limits, segregation of duties), and changes to who may do what are recorded as security events (services/security_events.py; SIEM_PLAN.md has the catalog). Each one has the caller's address, the request id and who, and never a password, token, secret or account data. Floods (failed tokens, refusals) are sampled to one event a minute per source, with a count.

A bank reads its own events at GET /api/v1/security-events, and its action log, with each entry's signature, at GET /api/v1/audit-log. Both are feeds with a cursor, and need audit_log:read. openclear events tail --follow --cursor-file f prints the events as JSON lines for a log collector. The operator reads every bank's, and those belonging to no bank, at GET /api/v1/platform/security-events (platform key, operations scope).

Events are kept for OPENCLEAR_SECURITY_EVENT_RETENTION_DAYS (30).

Background jobs

Slow or CPU-heavy work (OCR on an uploaded image, building a data export, a bank's dropbox scan, retraining a model) runs as a background job (workers/jobs.py), not inside the web request. A job is a row in the job table: it's committed with whatever queued it, claimed by a worker, retried with back-off if it fails (up to OPENCLEAR_JOB_MAX_ATTEMPTS, default 5), and queued again if its worker stops sending heartbeats for OPENCLEAR_JOB_STALE_AFTER_SECONDS (default 300), e.g. after a crash or redeploy.

Where jobs run is OPENCLEAR_WORKER_MODE:

Mode What runs jobs Use it for
in_process (default) a worker thread inside each web process a single process: the launcher, the Docker image, the Fly demo. Nothing extra to run.
external openclear-worker processes you start separately sizing background work independently of web traffic, on Postgres
inline whatever queued the job, right away the test suite

openclear-worker --once runs every job that's due and exits (for cron). A job that has used all its attempts stays failed, with its error, until the platform operator retries it through /api/v1/platform/jobs (API.md).

Exports stream to disk in batches, so a bank of any size can export. The only bound is the time limit, OPENCLEAR_DATA_EXPORT_TIMEOUT_SECONDS (default an hour; a bank can set its own).

Housekeeping and data growth

Some tables grow with every request without being anyone's record. A daily housekeeping run (services/housekeeping_service.py; turn it on with OPENCLEAR_ENABLE_HOUSEKEEPING_SCHEDULER=true, or call workers.tasks.housekeeping_job from cron) clears them out:

What Kept for Setting (days; 0 keeps them forever)
Finished background jobs 30 days OPENCLEAR_RETENTION_FINISHED_JOBS_DAYS
Sent (or failed) notifications 180 days OPENCLEAR_RETENTION_NOTIFICATIONS_DAYS
Bulk-upload row results (the upload, its file and counts stay) 365 days OPENCLEAR_RETENTION_BULK_ROW_RESULTS_DAYS
Data export archives (a full copy of a bank's data) 30 days, then marked expired OPENCLEAR_RETENTION_DATA_EXPORTS_DAYS

It never deletes a bank's business records (presented checks, ACH, exceptions, decisions, issued items): how long to keep those is each bank's compliance decision.

The audit log can be archived instead (OPENCLEAR_AUDIT_ARCHIVE_AFTER_DAYS, off by default; services/audit_archive_service.py). Entries older than that are verified, then moved into a signed, gzipped JSON Lines file in storage, and a checkpoint records where the live chain carries on. A broken chain is never archived. Nothing is lost: the audit log page lists each archive for download, and "Verify" checks the archives too. The newest entry always stays in the table.

Plugins

Try it first: start a free sandbox bank, your own bank in about a minute with sample checks, ACH and exceptions, to see what a plugin would extend.

You can extend OpenClear without changing its code, using a separately installed Python package: another payment network, an OCR or storage service, a scoring model, or an import format for your core's files.

A plugin declares an entry point in the openclear.plugins group:

[project.entry-points."openclear.plugins"]
rtp = "openclear_rtp:plugin"

The object it points to has these:

  • a name;
  • a version;
  • plugin_api, the range of the plugin API version it supports, for example (1, 1);
  • register(registry).

Installing a package isn't enough to turn it on. The operator also allows it by entry point name with OPENCLEAR_PLUGINS=rtp,other.

A plugin is skipped if it:

  • isn't allowed;
  • needs a different plugin API version;
  • fails to load;
  • claims a name that's already taken.

Each skipped plugin is reported in the log and under Admin → Plugins, and the app still starts. OpenClear's own networks, OCR providers, email and SMS, storage, scoring model and import formats register the same way, from plugins/builtin.py.

Plugins run inside OpenClear with its full access. They're trusted code, so install only plugins you'd trust as much as OpenClear itself.

A plugin loaded into OpenClear is part of the same program, so it's AGPL-3.0 too. A closed system should integrate through the API and webhooks instead.

A plugin's import formats and decision file layouts are off for each bank until its admin turns them on under Admin → Plugins. To make a plugin required, set OPENCLEAR_PLUGINS_REQUIRED; OpenClear won't start if it doesn't load.

To write a plugin:

The plan is in PLUGIN_PLAN.md.

Upgrade and downgrade support

  • Upgrading is always supported, from any prior version straight to the latest — alembic upgrade head must work regardless of how old your starting version is. This is enforced by tests/test_migrations/test_upgrade_downgrade_policy.py:: test_full_upgrade_from_base_succeeds, run as part of the normal test suite.
  • Downgrading is only supported up to 2 minor versions back, and never across a major version boundary — a migration introduced at a major version bump is allowed to have an irreversible downgrade() (raising, or a documented no-op), reserving room for genuinely breaking changes to exactly that boundary.

migrations/version_history.py records which Alembic revision was head at each release — test_downgrade_two_minor_versions_supported resolves "2 minor versions back" from there and actually runs the downgrade against a scratch database. It skips cleanly (not silently, not failing) whenever there isn't yet 2 minor versions of recorded history to test against.

Cutting a release: bump the version in pyproject.toml and src/openclear/main.py together, then add one new entry to VERSION_HISTORY in migrations/version_history.py mapping the new version to the current Alembic head — only if that release actually added a migration (a patch that doesn't touch the schema doesn't need an entry). Never edit or remove a past entry.

Web UI

Server-rendered (FastAPI + Jinja2, no Node/build step) under /ui/*, covering every resource: accounts, issued items, stop payments, paid items, check images (upload + OCR status), ACH authorizations/transactions, the exceptions review queue (recommend/decide), admin ML screens, users/security groups, per-tenant branding settings, the immutable action log, and WebAuthn security-key management.

It's a second presentation layer over the same services//auth/ code the JSON API uses (web/routers/*.py call service functions directly — never the JSON API over HTTP), authenticated via cookies instead of a bearer token: web/deps.py::get_web_context reads an access_token cookie, auth/deps.py::get_current_context reads the Authorization header — the two channels never read each other's credential. Moving to cookies reintroduces CSRF risk the header-based API doesn't have, so every /ui/* POST is guarded by a double-submit cookie token (web/security.py); UI gating checks the same ctx.permissions set (resolved from the caller's security group) the API enforces, exposed to templates as a can(ctx, permission) Jinja global — hiding a button is cosmetic, the POST route's own permission check is what actually enforces it.

Architecture at a glance

The full picture (and the reasoning behind it) is in docs/ARCHITECTURE.md.

  • db/ — engine/session factory (one code path for all three backends), tenant context
  • domain/ — SQLAlchemy models (import openclear.domain to register every mapper — see its __init__.py docstring for why this matters)
  • networks/ — the pluggable per-payment-network layer (check/, ach/); each implements networks.base.NetworkAdapter and self-registers via networks.registry.register_adapter(). Adding a new network (e.g. RTP) means a plugin that registers its adapter (see Plugins) — no changes to exception_item, decision, ml/, or the /exceptions API.
  • ocr/ — pluggable OCR (OCRProvider protocol; Tesseract is the default, cloud providers are stubbed behind optional extras)
  • ml/ — model training/scoring per network, fed by human pay/return decisions (decision.features_json). Each organization chooses the shared model (trained on every participating organization's decisions, run by the platform operator through /api/v1/platform/ml/* with a shared_model-scoped platform key) or a bank-only model (its own decisions only, seeded from a copy of the shared model when it switches, run by its own admins). Customer models sit on top of either. See ml/predict.py for which model scores an exception, services/tenant_ml_service.py for switching, and the admin "ML Scoring" docs for the details a bank sees.
  • plugins/ — the plugin registry and loader; plugins/builtin.py registers the built-ins
  • api/v1/ — FastAPI routers; exceptions.py/decisions.py are network-agnostic
  • workers/ — the scheduled jobs (ML retrain, dropbox import, notifications, disposition sweep, demo reset), run by an opt-in in-process APScheduler (OPENCLEAR_ENABLE_ML_SCHEDULER and friends) or an external cron/k8s CronJob calling the functions in workers/tasks.py. workers/leader_lock.py keeps each job to one instance at a time on Postgres
  • web/ — the server-rendered UI (see "Web UI" above); templates/ and static/ live inside the package so they ship with it wherever it's installed
  • scripts/launcher.py — the one-click local setup/run script (stdlib-only until it re-execs itself under a freshly-created venv's own interpreter); run_openclear.command (macOS), run_openclear.bat (Windows), and run_openclear.sh (Linux) are thin platform-specific wrappers around it — all three just locate a Python interpreter and hand off to the same script

License

Copyright (C) 2026 Chaffed

OpenClear is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This means that if you run a modified version of OpenClear as a network service, you must make the modified source available to that service's users — see LICENSE for the full text.

Building an integration? Start with a free developer sandbox (sandbox/, or /ui/sandbox on a sandbox installation) and the integration checklist.

The client SDKs in sdks/ are licensed Apache-2.0 instead (each has its own LICENSE). A proprietary core or online banking platform can include them without any AGPL obligation.

来源:Hacker News · github.com