WarpEngine 0.5.0: a catalog that can say a title is not yours
A desktop client reading /api/software had no way to learn that a title costs money. There was nothing in the response to say so, no way to sign in, and no way to be told "you do not own this" — so a store with paid titles could only hand the client a 403 at download time and let it guess why. The fix belongs here rather than in the client. A client serves more than one store, so anything it knows about a particular one has to arrive from that store's own API; a rule compiled into the client is a rule that breaks every other catalog it reads. Three seams, each following the storage adapter's shape — documented contract, default that is byte for byte the old behaviour, one config key to replace it: - **access policy** — visible_software_scope / access_for / authorize_download. Every catalog entry now carries an `access` block (gated, entitled, price, purchaseUrl, webUrl) and both /api/download and /file/* ask before serving. The vocabulary is deliberately generic: a word from one host's domain would make every client that reads it specific to that host. - **client sign-in** — the device authorization grant (RFC 8628), over the host's own user model. The approval page stays the host's, because approving needs a session and HTML. Tokens are ApplicationTokens with a `catalog` scope, so publishing and reading stay separable. - **service descriptor** — GET /api/service says what this deployment is and whether it has a sign-in at all, which is how a client stops guessing. With no policy and no subject class configured — every deployment today — the API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and the 187 pre-existing examples pass untouched. A policy that raises is treated as a refusal, not permission. An artifact served because the gatekeeper crashed is the one failure mode this must not have, so a broken policy empties the catalog and denies the download. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,28 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
|
||||
t.index :deleted_at
|
||||
end
|
||||
|
||||
# Device sign-in for clients that have no browser of their own (RFC 8628).
|
||||
# Short-lived rows: one exists for the minute or two between "the client asked"
|
||||
# and "the person answered". Only used where access_token_owner_class is set.
|
||||
create_table :device_grants do |t|
|
||||
t.string :device_code, limit: 64, null: false
|
||||
t.string :user_code, limit: 16, null: false
|
||||
t.string :client_name, limit: 128
|
||||
t.string :subject_type, limit: 128
|
||||
t.bigint :subject_id
|
||||
t.bigint :application_token_id
|
||||
# The issued token in the clear, cleared on the poll that hands it over — see
|
||||
# the model. Everything else about a token is stored as a digest.
|
||||
t.string :issued_token, limit: 64
|
||||
t.datetime :approved_at, precision: 3
|
||||
t.datetime :denied_at, precision: 3
|
||||
t.datetime :expires_at, precision: 3, null: false
|
||||
t.timestamps precision: 3, null: true
|
||||
t.index :device_code, unique: true
|
||||
t.index :user_code, unique: true
|
||||
t.index :expires_at
|
||||
end
|
||||
|
||||
create_table :downloads do |t|
|
||||
t.string :file_path, null: false
|
||||
t.references :release, foreign_key: { on_delete: :nullify }, index: false
|
||||
|
||||
@@ -43,6 +43,26 @@ Rails.application.config.to_prepare do
|
||||
# softwares got an owner (backfill)!
|
||||
# c.enforce_software_ownership = true
|
||||
|
||||
# Who may see a title and who may download it. :open (the default) lists every
|
||||
# software and serves every artifact — the behaviour of a catalog nobody sells
|
||||
# from. A host that does sell supplies a policy answering three methods; see
|
||||
# WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid
|
||||
# title can announce itself as paid instead of failing at the download.
|
||||
# c.access_policy = MyStore::AccessPolicy.new
|
||||
|
||||
# Client sign-in. nil (default) means there is none: /api/auth/* is inactive and
|
||||
# GET /api/service reports auth: null, so a client offers no sign-in at all. Set
|
||||
# the class a *client* token belongs to — usually your user model — to turn on
|
||||
# the device authorization grant.
|
||||
# c.access_token_owner_class = "User"
|
||||
#
|
||||
# Your own page where a signed-in person types the code their client displayed.
|
||||
# It calls WarpEngine::DeviceGrantService#approve. A path is made absolute
|
||||
# against the request, so you need not know your own hostname (default "/devices").
|
||||
# c.identity_verification_url = "/devices"
|
||||
# c.device_code_ttl = 600 # seconds a pending code lives
|
||||
# c.device_code_interval = 5 # seconds a client is told to wait between polls
|
||||
|
||||
# If host models also reference catalog images, register them so the
|
||||
# admin Images page's orphan detection takes them into account:
|
||||
# c.image_owners = [
|
||||
|
||||
@@ -10,6 +10,7 @@ require "apipie-rails"
|
||||
require "warp_engine/version"
|
||||
require "warp_engine/configuration"
|
||||
require "warp_engine/storage"
|
||||
require "warp_engine/access"
|
||||
|
||||
module WarpEngine
|
||||
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
|
||||
@@ -42,6 +43,17 @@ module WarpEngine
|
||||
def self.storage
|
||||
Storage.adapter
|
||||
end
|
||||
|
||||
# Who may see and download what. :open by default — see WarpEngine::AccessPolicy.
|
||||
def self.access_policy
|
||||
AccessPolicy.current
|
||||
end
|
||||
|
||||
# The host has configured a subject class, so client sign-in is available. A client
|
||||
# asks GET /api/service rather than this, but the engine's own controllers need it.
|
||||
def self.identity_configured?
|
||||
config.access_token_owner_class.present?
|
||||
end
|
||||
end
|
||||
|
||||
require "warp_engine/engine"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
module WarpEngine
|
||||
# Who may see a title, and who may download it.
|
||||
#
|
||||
# Until now every catalog entry was public and every artifact was free: the API
|
||||
# listed all software and /api/download handed over any file it could find. That is
|
||||
# the right default for a catalog nobody sells from, and it stays the default — but a
|
||||
# host that does sell needs the engine to *say so*, because the clients reading this
|
||||
# API have no other way to learn it. A desktop client cannot know that a title is
|
||||
# paid; it can only be told.
|
||||
#
|
||||
# This module is that seam. The default policy is byte for byte the previous
|
||||
# behaviour, and a host swaps it on the configuration:
|
||||
#
|
||||
# c.access_policy = MyStorePolicy.new # or :open (default)
|
||||
#
|
||||
# A policy is any object answering to this contract:
|
||||
#
|
||||
# visible_software_scope(subject:) -> ActiveRecord::Relation
|
||||
# access_for(software:, subject:) -> WarpEngine::Access
|
||||
# authorize_download(asset:, subject:, request:) -> Access::Grant or nil
|
||||
#
|
||||
# `subject` is whoever the request authenticated as (see SubjectAuthentication), or
|
||||
# nil for an anonymous caller. It is deliberately untyped here: the engine has no user
|
||||
# model, and whose object this is belongs to the host.
|
||||
module AccessPolicy
|
||||
# Everything visible, everything open, no prices. The catalog as it always was.
|
||||
class Open
|
||||
def visible_software_scope(subject: nil)
|
||||
WarpEngine::Software.all
|
||||
end
|
||||
|
||||
def access_for(software:, subject: nil)
|
||||
Access::OPEN
|
||||
end
|
||||
|
||||
# An open catalog authorises every asset it can find. Returning a bare grant
|
||||
# rather than `true` keeps one return type across policies.
|
||||
def authorize_download(asset: nil, subject: nil, request: nil)
|
||||
Access::Grant::OPEN
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def current
|
||||
configured = WarpEngine.config.access_policy
|
||||
|
||||
case configured
|
||||
when nil, :open, "open" then open_policy
|
||||
else configured
|
||||
end
|
||||
end
|
||||
|
||||
def open? = current.is_a?(Open)
|
||||
|
||||
def open_policy
|
||||
@open_policy ||= Open.new
|
||||
end
|
||||
|
||||
# Tests and hosts that swap the configuration at runtime.
|
||||
def reset!
|
||||
@open_policy = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# What a client is told about one title's availability.
|
||||
#
|
||||
# The vocabulary is deliberately generic — `gated`, `entitled`, `price` — because
|
||||
# every client reading it serves more than one store. A word from any particular
|
||||
# host's domain ("product", "purchase order", "library") would make the client
|
||||
# that reads it specific to that host, which is exactly what this engine exists
|
||||
# to prevent.
|
||||
class Access
|
||||
# Where a hosted (browser) build is played, when the host serves it somewhere other
|
||||
# than the engine's own /file/ path. nil leaves the client with what it already
|
||||
# builds, which is the pre-existing behaviour.
|
||||
attr_reader :gated, :entitled, :price_cents, :currency, :purchase_url, :web_url
|
||||
|
||||
def initialize(gated: false, entitled: true, price_cents: nil, currency: nil,
|
||||
purchase_url: nil, web_url: nil)
|
||||
@gated = gated ? true : false
|
||||
@entitled = entitled
|
||||
@price_cents = price_cents
|
||||
@currency = currency
|
||||
@purchase_url = purchase_url
|
||||
@web_url = web_url
|
||||
end
|
||||
|
||||
# An open catalog's answer, and the shape every response carries even when no
|
||||
# policy is configured: a client should never have to tell "no access block" from
|
||||
# "not gated". One of those is a question, the other is an answer.
|
||||
OPEN = new.freeze
|
||||
|
||||
def as_json(*)
|
||||
{
|
||||
gated: gated,
|
||||
entitled: entitled.nil? ? nil : (entitled ? true : false),
|
||||
price: price_cents.nil? ? nil : { amountCents: price_cents, currency: currency },
|
||||
purchaseUrl: purchase_url,
|
||||
webUrl: web_url
|
||||
}
|
||||
end
|
||||
|
||||
# A download the policy allowed.
|
||||
#
|
||||
# `filename` and `expires_in` let a host override what the engine would otherwise
|
||||
# decide on its own; both nil means "you choose", which is what the open policy says.
|
||||
class Grant
|
||||
attr_reader :filename, :expires_in
|
||||
|
||||
def initialize(filename: nil, expires_in: nil)
|
||||
@filename = filename
|
||||
@expires_in = expires_in
|
||||
end
|
||||
|
||||
OPEN = new.freeze
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,22 @@ module WarpEngine
|
||||
# PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key).
|
||||
# With neither set, POST /build/config rejects every request.
|
||||
# ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url.
|
||||
# access_policy: who may see a title and who may download it.
|
||||
# :open (default) — every software listed, every artifact served, no prices:
|
||||
# byte for byte the previous behaviour;
|
||||
# any object — must answer visible_software_scope/access_for/
|
||||
# authorize_download, see WarpEngine::AccessPolicy.
|
||||
# access_token_owner_class: class name of the subject a *client* token belongs to
|
||||
# (e.g. "Accounts::User"). nil (default) means no client sign-in: /api/auth/* is
|
||||
# inactive and GET /api/service reports auth: null. Deliberately separate from
|
||||
# application_token_owner_class, which owns *publishing* tokens — a publisher and
|
||||
# a customer are rarely the same kind of thing.
|
||||
# identity_verification_url: the host's own page where a person approves a device
|
||||
# code. Path or absolute URL; nil falls back to "/devices". The page is the host's
|
||||
# because approving needs a session, a login and HTML — none of which is the
|
||||
# engine's business.
|
||||
# device_code_ttl / device_code_interval: how long a pending device code lives, and
|
||||
# how often a client is told to poll for it.
|
||||
# woodpecker_url / woodpecker_api_token / woodpecker_repo_owner:
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
|
||||
# All nil → the management features are inactive.
|
||||
@@ -44,7 +60,12 @@ module WarpEngine
|
||||
:woodpecker_api_token,
|
||||
:woodpecker_repo_owner,
|
||||
:image_owners,
|
||||
:storage_adapter
|
||||
:storage_adapter,
|
||||
:access_policy,
|
||||
:access_token_owner_class,
|
||||
:identity_verification_url,
|
||||
:device_code_ttl,
|
||||
:device_code_interval
|
||||
|
||||
def initialize
|
||||
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
@@ -63,6 +84,11 @@ module WarpEngine
|
||||
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
|
||||
@image_owners = []
|
||||
@storage_adapter = :local
|
||||
@access_policy = :open
|
||||
@access_token_owner_class = nil
|
||||
@identity_verification_url = nil
|
||||
@device_code_ttl = 600
|
||||
@device_code_interval = 5
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
VERSION = "0.4.0"
|
||||
VERSION = "0.5.0"
|
||||
|
||||
# The header every API response carries. Named here rather than written out at the one
|
||||
# place that sets it: clients read it, the README documents it, and a string in three
|
||||
|
||||
Reference in New Issue
Block a user