diff --git a/licensing-service/migrations/0023_merchant_onboard_role.sql b/licensing-service/migrations/0023_merchant_onboard_role.sql new file mode 100644 index 0000000..83c592b --- /dev/null +++ b/licensing-service/migrations/0023_merchant_onboard_role.sql @@ -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); diff --git a/licensing-service/src/api/api_keys.rs b/licensing-service/src/api/api_keys.rs index 294b3a3..ef7f7f8 100644 --- a/licensing-service/src/api/api_keys.rs +++ b/licensing-service/src/api/api_keys.rs @@ -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 ` 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(), ) })?; diff --git a/licensing-service/src/api/openapi.rs b/licensing-service/src/api/openapi.rs index a929ea0..deb2b99 100644 --- a/licensing-service/src/api/openapi.rs +++ b/licensing-service/src/api/openapi.rs @@ -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"] } } } diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 03afe77..3da1bf0 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -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 diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index cdd3c66..6204e54 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -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' }, '')