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:
@@ -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);
|
||||
@@ -7,7 +7,8 @@
|
||||
//!
|
||||
//! 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
|
||||
//! 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
|
||||
//! appears in any response afterward — only its sha256 hash is stored.
|
||||
//! 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
|
||||
/// requests without touching catalog or settings.
|
||||
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
|
||||
/// that use `require_scope`; still rejected by endpoints that gate on
|
||||
/// settings-write or tier-write where the master key is required.
|
||||
@@ -73,6 +84,7 @@ impl Role {
|
||||
Role::ReadOnly => "read-only",
|
||||
Role::LicenseIssuer => "license-issuer",
|
||||
Role::Support => "support",
|
||||
Role::MerchantOnboard => "merchant-onboard",
|
||||
Role::FullAdmin => "full-admin",
|
||||
}
|
||||
}
|
||||
@@ -81,6 +93,7 @@ impl Role {
|
||||
"read-only" => Some(Role::ReadOnly),
|
||||
"license-issuer" => Some(Role::LicenseIssuer),
|
||||
"support" => Some(Role::Support),
|
||||
"merchant-onboard" => Some(Role::MerchantOnboard),
|
||||
"full-admin" => Some(Role::FullAdmin),
|
||||
_ => None,
|
||||
}
|
||||
@@ -104,6 +117,18 @@ impl Role {
|
||||
| "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(|| {
|
||||
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(),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const SPEC_JSON: &str = r##"{
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"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": {
|
||||
@@ -398,7 +398,7 @@ const SPEC_JSON: &str = r##"{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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"]
|
||||
} } }
|
||||
|
||||
@@ -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`
|
||||
/// entitlement) with 402. Switching the daemon's self-tier to a
|
||||
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
|
||||
|
||||
@@ -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: 'license-issuer' }, 'License issuer — read + issue / revoke / change-tier licenses'),
|
||||
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)'),
|
||||
])
|
||||
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
|
||||
|
||||
Reference in New Issue
Block a user