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:
@@ -0,0 +1,102 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, from both ends.
|
||||
#
|
||||
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
|
||||
# The person's end is #approve and #deny, which the *host* calls from its own page —
|
||||
# approving needs a session and a logged-in human, and the engine has neither.
|
||||
class DeviceGrantService
|
||||
class NotConfigured < StandardError; end
|
||||
class UnknownCode < StandardError; end
|
||||
|
||||
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
|
||||
# authenticate as yet, which is the whole reason this flow exists.
|
||||
def request(client_name:)
|
||||
ensure_configured!
|
||||
|
||||
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
|
||||
end
|
||||
|
||||
# The client polls with the device code. Returns [state, token], where the token is
|
||||
# the plain string and is available exactly once — on the poll that finds the grant
|
||||
# newly approved. A second poll gets :approved with no token, which is the honest
|
||||
# answer: the secret was handed over and is not kept.
|
||||
def poll(device_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_by(device_code: device_code.to_s)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
return [ grant.state, nil ] unless grant.state == :approved
|
||||
|
||||
# Read once, then gone: the column exists only to carry the secret across the gap
|
||||
# between the browser that approved it and the client that is polling for it.
|
||||
plain = grant.issued_token
|
||||
grant.update_columns(issued_token: nil) if plain.present?
|
||||
[ :approved, plain ]
|
||||
end
|
||||
|
||||
# The host's approval page calls this with the code a person typed and the subject
|
||||
# they are signed in as. Issuing the token here rather than on the next poll keeps
|
||||
# the decision and its consequence in one transaction.
|
||||
def approve(user_code:, subject:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
token = WarpEngine::ApplicationToken.create!(
|
||||
name: grant.client_name.presence || "Client",
|
||||
owner_type: WarpEngine.config.access_token_owner_class,
|
||||
owner_id: subject.id,
|
||||
scopes: [ WarpEngine::ApplicationToken::CATALOG_SCOPE ]
|
||||
)
|
||||
grant.update!(
|
||||
subject_type: WarpEngine.config.access_token_owner_class,
|
||||
subject_id: subject.id,
|
||||
application_token: token,
|
||||
issued_token: token.plain_token,
|
||||
approved_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
grant
|
||||
end
|
||||
|
||||
def deny(user_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
grant.update!(denied_at: Time.current)
|
||||
grant
|
||||
end
|
||||
|
||||
# Signing out: the client throws its own token away. Revocation is a soft delete on
|
||||
# the token, so the audit trail of who signed in from where survives it.
|
||||
def revoke(token:)
|
||||
return false if token.nil?
|
||||
|
||||
token.revoke!
|
||||
true
|
||||
end
|
||||
|
||||
# Where a person goes to type the user code. A path is made absolute against the
|
||||
# request's own base, so a host that configured "/devices" does not have to know its
|
||||
# own hostname.
|
||||
def verification_url(base_url: nil)
|
||||
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
|
||||
return configured if configured.start_with?("http://", "https://")
|
||||
return configured if base_url.blank?
|
||||
|
||||
"#{base_url.to_s.chomp('/')}/#{configured.delete_prefix('/')}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_configured!
|
||||
raise NotConfigured unless WarpEngine.identity_configured?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,10 @@
|
||||
module WarpEngine
|
||||
class DownloadService
|
||||
# The access policy said no. The controller turns this into a 403 — distinct from
|
||||
# the nil that means "no such file", because telling a person their file is missing
|
||||
# when it is merely locked sends them looking for the wrong problem.
|
||||
class Denied < StandardError; end
|
||||
|
||||
def self.container_base
|
||||
WarpEngine.config.file_container_path
|
||||
end
|
||||
@@ -12,19 +17,28 @@ module WarpEngine
|
||||
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
|
||||
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
|
||||
# tehát változatlanul lemezről.
|
||||
def locate(path:, ip:, user_agent:, referer:)
|
||||
#
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
relative = path.to_s
|
||||
return nil unless storage.file?(relative)
|
||||
|
||||
log_download(relative, ip: ip, user_agent: user_agent, referer: referer)
|
||||
asset = find_asset(relative)
|
||||
grant = authorize!(asset, subject, request)
|
||||
|
||||
storage.locate(relative, filename: File.basename(relative))
|
||||
log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject)
|
||||
|
||||
storage.locate(relative,
|
||||
filename: grant.filename.presence || File.basename(relative),
|
||||
expires_in: grant.expires_in)
|
||||
end
|
||||
|
||||
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
|
||||
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
|
||||
def create(path:, ip:, user_agent:, referer:)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
|
||||
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
|
||||
subject: subject, request: request)
|
||||
return nil if location.nil?
|
||||
|
||||
location.file? ? location.path : nil
|
||||
@@ -36,18 +50,41 @@ module WarpEngine
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
def log_download(relative, ip:, user_agent:, referer:)
|
||||
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
|
||||
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
# A policy that refuses returns nil; one that raises is treated as a refusal too.
|
||||
# An artifact served because the gatekeeper crashed is the one failure mode this
|
||||
# engine must not have.
|
||||
def authorize!(asset, subject, request)
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
|
||||
raise Denied if grant.nil?
|
||||
|
||||
WarpEngine::Download.create!(
|
||||
grant
|
||||
rescue Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise Denied
|
||||
end
|
||||
|
||||
def find_asset(relative)
|
||||
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
|
||||
WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
end
|
||||
|
||||
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
|
||||
download = WarpEngine::Download.create!(
|
||||
file_path: relative,
|
||||
release: asset&.release,
|
||||
ip_address: ip,
|
||||
user_agent: user_agent&.truncate(500),
|
||||
referer: referer&.truncate(500)
|
||||
)
|
||||
|
||||
# The same seam the publish side has: a host that wants its own record of who
|
||||
# downloaded what subscribes rather than reaching into this class.
|
||||
ActiveSupport::Notifications.instrument("warp_engine.download",
|
||||
path: relative, asset: asset, release: asset&.release,
|
||||
software: asset&.release&.software, subject: subject, download: download, ip: ip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,16 @@ module WarpEngine
|
||||
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
|
||||
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
|
||||
# patchelné. Lásd WarpEngine::Storage.
|
||||
def show(input)
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
#
|
||||
# A hosted (browser) build reaches this path as hundreds of relative requests for
|
||||
# js, wasm and images, which is why the gate here is the policy's plain yes/no
|
||||
# rather than anything signed: there is nothing to sign per file. A host serving
|
||||
# gated web builds to browsers will usually want its own session-based route in
|
||||
# front of this one — a browser has a session, and a redirect to a login page is a
|
||||
# better answer there than a bare 403.
|
||||
def show(input, subject: nil)
|
||||
relative = input.path.to_s
|
||||
|
||||
if storage.directory?(relative)
|
||||
@@ -20,11 +29,34 @@ module WarpEngine
|
||||
|
||||
return FileResultDto.not_found unless storage.file?(relative)
|
||||
|
||||
authorize!(relative, subject)
|
||||
|
||||
to_result(storage.locate(relative, filename: File.basename(relative)))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Same rule and same failure mode as DownloadService: a policy that refuses or
|
||||
# raises means no file. An artifact served because the gatekeeper crashed is the
|
||||
# one failure mode this engine must not have.
|
||||
def authorize!(relative, subject)
|
||||
# The open policy authorises everything, and this path serves a browser build as
|
||||
# hundreds of requests for js, wasm and images. Asking it per file would mean a
|
||||
# LIKE query per asset for an answer that is always yes.
|
||||
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
|
||||
|
||||
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
|
||||
raise WarpEngine::DownloadService::Denied if grant.nil?
|
||||
|
||||
grant
|
||||
rescue WarpEngine::DownloadService::Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise WarpEngine::DownloadService::Denied
|
||||
end
|
||||
|
||||
def storage
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
@@ -2,15 +2,15 @@ module WarpEngine
|
||||
class SoftwareHighlightedService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index
|
||||
software = WarpEngine::Software.includes(:external_links, :software_images)
|
||||
def index(subject: nil)
|
||||
software = visible_scope(subject).includes(:external_links, :software_images)
|
||||
.where(highlighted: true)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
return nil unless software
|
||||
|
||||
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
|
||||
build_response(software, releases, download_counts_for(releases.map(&:id)))
|
||||
build_response(software, releases, download_counts_for(releases.map(&:id)), subject: subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ module WarpEngine
|
||||
module SoftwareResponseBuilder
|
||||
private
|
||||
|
||||
def build_response(software, releases, download_counts)
|
||||
def build_response(software, releases, download_counts, subject: nil)
|
||||
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
|
||||
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
||||
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
||||
@@ -14,10 +14,33 @@ module WarpEngine
|
||||
latest: latest,
|
||||
web_playable: web_playable,
|
||||
total_downloads: total_downloads,
|
||||
download_counts: download_counts
|
||||
download_counts: download_counts,
|
||||
access: access_for(software, subject)
|
||||
)
|
||||
end
|
||||
|
||||
# Always present, even under the open policy: a client should never have to tell
|
||||
# "this catalog says nothing about access" from "this title is not gated". One of
|
||||
# those is a question and the other is an answer.
|
||||
def access_for(software, subject)
|
||||
WarpEngine.access_policy.access_for(software: software, subject: subject)
|
||||
rescue StandardError => e
|
||||
# A policy that raises must not take the catalog down with it. The open answer is
|
||||
# wrong here, so the closed one is what a broken policy gets: a title nobody can
|
||||
# download is recoverable, a paid title handed out for free is not.
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
WarpEngine::Access.new(gated: true, entitled: false)
|
||||
end
|
||||
|
||||
# The titles this subject may see. A policy that raises empties the catalog rather
|
||||
# than leaking it — the safe direction — but it is still a bug, so it is logged.
|
||||
def visible_scope(subject)
|
||||
WarpEngine.access_policy.visible_software_scope(subject: subject)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
WarpEngine::Software.none
|
||||
end
|
||||
|
||||
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
|
||||
def download_counts_for(release_ids)
|
||||
return {} if release_ids.empty?
|
||||
|
||||
@@ -2,11 +2,16 @@ module WarpEngine
|
||||
class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
def index(owner_id: nil)
|
||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
||||
# `subject` is whoever the request authenticated as, or nil. It decides two things:
|
||||
# which titles are listed at all (the policy's scope) and what each one's `access`
|
||||
# block says about entitlement.
|
||||
def index(owner_id: nil, subject: nil)
|
||||
softwares = visible_scope(subject)
|
||||
.includes(releases: [ :release_assets ])
|
||||
.includes(:external_links, :software_images)
|
||||
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
|
||||
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
|
||||
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts, subject: subject) } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user