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
|
//! 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(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
} } }
|
} } }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }, '')
|
||||||
|
|||||||
Reference in New Issue
Block a user