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
+28 -2
View File
@@ -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(),
)
})?;
+2 -2
View File
@@ -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"]
} } }