From 257669092b08790f6ed0fb386efc801a9a0c1471 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 08:45:25 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:11=20+=20v0.2.0:12=20=E2=80=94=20Archive?= =?UTF-8?q?,=20Settings,=20agent=20surface,=20machines=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs. --- KEYSAT_AGENT_GUIDE.md | 370 ++++++ KEYSAT_INTEGRATION.md | 77 +- licensing-service/docs/INTEGRATION.md | 2 +- .../migrations/0015_policy_archive.sql | 19 + .../migrations/0016_scoped_api_keys.sql | 37 + licensing-service/src/analytics.rs | 2 +- licensing-service/src/api/api_keys.rs | 342 ++++++ licensing-service/src/api/buy_page.rs | 2 +- licensing-service/src/api/machines.rs | 38 +- licensing-service/src/api/mod.rs | 24 +- licensing-service/src/api/openapi.rs | 438 +++++++ licensing-service/src/api/payment_provider.rs | 1 + licensing-service/src/api/policies.rs | 198 ++- licensing-service/src/api/self_license.rs | 12 +- licensing-service/src/api/tier.rs | 89 +- .../src/api/zaprite_authorize.rs | 1 + licensing-service/src/db/repo.rs | 160 ++- licensing-service/src/license_self.rs | 116 +- licensing-service/src/main.rs | 9 +- licensing-service/src/models.rs | 6 + licensing-service/src/subscriptions.rs | 49 + licensing-service/tests/api.rs | 167 +++ licensing-service/web/index.html | 1083 ++++++++++++++--- startos/actions/index.ts | 65 +- startos/versions/v0.2.0.ts | 57 +- 25 files changed, 2980 insertions(+), 384 deletions(-) create mode 100644 KEYSAT_AGENT_GUIDE.md create mode 100644 licensing-service/migrations/0015_policy_archive.sql create mode 100644 licensing-service/migrations/0016_scoped_api_keys.sql create mode 100644 licensing-service/src/api/api_keys.rs create mode 100644 licensing-service/src/api/openapi.rs diff --git a/KEYSAT_AGENT_GUIDE.md b/KEYSAT_AGENT_GUIDE.md new file mode 100644 index 0000000..c2c8861 --- /dev/null +++ b/KEYSAT_AGENT_GUIDE.md @@ -0,0 +1,370 @@ +# Keysat Agent Integration Guide + +How to build agents, bots, and automation that operate a Keysat instance. + +Keysat was designed from the start to be agent-friendly. The admin API uses +plain HTTP + JSON with Bearer-token auth. There's an OpenAPI 3.1 spec for +discovery. Scoped API keys let you give an agent least-privilege access +without handing over the master credential. Errors carry stable machine-readable codes. Webhooks let an agent react to events instead of polling. + +This guide is for the *operator side* of Keysat — running, configuring, and +performing day-to-day operations on a Keysat instance. For the *buyer side* +(validating licenses inside your app), see [KEYSAT_INTEGRATION.md](KEYSAT_INTEGRATION.md). + +--- + +## Quick start + +```bash +# 1. Discover the API surface +curl https://your-keysat-host/v1/openapi.json + +# 2. Generate a scoped API key (in admin UI: Settings → API keys, or via curl) +curl -X POST https://your-keysat-host/v1/admin/api-keys \ + -H "Authorization: Bearer $MASTER_ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{"label":"Support bot","role":"support"}' +# Response includes `token: ks_...`. Save it — it's only shown once. + +# 3. Use the scoped key +curl https://your-keysat-host/v1/admin/licenses?status=active \ + -H "Authorization: Bearer ks_..." +``` + +--- + +## Authentication + +All admin endpoints use HTTP Bearer auth: + +``` +Authorization: Bearer +``` + +Two kinds of tokens are accepted: + +**Master admin API key** — the env-configured `KEYSAT_ADMIN_API_KEY` (visible +in StartOS Actions → Show credentials on first install). Full access to every +endpoint. This is the operator's credential. Don't hand it to agents. + +**Scoped API keys** — additional tokens generated in admin UI → Settings → +API keys. Each carries a role that bounds what it can do. Format: `ks_<43 chars>`. Operators can revoke any scoped key from the same UI; revoked tokens stop working immediately. + +### Role to scope mapping + +| Role | What it can do | +|---|---| +| `read-only` | List / get every resource. Mutate nothing. | +| `license-issuer` | All `read-only` scopes + issue / revoke / suspend / change-tier on licenses. Cannot touch products, policies, or codes. | +| `support` | All `license-issuer` scopes + cancel subscriptions + force-deactivate machines. | +| `full-admin` | Every scope. Equivalent to the master key for most endpoints. | + +Endpoints that touch settings (operator name, payment provider connections, +self-license activation, scoped API key management) always require the master +admin key. A `full-admin` scoped key cannot, for example, generate another +scoped key — that's a self-defeating elevation path. + +--- + +## Discovering the API + +Two complementary discovery mechanisms. + +### OpenAPI 3.1 spec + +`GET /v1/openapi.json` — unauthenticated. Returns a curated spec covering the +agent-relevant subset of endpoints. Use this with: + +- **OpenAI Custom GPTs**: paste the URL as an Action. +- **OpenAI Assistants / Functions**: feed the spec to tool definition generators. +- **Claude tool use**: use the spec to derive your `tools` array; Claude Code agents can `WebFetch` the spec at runtime and reason about endpoints. +- **LangChain / AutoGen / Smolagents**: use their OpenAPI loaders. +- **Code generation**: `openapi-generator-cli generate -i /v1/openapi.json -g python -o ./client`. + +The spec is a stable agent surface, not auto-derived from handler signatures. +We commit to keeping documented endpoints and field shapes stable across +minor releases. + +### Embedded endpoint listing + +This guide's "Common workflows" section below covers the most common agent +tasks with copy-paste examples. + +--- + +## Response envelope conventions + +Every error response uses the same JSON envelope: + +```json +{ + "ok": false, + "error": "tier_cap", + "message": "Your Creator tier allows up to 5 products. You're at 5...", + "upgrade_url": "https://licensing.keysat.xyz/buy/keysat?policy=pro" +} +``` + +`error` is a stable machine-readable code; `message` is human-readable. The +`upgrade_url` field appears on 402 (tier cap) responses so a UI can render an +upgrade CTA without parsing message strings. + +### Error codes + +| HTTP | `error` code | When | +|---|---|---| +| 400 | `bad_request` | Malformed body, missing required field, invalid enum value | +| 401 | `unauthorized` | No `Authorization: Bearer` header | +| 403 | `forbidden` | Wrong token, revoked scoped key, role doesn't grant required scope | +| 404 | `not_found` | Resource id doesn't exist | +| 409 | `conflict` | Slug collision, delete-with-references blocked, etc. | +| 402 | `tier_cap` | Operator's self-tier doesn't include the required entitlement | +| 429 | `rate_limited` | Rate limit hit (e.g. /v1/recover, /v1/validate) | +| 502 | `upstream_error` | BTCPay / Zaprite call failed | +| 503 | `service_unavailable` / `btcpay_not_configured` | Provider not yet connected | +| 500 | `internal_error` | Bug. Includes a trace id in logs; report it. | + +### Validate response + +`POST /v1/validate` is the one endpoint that returns 200 in all cases. Inspect +`ok` + `reason`: + +| `reason` | Meaning | +|---|---| +| `bad_signature` | Signature doesn't verify against the trust-root pubkey | +| `not_found` | License key not in the daemon's DB | +| `revoked` | Operator revoked it | +| `suspended` | Operator suspended it (reversible) | +| `expired` | Past `expires_at` | +| `fingerprint_mismatch` | Different machine than the one bound on first activate | +| `product_mismatch` | License is for a different product than the caller asserted | +| `machine_cap_exceeded` | Activating this fingerprint would exceed `max_machines` | + +--- + +## Common workflows + +### Issue a comp license + +```bash +curl -X POST $KS/v1/admin/licenses \ + -H "Authorization: Bearer ks_..." \ + -H "Content-Type: application/json" \ + -d '{ + "product_slug": "recap", + "policy_slug": "pro", + "buyer_email": "alice@example.com", + "buyer_note": "Conference speaker comp" + }' +``` + +Returns the issued license object including `license_key`. The buyer pastes +the key into their app; subsequent validate calls return `ok: true` with the +policy's entitlements. + +Scope required: `licenses:write` (any role except `read-only`). + +### Revoke a license + +```bash +curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/revoke \ + -H "Authorization: Bearer ks_..." \ + -H "Content-Type: application/json" \ + -d '{"reason":"refund issued"}' +``` + +Idempotent. The next online validate from the buyer's app returns `reason: revoked`. + +Scope required: `licenses:write`. + +### Find a license by email + +```bash +curl "$KS/v1/admin/licenses?buyer_email=alice@example.com" \ + -H "Authorization: Bearer ks_..." +``` + +Returns matching licenses (without the `license_key` field — that's only +returned on issue / recover). Use the `id` for follow-up operations. + +Scope required: `licenses:read`. + +### Cancel a buyer's subscription + +```bash +# Look up the subscription id first (filter by license_id if you have it) +curl "$KS/v1/admin/subscriptions?status=active" \ + -H "Authorization: Bearer ks_..." + +# Then cancel +curl -X POST $KS/v1/admin/subscriptions/$SUB_ID/cancel \ + -H "Authorization: Bearer ks_..." \ + -d '{"reason":"buyer requested"}' +``` + +License stays valid through the current cycle's `expires_at`. Renewal worker +stops issuing new invoices. + +Scope required: `subscriptions:write`. + +### Free a machine seat + +```bash +curl -X POST $KS/v1/admin/machines/$MACHINE_ID/deactivate \ + -H "Authorization: Bearer ks_..." \ + -d '{"reason":"buyer moved devices"}' +``` + +The seat opens up. The buyer's next validate from any machine takes the +freed seat. + +Scope required: `machines:write`. + +### Programmatic tier change (comp upgrade) + +```bash +curl -X POST $KS/v1/admin/licenses/$LICENSE_ID/change-tier \ + -H "Authorization: Bearer ks_..." \ + -d '{ + "target_policy_slug": "pro", + "reason": "support resolution" + }' +``` + +Always applies as comp (no invoice) from the admin path. Buyer-initiated +paid upgrades go through `/v1/upgrade` (different endpoint, signed-license auth). + +Scope required: `licenses:write`. + +--- + +## Webhooks — react to events instead of polling + +Configure webhook endpoints in admin UI → Webhooks. The daemon POSTs JSON +payloads, HMAC-SHA256 signed with the endpoint's secret, on these events: + +| Event | Fires on | +|---|---| +| `license.issued` | New license minted (purchase, comp, redeem) | +| `license.revoked` / `license.suspended` / `license.unsuspended` | Admin operations | +| `license.tier_changed` | Tier upgrade/downgrade applied | +| `invoice.paid` | A BTCPay/Zaprite invoice settled | +| `subscription.renewal_pending` | Renewal worker created a fresh invoice | +| `subscription.renewal_skipped` | Renewal skipped (e.g. policy archived) | +| `subscription.cancelled` | Buyer or admin cancelled | +| `subscription.lapsed` | Past_due grace expired | +| `machine.activated` | First validate from a new fingerprint | + +Verify signatures: + +```python +import hmac, hashlib + +def verify(body_bytes: bytes, signature_header: str, secret: str) -> bool: + expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature_header) +``` + +The header is `X-Keysat-Signature`. Failed deliveries retry with exponential +backoff up to 10 attempts; permanently-failed deliveries land in the DLQ +visible at admin UI → Webhooks → Failed. + +--- + +## Designing a robust agent + +A few patterns that work well in practice. + +### Idempotency + +The daemon's mutation endpoints are idempotent where they can be. Revoke, +suspend, unsuspend, archive, unarchive, subscription cancel — all return +success on the second call without changing state. Your agent can safely +retry on network errors. + +### Pagination + +List endpoints return up to ~100 rows by default. Use `?limit=N` and +`?offset=N` for larger result sets. The OpenAPI spec documents the limits +per endpoint. + +### Rate limits + +The admin endpoints have no per-IP rate limit today — operators are trusted. +The public endpoints (`/v1/validate`, `/v1/recover`) are rate-limited per +client IP (10/min for /recover; /validate is unlimited but a reasonable +agent calls it once per app boot + once per hour). + +### Master key handling + +If your automation needs `full-admin` because it touches operator-only +operations (creating other API keys, changing payment providers), use the +master key from a secret manager. If it can stay within license / product / +policy operations, **always use a scoped key**. Operators can revoke a +compromised scoped key without rotating the master credential. + +### Backoff on 5xx + +`internal_error` (500) is a bug or a transient DB lock. Retry with exponential +backoff (1s, 2s, 4s, 8s, give up). Don't retry on 4xx — those are deterministic +client errors. + +--- + +## Concrete agent recipe — "Comp a license to anyone who emails support@" + +```python +import os, requests, imaplib, email + +KS = os.environ["KEYSAT_URL"] +TOKEN = os.environ["KEYSAT_API_KEY"] # license-issuer-scoped key + +def issue_comp_license(buyer_email: str, product_slug: str, reason: str) -> str: + r = requests.post( + f"{KS}/v1/admin/licenses", + headers={"Authorization": f"Bearer {TOKEN}"}, + json={ + "product_slug": product_slug, + "policy_slug": "default", + "buyer_email": buyer_email, + "buyer_note": reason, + }, + timeout=10, + ) + r.raise_for_status() + return r.json()["license_key"] + +# Poll IMAP, parse incoming requests, call issue_comp_license, reply with the key +``` + +That's the entire pattern. The agent doesn't need full admin — just the +license-issuer role. If it ever gets compromised, you revoke the scoped key +in the admin UI and generate a new one in 30 seconds. + +--- + +## What's NOT exposed to agents + +Some operations are deliberately operator-only and not accessible to any +scoped key, including `full-admin`: + +- Generating / revoking scoped API keys (`/v1/admin/api-keys`) +- Connecting / disconnecting payment providers +- Setting the operator name +- Activating the self-license (`/v1/admin/self-license`) +- Resetting the analytics install_uuid +- Changing the web UI password (StartOS Action only) + +These all require the master `KEYSAT_ADMIN_API_KEY`. The reasoning: an agent +that can rotate its own credentials, connect arbitrary payment processors, or +change the operator identity is no longer bounded by the role it was given. + +--- + +## Help us improve this guide + +The OpenAPI spec is the source of truth for the API surface. This guide is a +hand-curated overlay focused on the workflows we've seen agents actually need. +If you're building something the spec covers but this guide doesn't make +obvious, open an issue at github.com/keysat-xyz/keysat with the workflow +shape and we'll add it. diff --git a/KEYSAT_INTEGRATION.md b/KEYSAT_INTEGRATION.md index bb39b5b..b28d4b9 100644 --- a/KEYSAT_INTEGRATION.md +++ b/KEYSAT_INTEGRATION.md @@ -1218,9 +1218,9 @@ function entitlementLabel(slug: string): string { } ``` -If the operator hasn't defined a catalog (legacy "free-text" mode), -the array is empty and you fall back to rendering the raw slugs — -or replacing underscores with spaces yourself for a quick polish. +If the operator hasn't defined a catalog (free-text mode), the array +is empty and you fall back to rendering the raw slugs — or replacing +underscores with spaces yourself for a quick polish. **Catalog stability rule**: once you ship gating logic that checks for entitlement `"export"`, the operator's catalog and policy @@ -1291,6 +1291,71 @@ operator's licensing server being up. --- +## 9a. Cross-product safety — read this if the operator sells more than one product + +Many operators run a single Keysat instance that issues licenses for multiple +products (e.g. one Keysat serves both Recap and Notewise). All of those +licenses are signed by the **same Ed25519 keypair**. Without the right check +in your app, a license issued for Recap would parse + signature-verify +successfully inside Notewise — same public key, valid signature. That would +be a real bug, not a theoretical one. + +**The protection exists, but it's your job to use it.** The LIC1 payload +includes a signed `product_slug` field. Recap's licenses literally carry +`"product_slug": "recap"` inside the signed bytes; Notewise's carry +`"product_slug": "notewise"`. The signature covers those bytes, so the +buyer can't tamper with them — but the SDK won't reject a wrong-product +license unless you tell it which product you are. + +### Rule + +- **Online validation:** always pass `product_slug` to `client.validate(...)`. + The daemon enforces it and returns `reason: 'product_mismatch'` on mismatch. +- **Offline verify:** always assert `payload.product_slug === MY_PRODUCT_SLUG` + after `parseAndVerify(...)`. The SDK does not do this for you. + +### Concrete pattern (TypeScript) + +```ts +const MY_PRODUCT_SLUG = 'recap' // hard-code; matches what the operator picked + +// Online — daemon enforces product_slug for you +const r = await client.validate(licenseKey, MY_PRODUCT_SLUG, machineFingerprint) +if (!r.ok) { + // r.reason === 'product_mismatch' if a Notewise license was presented + reject(r.reason) + return +} + +// Offline — you must check yourself +const payload = parseAndVerify(licenseKey, EMBEDDED_PUBKEY_PEM) +if (payload.product_slug !== MY_PRODUCT_SLUG) { + reject('product_mismatch') + return +} +``` + +Same shape in Python / Rust / Go: pass `product_slug` to `validate`, +check `payload.product_slug` after `parse_and_verify`. Every SDK exposes +the field on the parsed payload object. + +### Why the SDK doesn't auto-reject offline + +`ParseAndVerify` is intentionally low-level — it returns the verified +payload and lets the caller decide what to enforce. A multi-product app +(unusual but possible) might legitimately accept any product the operator +signed for; a per-product app must reject mismatches. Making this opt-in +keeps the SDK honest about what it's checking on your behalf. + +### Forgetting to check is a silent failure + +If you call `parseAndVerify` without asserting the product, a license +from any of the operator's products will signature-verify and you'll +treat it as valid. There is no warning. **Make the check a constant +in your app and assert it on every code path that loads a license.** + +--- + ## 10. Fingerprint binding (for `validate()`) When you call `client.validate(...)`, the third argument is a machine @@ -1720,6 +1785,12 @@ ship it. against slug `bar`. Typos in the slug constant cause "license valid but my code rejects it" head-scratchers. Read the slug from a single constant. +- **Not asserting `product_slug` after offline verify.** `ParseAndVerify` + checks the signature, not the product. If the operator sells multiple + products from the same Keysat, every product's licenses share the + signing key — a license for Product A will signature-verify inside + Product B's app. Always assert `payload.product_slug === MY_PRODUCT_SLUG` + after the parse. See §9a for the full pattern. - **Logging the full license key.** It's a bearer credential — log the `license_id` instead. - **Refusing to start without a license.** Boot in unlicensed mode and diff --git a/licensing-service/docs/INTEGRATION.md b/licensing-service/docs/INTEGRATION.md index 5251239..480227c 100644 --- a/licensing-service/docs/INTEGRATION.md +++ b/licensing-service/docs/INTEGRATION.md @@ -219,4 +219,4 @@ Whatever you pick, hash it before sending if you want to avoid exposing the unde ## Tor / `.onion` support -Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-paid and privacy-adjacent. +Since licensing-service runs on Start9, it automatically gets a Tor `.onion` address. If you ship a Tor transport in your client, you get censorship-resistant validation for free, which is particularly valuable given the whole stack is Bitcoin-native and privacy-adjacent. diff --git a/licensing-service/migrations/0015_policy_archive.sql b/licensing-service/migrations/0015_policy_archive.sql new file mode 100644 index 0000000..c6781c8 --- /dev/null +++ b/licensing-service/migrations/0015_policy_archive.sql @@ -0,0 +1,19 @@ +-- Migration 0015: policies.archived_at +-- +-- Adds a soft-archive flag to policies. An archived policy is hidden +-- from the admin grid (unless the operator opts to show archived) and +-- from the public /buy/ page. Existing licenses keep validating +-- because their entitlements are signed into the LIC1 payload; the +-- policy row is not consulted at validate time. Active recurring +-- subscriptions tied to an archived policy stop renewing — the renewal +-- worker treats archived as a hard stop and surfaces a clear event. +-- +-- Why a column instead of a status TEXT enum? Policies already have +-- two boolean toggles (active, public). A nullable timestamp is the +-- minimum-information shape: NULL = live, timestamp = when archived. +-- Useful for sorting "Archived (most recent first)" without an extra +-- column. + +ALTER TABLE policies ADD COLUMN archived_at TEXT NULL; + +CREATE INDEX IF NOT EXISTS idx_policies_archived_at ON policies(archived_at); diff --git a/licensing-service/migrations/0016_scoped_api_keys.sql b/licensing-service/migrations/0016_scoped_api_keys.sql new file mode 100644 index 0000000..22d335d --- /dev/null +++ b/licensing-service/migrations/0016_scoped_api_keys.sql @@ -0,0 +1,37 @@ +-- Migration 0016: scoped API keys +-- +-- The master `admin_api_key` (set in config) is a full-access credential; +-- it's the right thing for the operator to hold but the wrong thing to +-- hand to an agent or a third-party tool. This table stores additional +-- API keys with operator-chosen roles that bound what they can do. +-- +-- Storage model: +-- - `token_hash` is the sha256 of the raw token. We NEVER store the +-- raw token after generation — the create endpoint returns it once +-- in the response body and that's the operator's only chance to +-- copy it. +-- - `role` is a string tag (read-only | license-issuer | support | +-- full-admin). Scope sets per role are computed at auth time, not +-- stored here — that way we can extend the scope mapping without a +-- migration. +-- - `revoked_at` flips this row from valid → permanently rejected. +-- Soft-revoke rather than DELETE so the audit log keeps a stable +-- reference and the operator can see "this key existed but is +-- gone now" in the admin UI. +-- - `last_used_at` is touched best-effort on every successful auth. +-- Bounded write rate (one update per minute per key) is the next +-- iteration; for v1 we update every call. + +CREATE TABLE IF NOT EXISTS scoped_api_keys ( + 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', 'full-admin')) +); + +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/analytics.rs b/licensing-service/src/analytics.rs index 21aaf20..2f0bdd4 100644 --- a/licensing-service/src/analytics.rs +++ b/licensing-service/src/analytics.rs @@ -14,7 +14,7 @@ //! identity, store id, or any user-supplied value. Resetting //! analytics opt-in regenerates it. //! - `daemon_version` — e.g. `"0.1.0:46"`. -//! - `tier` — `"unlicensed" | "creator" | "pro" | "patron"`. +//! - `tier` — `"creator" | "pro" | "patron"`. //! - `counts` — rounded down to the nearest 5 to prevent //! fingerprinting an operator by exact license count. //! - `uptime_seconds` — bucketed to "<1d" / "1-7d" / "1-4w" / ">4w". diff --git a/licensing-service/src/api/api_keys.rs b/licensing-service/src/api/api_keys.rs new file mode 100644 index 0000000..b2e7b46 --- /dev/null +++ b/licensing-service/src/api/api_keys.rs @@ -0,0 +1,342 @@ +//! Scoped API keys — additional API keys with bounded permissions. +//! +//! Master credential is the env-configured `admin_api_key` (full access). +//! These scoped keys exist so operators can grant an agent / bot / partner +//! script a credential that does only what it needs to. Operator-friendly +//! flow: +//! +//! 1. Operator generates a new key in Settings → API keys, picks a role +//! from a fixed list (Read-only / License issuer / Support / Full admin). +//! 2. UI 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. +//! Endpoints that have been scope-wired check the agent's role +//! grants the required scope; if not, 403. +//! 4. Operator can revoke any key from the same UI; revoked tokens +//! stop working immediately. +//! +//! The master `admin_api_key` always works on every endpoint. Scoped keys +//! work only on endpoints that have been migrated to call `require_scope` +//! instead of `require_admin`. Endpoints not yet migrated reject scoped +//! keys with 403 — secure-by-default. + +use crate::api::admin::{request_context, require_admin}; +use crate::api::AppState; +use crate::db::repo; +use crate::error::{AppError, AppResult}; +use axum::{ + extract::{Path, State}, + http::{header, HeaderMap}, + Json, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +/// Roles an operator can grant to a scoped API key. +/// +/// Each role expands to a static set of scopes at auth time. Adding a +/// new role requires a migration check-constraint update plus a new arm +/// here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// Every `:read` scope. Cannot mutate anything. + ReadOnly, + /// Read-only + license writes. Can issue / revoke / suspend / change + /// tier on licenses, but can't touch products, policies, or codes. + /// Right shape for an automation that gives out comp licenses. + LicenseIssuer, + /// License-issuer + subscription cancellation + machine deactivation. + /// Right shape for a customer-support agent that resolves common + /// requests without touching catalog or settings. + Support, + /// 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. + FullAdmin, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::ReadOnly => "read-only", + Role::LicenseIssuer => "license-issuer", + Role::Support => "support", + Role::FullAdmin => "full-admin", + } + } + pub fn parse(s: &str) -> Option { + match s { + "read-only" => Some(Role::ReadOnly), + "license-issuer" => Some(Role::LicenseIssuer), + "support" => Some(Role::Support), + "full-admin" => Some(Role::FullAdmin), + _ => None, + } + } + /// Returns true if this role grants the named scope. Scope names are + /// `:`, e.g. `licenses:write`. + pub fn grants(self, scope: &str) -> bool { + match self { + Role::FullAdmin => true, + Role::ReadOnly => scope.ends_with(":read"), + Role::LicenseIssuer => { + scope.ends_with(":read") + || matches!(scope, "licenses:write") + } + Role::Support => { + scope.ends_with(":read") + || matches!( + scope, + "licenses:write" + | "subscriptions:write" + | "machines:write" + ) + } + } + } +} + +/// Verify the request carries a credential that grants the named scope. +/// Order of acceptance: +/// 1. Master `admin_api_key` — always passes. +/// 2. Scoped API key whose role grants `scope`. +/// +/// Returns the actor hash (sha256 of the token) for audit purposes. On +/// failure, 401 if no bearer header, 403 if the token is wrong or lacks +/// the scope. +pub async fn require_scope( + state: &AppState, + headers: &HeaderMap, + scope: &str, +) -> AppResult { + let header_val = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + let token = header_val + .strip_prefix("Bearer ") + .ok_or(AppError::Unauthorized)?; + + // Master admin key — constant-time compare against the configured value. + if bool::from( + token + .as_bytes() + .ct_eq(state.config.admin_api_key.as_bytes()), + ) { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + return Ok(hex::encode(hasher.finalize())); + } + + // Scoped API key — hash the candidate, look up, verify not revoked, + // confirm role grants the scope. + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + let row: Option<(String, String, Option)> = sqlx::query_as( + "SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?", + ) + .bind(&token_hash) + .fetch_optional(&state.db) + .await?; + + let (key_id, role_str, revoked_at) = match row { + Some(r) => r, + None => return Err(AppError::Forbidden), + }; + if revoked_at.is_some() { + return Err(AppError::Forbidden); + } + let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?; + if !role.grants(scope) { + return Err(AppError::Forbidden); + } + + // Best-effort touch. Ignored on failure (clock skew, lock contention). + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?") + .bind(&now) + .bind(&key_id) + .execute(&state.db) + .await; + + Ok(token_hash) +} + +// ---------- CRUD endpoints (gated on master admin only) ---------- + +#[derive(Debug, Deserialize)] +pub struct CreateApiKeyReq { + pub label: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +pub struct CreateApiKeyResp { + pub id: String, + pub label: String, + pub role: String, + pub created_at: String, + /// The raw token. Returned ONCE on create and never again — operator + /// must copy it now or generate a new key. + pub token: String, +} + +/// `POST /v1/admin/api-keys` — generate a new scoped key. Master-only. +pub async fn create( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + + let label = req.label.trim(); + if label.is_empty() || label.len() > 80 { + return Err(AppError::BadRequest( + "label is required and must be at most 80 characters".into(), + )); + } + 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(), + ) + })?; + + // 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars. + // Prefix `ks_` so it's recognizable in logs as a Keysat-style token. + use rand::RngCore; + let mut raw = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut raw); + let token = format!( + "ks_{}", + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, raw) + ); + + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let token_hash = hex::encode(hasher.finalize()); + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(label) + .bind(&token_hash) + .bind(role.as_str()) + .bind(&now) + .execute(&state.db) + .await?; + + let _ = repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "api_key.create", + Some("api_key"), + Some(&id), + ip.as_deref(), + ua.as_deref(), + &json!({ "label": label, "role": role.as_str() }), + ) + .await; + + Ok(Json(CreateApiKeyResp { + id, + label: label.to_string(), + role: role.as_str().to_string(), + created_at: now, + token, + })) +} + +#[derive(Debug, Serialize)] +pub struct ApiKeyListEntry { + pub id: String, + pub label: String, + pub role: String, + pub created_at: String, + pub last_used_at: Option, + pub revoked_at: Option, +} + +/// `GET /v1/admin/api-keys` — list every key (active + revoked). Master-only. +/// Never returns the raw token — only metadata. +pub async fn list( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + require_admin(&state, &headers)?; + let rows: Vec<(String, String, String, String, Option, Option)> = + sqlx::query_as( + "SELECT id, label, role, created_at, last_used_at, revoked_at + FROM scoped_api_keys ORDER BY created_at DESC", + ) + .fetch_all(&state.db) + .await?; + let out: Vec = rows + .into_iter() + .map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry { + id, + label, + role, + created_at, + last_used_at, + revoked_at, + }) + .collect(); + Ok(Json(json!({ "api_keys": out }))) +} + +/// `DELETE /v1/admin/api-keys/:id` — soft-revoke. Master-only. Idempotent: +/// revoking an already-revoked key returns ok with no state change. +pub async fn revoke( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + let now = Utc::now().to_rfc3339(); + let rows = sqlx::query( + "UPDATE scoped_api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL", + ) + .bind(&now) + .bind(&id) + .execute(&state.db) + .await? + .rows_affected(); + if rows == 0 { + // Either not found, or already revoked. Distinguish for the response. + let exists: Option = sqlx::query_scalar("SELECT 1 FROM scoped_api_keys WHERE id = ?") + .bind(&id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound(format!("api_key '{id}'"))); + } + return Ok(Json(json!({ "ok": true, "already_revoked": true }))); + } + + let _ = repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "api_key.revoke", + Some("api_key"), + Some(&id), + ip.as_deref(), + ua.as_deref(), + &json!({}), + ) + .await; + Ok(Json(json!({ "ok": true, "revoked_at": now }))) +} diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 5f36a52..670d648 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -532,7 +532,7 @@ footer.kfooter a:hover {{ color:var(--navy-900); }}
- Powered by Keysat · Bitcoin-paid software licensing + Powered by Keysat · Bitcoin-native self-hosted software licensing