Files
keysat-plans/agent-payment-connect-scope.md
T

157 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: agent-delegable payment-provider connect (without making it a fund-redirection key)
Status: **APPROVED — agreed shape, ready to build. No urgency** (doc-harness Path 1, no
payments, ships first; this work feeds Path 2, the full regtest buyer-pays walkthrough).
Author: this session, 2026-06-16. Sign-off: other dev, 2026-06-16.
Related: `merchant-onboard` role shipped in `0.2.0:57` (`src/api/api_keys.rs`); multi-provider
model (`plans/multi-provider-payment-model.md`).
## 1. Problem / motivation
Make Keysat **fully agent-compatible**: an operator hands an agent a scoped credential and
says *"add Keysat licensing to this software and connect a Bitcoin wallet on BTCPay,"* and
the agent does the whole thing over the API. Two use cases:
1. **Production delegation** — a real operator delegates first-time setup (catalog + connect
their BTCPay) to an agent.
2. **Doc-onboarding test harness** — a fresh AI agent integrates Keysat from the docs alone
against a disposable server, published as marketing ("all the agent had to do was X, Y,
Z"). For dev/testing the agent uses **regtest** (private Bitcoin network — mine blocks on
demand, no faucets, no real money).
`merchant-onboard` (shipped `:57`) covers catalog + manual license issuance end-to-end but
**cannot connect a payment provider** — that stays master-key only. So the agent story stops
one step short of "accept buyer payments."
## 2. The core risk (why connect is the crown jewel) — ratified
Creating products/policies/licenses is *"what you sell."* Connecting — or **re**-connecting —
a payment provider is **"where the money goes":** a credential that can do it can repoint
settlement at an **attacker-controlled wallet/store**, after which every buyer payment lands
there. Direct **theft-of-funds**, strictly worse than any catalog write. The value of a
scoped key is **bounded blast radius**; folding connect into `merchant-onboard` would make it
"near-total financial control with a friendly name."
**Binding decision (both devs): do NOT fold connect into `merchant-onboard`.** A key that can
repoint settlement is a fund-redirection key; bounding that blast radius is the whole point.
## 3. Current state (verified against code, 2026-06-16)
- All provider-mutating endpoints are **master-key only** (`require_admin`):
- BTCPay connect — `api/btcpay_authorize.rs:87` (OAuth-style: returns an `authorize_url`; a
human approves on BTCPay, then the callback persists store id / API key / webhook secret).
- BTCPay disconnect — `btcpay_authorize.rs:426`.
- Zaprite connect — `api/zaprite_authorize.rs:56` (API-key paste; **no** OAuth step).
- Zaprite disconnect — `zaprite_authorize.rs:207`.
- Only `payment_providers:read` exists as a scope; there is **no** `payment_providers:write`
today. `merchant-onboard` grants all `:read` + `products:write` + `policies:write` +
`licenses:write`. Master-only ops sit behind `require_admin`, never consulting `Role::grants`.
- Asymmetry: **BTCPay is self-hosted, can run regtest/testnet** (the safe case). **Zaprite is
a hosted mainnet SaaS** — no regtest equivalent assumed, so scoped Zaprite-connect stays
master-only and the demo uses BTCPay (the motivating case anyway).
## 4. Options (B rejected; built shape is C + D, gated by E)
| # | Option | Verdict |
|---|--------|---------|
| A | Keep connect master-only (status quo) | Safe, not fully agent-compatible |
| B | Fold connect into `merchant-onboard` | **Rejected (binding)** — fund-redirection key |
| C | À-la-carte scope `payment_providers:write`, never in any default role | **Chosen** packaging |
| D | Network gate: scoped connect only regtest/testnet/signet; mainnet → master | **Chosen** (inner defense) |
| E | Sandbox-mode daemon flag for disposable instances | **Chosen as the OUTER gate** (see §5) |
## 5. Agreed design — gate order matters
A *scoped* `payment_providers:write` connect is permitted **iff ALL** hold, checked in this
order:
1. **OUTER — sandbox-mode daemon flag is ON.** On a production (non-sandbox) daemon, scoped
payment-connect is **disabled entirely — even regtest.** *(Refinement, dev:* a scoped key
connecting a regtest provider on a production box would knock out the live store's real
payments — denial-of-revenue, not theft, but still bad. So the flag, not the network, is
the first gate.*)*
2. **INNER — target network is non-mainnet** (regtest/testnet/signet). Defense-in-depth so
that even on a sandbox daemon a scoped key can't wire up mainnet. **Fail closed:** if the
network can't be positively determined as non-mainnet, treat it as mainnet → deny.
3. **BTCPay OAuth human-approve** still happens (someone approves on BTCPay's side).
Master key bypasses 12 and may connect any network (still subject to BTCPay's own OAuth).
**The sandbox flag is daemon-level config (env / launch), read at boot — NEVER settable via
any API, scoped or otherwise.** *(Refinement, dev:* otherwise a scoped key flips sandbox on,
then connects. Keep it strictly out-of-band.*)*
Net: full agent-compatibility for dev/testing on a sandbox+regtest instance (zero master-key
steps — the doc-harness Path 2), and production stays locked: no scoped key can connect,
reconnect, or disconnect a provider on a live box.
## 6. Resolved decisions (the dev's open-question answers)
1. **BTCPay network detection — VERIFIED.** Greenfield's `GET /api/v1/server/info`
(`ServerInfoData`) has **no** network/chainType field (only `syncStatus[].chainHeight`
etc.). Determine the network from a **network-encoding artifact**, not a field:
- Primary: fetch a store on-chain receive address —
`GET /api/v1/stores/{storeId}/payment-methods/{pmid}/wallet/address`
`OnChainWalletAddressData.address` — and classify by prefix: bech32 HRP `bc1…`=mainnet,
`tb1…`=testnet/signet, `bcrt1…`=regtest (legacy base58 `1`/`3`=mainnet vs `m`/`n`/`2`=
test/regtest).
- Secondary: the on-chain payment method's `derivationScheme` (NBXplorer format) — xpub/
ypub/zpub=mainnet, tpub/upub/vpub=testnet. (Fetching config needs
`btcpay.store.canmodifystoresettings`.)
- **Fail closed:** Lightning-only store / no address / unrecognized prefix → treat as
mainnet → require master.
2. **Zaprite** — no non-mainnet mode assumed → scoped Zaprite-connect stays master-only;
demo uses BTCPay. (Agreed.)
3. **Reconnect / rotate of an already-connected mainnet provider — always master-only.**
(Strong yes — that's the exact attack.) Sandbox/regtest reconnect follows the §5 scoped
rules; mainnet reconnect is never scoped.
4. **Packaging — à-la-carte `payment_providers:write`** (composes with `merchant-onboard`,
avoids role sprawl), **not** a new fixed role. Implementation note: "composes with" means
moving from one-role-per-key to **role + optional extra scopes per key** (or full per-key
scope sets) — a schema addition (`scoped_api_keys` gains a scopes column, migration
`0024`). Acceptable given no urgency; flagged so it's not a surprise at build time.
## 7. Design sketch (build)
- **Scope string**: add `"payment_providers:write"`. Granted per-key (à-la-carte, §6.4), never
via a `:write` suffix match, never in `merchant-onboard`'s grant set.
- **Sandbox flag**: e.g. `KEYSAT_SANDBOX_MODE=1`, read at boot into `AppState`; surfaced
read-only in `/v1/admin/tier`-style status + a prominent "SANDBOX" banner in the admin UI so
a sandbox instance is never mistaken for production. **No setter endpoint of any kind.**
- **Gate helper**: replace `require_admin` on the connect handlers with
`require_provider_connect(state, headers, target_network)`:
- master → allow any network;
- scoped w/ `payment_providers:write` → allow **iff** `state.sandbox_mode` **and**
`target_network != mainnet`;
- else 403.
Resolve `target_network` (§6.1) **before** persisting anything.
- **Reconnect/disconnect**: mainnet reconnect always master-only (§6.3); disconnect on a
production daemon stays master-only (denial-of-revenue, consistent with §5.1). In sandbox,
both follow the scoped rule.
- **Audit**: every scoped connect writes an audit row with actor hash + resolved network +
provider.
## 8. Migration / back-compat
- New scope string + gate helper + sandbox flag. Migration `0024` only for §6.4 (per-key extra
scopes column on `scoped_api_keys`).
- Master-key automation unaffected; existing `merchant-onboard` keys unaffected (capability
unchanged); existing keys default to no extra scopes.
## 9. Testing
- Scoped `payment_providers:write` key on a **sandbox** daemon: connect regtest BTCPay →
allowed; connect mainnet BTCPay → 403; Lightning-only / unknown network → 403 (fail-closed).
- Same key on a **production** (non-sandbox) daemon: connect regtest → **403** (outer gate);
proves §5.1.
- Sandbox flag is not flippable via API (assert no endpoint mutates it).
- Master key: connect any network → allowed.
- `merchant-onboard` without the extra scope → 403 on connect everywhere (proves no role widen).
- Mainnet reconnect/rotate → master-only even in sandbox.
## 10. Status
Shape **approved by both devs**: C (à-la-carte `payment_providers:write`) + D (network gate,
fail-closed) gated by E (sandbox flag as the outer gate, daemon-level only), BTCPay OAuth
preserved, B explicitly rejected. Build when it suits — Path 1 ships first.