Add merchant-onboard scoped-key role for self-serve onboarding

New scoped API-key role granting read + products:write + policies:write +
licenses:write — the least-privilege credential for end-to-end catalog
setup and license issuance (create product, define policies/tiers, issue
licenses against them) without holding the master key.

The catalog write scopes already existed and were enforced on the
endpoints; only the role->scope expansion was missing. So this is a new
Role variant, not a scope-model change. grants() matches scope strings
explicitly (never by :write suffix) so the role can't widen into
settings / payment / merchant-profile / webhook writes, and every
master-only operation stays behind require_admin and so is structurally
unreachable. Existing tier caps still bound it (Creator: 5 products /
5 policies per product).

Migration 0023 rebuilds scoped_api_keys to widen the role CHECK (SQLite
can't alter a CHECK in place); the table has no FKs, so it's a plain
copy/drop/rename. Test covers the full onboard chain under the key's own
credential plus denial of master-only gates and support-only writes.
This commit is contained in:
Grant
2026-06-16 18:55:18 -05:00
parent 6b02992013
commit d5885d1d97
5 changed files with 163 additions and 4 deletions
@@ -0,0 +1,39 @@
-- Migration 0023: add the 'merchant-onboard' scoped-API-key role.
--
-- 0016 created scoped_api_keys with a CHECK that pins `role` to the four
-- roles known then (read-only | license-issuer | support | full-admin).
-- SQLite can't ALTER or DROP a CHECK constraint in place, so adding a
-- fifth role means rebuilding the table with a widened CHECK.
--
-- scoped_api_keys has no foreign keys (inbound or outbound), so this is
-- the simple copy -> drop -> rename rebuild, without any of the FK
-- juggling that 0009 needed. sqlx-migrate wraps each file in a
-- transaction; we don't BEGIN here.
--
-- Idempotent: re-running produces the same end state. Existing rows (any
-- role, active or revoked) are preserved verbatim. The leading DROP IF
-- EXISTS clears a stray _new table from any partially-applied prior run
-- before we rebuild.
DROP TABLE IF EXISTS scoped_api_keys_new;
CREATE TABLE scoped_api_keys_new (
id TEXT PRIMARY KEY NOT NULL,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
CHECK (role IN ('read-only', 'license-issuer', 'support', 'merchant-onboard', 'full-admin'))
);
INSERT INTO scoped_api_keys_new
SELECT id, label, token_hash, role, created_at, last_used_at, revoked_at
FROM scoped_api_keys;
DROP TABLE scoped_api_keys;
ALTER TABLE scoped_api_keys_new RENAME TO scoped_api_keys;
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_token ON scoped_api_keys(token_hash);
CREATE INDEX IF NOT EXISTS idx_scoped_api_keys_active ON scoped_api_keys(revoked_at);
+28 -2
View File
@@ -7,7 +7,8 @@
//! //!
//! 1. Operator mints a new key via the Settings → "Scoped API keys" panel //! 1. Operator mints a new key via the Settings → "Scoped API keys" panel
//! in the admin SPA (or directly via `POST /v1/admin/api-keys`), picking a //! in the admin SPA (or directly via `POST /v1/admin/api-keys`), picking a
//! role from a fixed list (Read-only / License issuer / Support / Full admin). //! role from a fixed list (Read-only / License issuer / Support /
//! Merchant onboard / Full admin).
//! 2. The create response returns the raw token ONCE. The token never //! 2. The create response returns the raw token ONCE. The token never
//! appears in any response afterward — only its sha256 hash is stored. //! appears in any response afterward — only its sha256 hash is stored.
//! 3. Agent uses `Authorization: Bearer <token>` like the master key. Each //! 3. Agent uses `Authorization: Bearer <token>` like the master key. Each
@@ -61,6 +62,16 @@ pub enum Role {
/// Right shape for a customer-support agent that resolves common /// Right shape for a customer-support agent that resolves common
/// requests without touching catalog or settings. /// requests without touching catalog or settings.
Support, Support,
/// Read-only + catalog *and* license writes: create/edit products,
/// define policies/tiers, and issue licenses against them. The
/// least-privilege credential for end-to-end self-serve onboarding —
/// a merchant (or an integrating agent) standing up a fresh catalog
/// via the API without the master key. Deliberately excludes the
/// support writes (subs/machines) and every master-only gate
/// (settings, tiers, payment connect, key mgmt, signing-key, db).
/// Tier caps still bound it: a Creator-tier box stays at 5 products /
/// 5 policies-per-product regardless of credential.
MerchantOnboard,
/// Every scope. Equivalent to the master `admin_api_key` for endpoints /// Every scope. Equivalent to the master `admin_api_key` for endpoints
/// that use `require_scope`; still rejected by endpoints that gate on /// that use `require_scope`; still rejected by endpoints that gate on
/// settings-write or tier-write where the master key is required. /// settings-write or tier-write where the master key is required.
@@ -73,6 +84,7 @@ impl Role {
Role::ReadOnly => "read-only", Role::ReadOnly => "read-only",
Role::LicenseIssuer => "license-issuer", Role::LicenseIssuer => "license-issuer",
Role::Support => "support", Role::Support => "support",
Role::MerchantOnboard => "merchant-onboard",
Role::FullAdmin => "full-admin", Role::FullAdmin => "full-admin",
} }
} }
@@ -81,6 +93,7 @@ impl Role {
"read-only" => Some(Role::ReadOnly), "read-only" => Some(Role::ReadOnly),
"license-issuer" => Some(Role::LicenseIssuer), "license-issuer" => Some(Role::LicenseIssuer),
"support" => Some(Role::Support), "support" => Some(Role::Support),
"merchant-onboard" => Some(Role::MerchantOnboard),
"full-admin" => Some(Role::FullAdmin), "full-admin" => Some(Role::FullAdmin),
_ => None, _ => None,
} }
@@ -104,6 +117,18 @@ impl Role {
| "machines:write" | "machines:write"
) )
} }
// Catalog + license writes only. Match scopes EXPLICITLY (never
// by `:write` suffix) so this role can never widen into
// settings:write / merchant_profiles:write / payment / webhooks
// / rates — all of which would otherwise share the suffix. Adding
// a write scope here is a deliberate per-string decision.
Role::MerchantOnboard => {
scope.ends_with(":read")
|| matches!(
scope,
"products:write" | "policies:write" | "licenses:write"
)
}
} }
} }
} }
@@ -212,7 +237,8 @@ pub async fn create(
} }
let role = Role::parse(req.role.trim()).ok_or_else(|| { let role = Role::parse(req.role.trim()).ok_or_else(|| {
AppError::BadRequest( AppError::BadRequest(
"role must be one of: read-only, license-issuer, support, full-admin".into(), "role must be one of: read-only, license-issuer, support, merchant-onboard, full-admin"
.into(),
) )
})?; })?;
+2 -2
View File
@@ -50,7 +50,7 @@ const SPEC_JSON: &str = r##"{
"bearerAuth": { "bearerAuth": {
"type": "http", "type": "http",
"scheme": "bearer", "scheme": "bearer",
"description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, or full-admin." "description": "Master admin_api_key OR a scoped API key (ks_...). Scoped keys are gated on a role: read-only, license-issuer, support, merchant-onboard, or full-admin."
} }
}, },
"schemas": { "schemas": {
@@ -398,7 +398,7 @@ const SPEC_JSON: &str = r##"{
"type": "object", "type": "object",
"properties": { "properties": {
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" }, "label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "full-admin"] } "role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] }
}, },
"required": ["label", "role"] "required": ["label", "role"]
} } } } } }
+93
View File
@@ -3449,6 +3449,99 @@ async fn scoped_full_admin_key_manages_catalog() {
); );
} }
/// Merchant-onboard scoped keys can run the full self-serve onboarding chain
/// with their OWN credential — create a product, define a policy/tier, and
/// issue a license against it (products:write + policies:write +
/// licenses:write) — WITHOUT the master key. They must still be denied every
/// master-only gate (db-info, minting other keys) and the support writes they
/// don't need (subscriptions:write), which keeps the role least-privilege and
/// non-escalating.
#[tokio::test]
async fn scoped_merchant_onboard_key_onboards_but_not_master() {
let (state, _tmp) = make_test_state().await;
let auth = format!("Bearer {}", mint_scoped_key(&state, "merchant-onboard").await);
// 1. Create a product — allowed (products:write). Note: the key itself
// creates it, not the master — that's the whole point of the role.
let req = build_request(
"POST",
"/v1/admin/products",
&[("authorization", &auth)],
Some(json!({ "slug": "onboard-prod", "name": "Onboard Prod", "price_sats": 1000 })),
);
assert_eq!(
send(&state, req).await.status(),
StatusCode::OK,
"merchant-onboard must be able to create products"
);
// 2. Define a policy/tier on it — allowed (policies:write). Non-recurring
// so the Creator-tier recurring gate (402) doesn't fire.
let req = build_request(
"POST",
"/v1/admin/policies",
&[("authorization", &auth)],
Some(json!({
"product_slug": "onboard-prod",
"name": "Standard",
"slug": "standard",
"duration_seconds": 0,
"max_machines": 1
})),
);
assert_eq!(
send(&state, req).await.status(),
StatusCode::OK,
"merchant-onboard must be able to define policies"
);
// 3. Issue a license against it — allowed (licenses:write).
let req = build_request(
"POST",
"/v1/admin/licenses",
&[("authorization", &auth)],
Some(json!({ "product_slug": "onboard-prod", "policy_slug": "standard" })),
);
assert_eq!(
send(&state, req).await.status(),
StatusCode::OK,
"merchant-onboard must be able to issue licenses"
);
// 4. Master-only gates stay denied — no escalation path.
let req = build_request("GET", "/v1/admin/db-info", &[("authorization", &auth)], None);
assert_eq!(
send(&state, req).await.status(),
StatusCode::FORBIDDEN,
"db-info is master-only; merchant-onboard must be denied"
);
let req = build_request(
"POST",
"/v1/admin/api-keys",
&[("authorization", &auth)],
Some(json!({ "label": "tries to elevate", "role": "full-admin" })),
);
assert_eq!(
send(&state, req).await.status(),
StatusCode::FORBIDDEN,
"merchant-onboard must NOT mint other keys (self-elevation guard)"
);
// 5. Support writes it doesn't need stay denied — least-privilege boundary
// on the other side (this is what separates it from the support role).
let req = build_request(
"POST",
"/v1/admin/subscriptions/does-not-exist/cancel",
&[("authorization", &auth)],
None,
);
assert_eq!(
send(&state, req).await.status(),
StatusCode::FORBIDDEN,
"merchant-onboard must NOT have subscriptions:write"
);
}
/// Zaprite Connect refuses on Creator-tier (no `zaprite_payments` /// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a /// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then /// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
+1
View File
@@ -6618,6 +6618,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
el('option', { value: 'read-only' }, 'Read-only — list everything; mutate nothing'), el('option', { value: 'read-only' }, 'Read-only — list everything; mutate nothing'),
el('option', { value: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'), el('option', { value: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'),
el('option', { value: 'support' }, 'Support — license issuer + cancel subs + deactivate machines'), el('option', { value: 'support' }, 'Support — license issuer + cancel subs + deactivate machines'),
el('option', { value: 'merchant-onboard' }, 'Merchant onboard — read + create products / policies + issue licenses (self-serve catalog setup)'),
el('option', { value: 'full-admin' }, 'Full admin — every scope (use sparingly)'), el('option', { value: 'full-admin' }, 'Full admin — every scope (use sparingly)'),
]) ])
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '') const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')