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:
@@ -5,7 +5,7 @@ software catalog: catalog models, a CI-pipeline-callable release updater, a
|
|||||||
public read-only JSON API, and optional ActiveAdmin resources that plug into
|
public read-only JSON API, and optional ActiveAdmin resources that plug into
|
||||||
your app's existing admin.
|
your app's existing admin.
|
||||||
|
|
||||||
Repository: `https://git.teletypegames.org/tools/warp_engine`
|
Repository: `https://git.teletypegames.org/engines/warp_engine`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@ Repository: `https://git.teletypegames.org/tools/warp_engine`
|
|||||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
||||||
a shared secret or by per-owner database tokens with expiry and scopes
|
a shared secret or by per-owner database tokens with expiry and scopes
|
||||||
(`ApplicationToken`, managed in the admin).
|
(`ApplicationToken`, managed in the admin).
|
||||||
|
- **Pluggable access**: a host supplies a policy and the catalog gains prices,
|
||||||
|
entitlements and gated downloads — and *says so* in its API, so clients can
|
||||||
|
show a paid title as paid instead of failing at the download. Default `:open`
|
||||||
|
is the catalog as it always was.
|
||||||
|
- **Client sign-in**: an RFC 8628 device authorization grant for clients with no
|
||||||
|
browser of their own, over the host's own user model. Off unless configured.
|
||||||
- **Pluggable storage**: artifacts are served through a storage adapter
|
- **Pluggable storage**: artifacts are served through a storage adapter
|
||||||
(`:local` by default); a host can serve them from an object store without
|
(`:local` by default); a host can serve them from an object store without
|
||||||
patching the engine.
|
patching the engine.
|
||||||
@@ -152,7 +158,7 @@ From the git repository:
|
|||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
# Gemfile
|
# Gemfile
|
||||||
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
|
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from the Forgejo rubygems registry (tagged releases):
|
Or from the Forgejo rubygems registry (tagged releases):
|
||||||
@@ -206,6 +212,15 @@ Rails.application.config.to_prepare do
|
|||||||
# after backfilling owners — ownerless softwares are claimable by anyone.
|
# after backfilling owners — ownerless softwares are claimable by anyone.
|
||||||
# c.enforce_software_ownership = true
|
# c.enforce_software_ownership = true
|
||||||
|
|
||||||
|
# Who may see a title and who may download it (see "Access" below).
|
||||||
|
# :open (default) lists everything and serves everything.
|
||||||
|
# c.access_policy = MyStore::AccessPolicy.new
|
||||||
|
|
||||||
|
# Client sign-in (see "Client sign-in" below). nil (default) means there is
|
||||||
|
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
|
||||||
|
# c.access_token_owner_class = "User"
|
||||||
|
# c.identity_verification_url = "/devices"
|
||||||
|
|
||||||
# If your app's own models reference catalog images, register them so the
|
# If your app's own models reference catalog images, register them so the
|
||||||
# admin Images page counts them as "in use":
|
# admin Images page counts them as "in use":
|
||||||
# c.image_owners = [
|
# c.image_owners = [
|
||||||
@@ -369,6 +384,91 @@ signing adapter turns them into redirects without any further change.
|
|||||||
the admin file manager — still writes to the local disk. A remote adapter
|
the admin file manager — still writes to the local disk. A remote adapter
|
||||||
needs its own upload path today.
|
needs its own upload path today.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Who may see a title, and who may download it. The default answers "everyone" to
|
||||||
|
both — every software listed, every artifact served, no prices — which is the
|
||||||
|
catalog the engine always had:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
c.access_policy = :open # default
|
||||||
|
```
|
||||||
|
|
||||||
|
A host that sells supplies a policy instead. The contract is three methods:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
class MyStore::AccessPolicy
|
||||||
|
# Which titles GET /api/software lists at all.
|
||||||
|
def visible_software_scope(subject: nil) = ... # an ActiveRecord scope
|
||||||
|
|
||||||
|
# What a client is told about one title.
|
||||||
|
def access_for(software:, subject: nil)
|
||||||
|
WarpEngine::Access.new(
|
||||||
|
gated: true, entitled: false, # needs an entitlement; this caller has none
|
||||||
|
price_cents: 1490, currency: "EUR",
|
||||||
|
purchase_url: "https://shop.example/games/slug",
|
||||||
|
web_url: "https://shop.example/play/slug" # nil keeps the engine's own /file/ path
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# nil refuses the download; a Grant allows it.
|
||||||
|
def authorize_download(asset:, subject:, request:) = WarpEngine::Access::Grant.new
|
||||||
|
end
|
||||||
|
|
||||||
|
c.access_policy = MyStore::AccessPolicy.new
|
||||||
|
```
|
||||||
|
|
||||||
|
`subject` is whoever the request authenticated as, or `nil` for an anonymous
|
||||||
|
caller — deliberately untyped, because the engine has no user model and whose
|
||||||
|
object this is belongs to the host.
|
||||||
|
|
||||||
|
Every catalog entry carries an `access` block, **including under the open
|
||||||
|
policy**, so a client never has to tell "this catalog says nothing" from "this
|
||||||
|
title is not gated":
|
||||||
|
|
||||||
|
```json
|
||||||
|
"access": { "gated": false, "entitled": true, "price": null,
|
||||||
|
"purchaseUrl": null, "webUrl": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
The vocabulary is generic on purpose. A client reads more than one store, and a
|
||||||
|
word from any one host's domain would make it specific to that host.
|
||||||
|
|
||||||
|
**A policy that raises is treated as a refusal**: an empty catalog and a denied
|
||||||
|
download, logged. An artifact served because the gatekeeper crashed is the one
|
||||||
|
failure mode this engine must not have.
|
||||||
|
|
||||||
|
## Client sign-in
|
||||||
|
|
||||||
|
A desktop client has no cookie jar and no browser session, so it cannot host a
|
||||||
|
login form without asking somebody to type a password into a window that is not
|
||||||
|
a browser. The engine implements the device authorization grant (RFC 8628)
|
||||||
|
instead — but only where a host has said whose tokens these are:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
c.access_token_owner_class = "User" # nil (default): no sign-in at all
|
||||||
|
c.identity_verification_url = "/devices" # your page where a person types the code
|
||||||
|
c.device_code_ttl = 600
|
||||||
|
c.device_code_interval = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
With `access_token_owner_class` unset, `/api/auth/*` answers 404 and
|
||||||
|
`GET /api/service` reports `auth: null`, so a client offers no sign-in.
|
||||||
|
|
||||||
|
The flow:
|
||||||
|
|
||||||
|
1. the client `POST`s `/api/auth/device` and shows the `userCode` it gets back;
|
||||||
|
2. the person opens `verificationUrl` in a browser and types that code;
|
||||||
|
3. **your page** calls `WarpEngine::DeviceGrantService#approve(user_code:, subject:)`
|
||||||
|
with the signed-in user — approving needs a session and HTML, neither of
|
||||||
|
which is the engine's business;
|
||||||
|
4. the client's next `POST /api/auth/device/token` carries the token away. It is
|
||||||
|
handed over exactly once and never stored in the clear afterwards.
|
||||||
|
|
||||||
|
The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as
|
||||||
|
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
|
||||||
|
and the admin lists both kinds of token and the sign-ins behind them.
|
||||||
|
|
||||||
## Publish events
|
## Publish events
|
||||||
|
|
||||||
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
|
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
|
||||||
@@ -387,17 +487,29 @@ end
|
|||||||
Hosts that must support older engine versions can feature-detect with
|
Hosts that must support older engine versions can feature-detect with
|
||||||
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
|
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
|
||||||
|
|
||||||
|
Downloads emit one too — `warp_engine.download`, with `path`, `asset`,
|
||||||
|
`release`, `software`, `subject` and the `Download` record — so a host can keep
|
||||||
|
its own account of who fetched what without reaching into `DownloadService`.
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
| Endpoint | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
|
| `GET /api/service` | What this deployment is: version, whether the catalog gates, and how to sign in (or that you cannot) |
|
||||||
|
| `GET /api/software` | Full catalog with releases, assets, links, download counts and an `access` block; `?owner_id=` filters to one publisher |
|
||||||
| `GET /api/software/highlighted` | The currently highlighted title |
|
| `GET /api/software/highlighted` | The currently highlighted title |
|
||||||
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
||||||
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
||||||
| `GET /api/image/:id` | Serves catalog images |
|
| `GET /api/image/:id` | Serves catalog images |
|
||||||
| `GET /api/download?path=` | Serves an artifact and logs a download record |
|
| `GET /api/download?path=` | Serves an artifact and logs a download record |
|
||||||
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
|
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
|
||||||
|
| `POST /api/auth/device` | Starts a device sign-in; returns the code pair (404 without a client identity) |
|
||||||
|
| `POST /api/auth/device/token` | Polls a device sign-in for its token |
|
||||||
|
| `DELETE /api/auth/token` | Revokes the bearer token on the request (signing out) |
|
||||||
|
|
||||||
|
Every read endpoint accepts an optional `Authorization: Bearer …`; none requires
|
||||||
|
one. What it changes is what the access policy is asked about — an anonymous
|
||||||
|
caller is a normal, supported caller.
|
||||||
|
|
||||||
### `WarpEngine-Version`
|
### `WarpEngine-Version`
|
||||||
|
|
||||||
@@ -406,7 +518,7 @@ client can branch on the engine's age without a round trip to ask:
|
|||||||
|
|
||||||
```
|
```
|
||||||
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
|
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
|
||||||
WarpEngine-Version: 0.4.0
|
WarpEngine-Version: 0.5.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Set before the action runs rather than after, which means an error response carries it
|
Set before the action runs rather than after, which means an error response carries it
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
|||||||
scope :all, default: true
|
scope :all, default: true
|
||||||
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||||
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
||||||
|
# Two kinds of token share this table: one publishes software, the other reads the
|
||||||
|
# catalog from somebody's desktop client. They are told apart by scope, and an admin
|
||||||
|
# looking for one is rarely looking for the other.
|
||||||
|
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
|
||||||
|
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
|
||||||
|
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
|
||||||
|
|
||||||
index do
|
index do
|
||||||
id_column
|
id_column
|
||||||
@@ -45,7 +51,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
|||||||
end
|
end
|
||||||
f.input :name
|
f.input :name
|
||||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
f.input :scopes_string, label: "Scopes (comma separated)",
|
||||||
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload.)
|
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload, the "catalog" scope for a client reading the API. Client tokens are normally issued by device sign-in rather than created here.)
|
||||||
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
|
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
|
||||||
f.input :expires_at, hint: "Leave empty for a token that never expires."
|
f.input :expires_at, hint: "Leave empty for a token that never expires."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
|
||||||
|
# Read-only on purpose. A grant is created by a client and answered by a person on the
|
||||||
|
# host's own page; an admin creating one by hand would be issuing somebody else a
|
||||||
|
# credential, which is not a thing this page should make easy.
|
||||||
|
actions :index, :show
|
||||||
|
|
||||||
|
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
|
||||||
|
if: proc { WarpEngine.identity_configured? }
|
||||||
|
|
||||||
|
config.sort_order = "created_at_desc"
|
||||||
|
config.batch_actions = false
|
||||||
|
|
||||||
|
scope :all, default: true
|
||||||
|
scope("Pending") { |scope| scope.where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||||
|
scope("Approved") { |scope| scope.where.not(approved_at: nil) }
|
||||||
|
scope("Denied") { |scope| scope.where.not(denied_at: nil) }
|
||||||
|
|
||||||
|
index do
|
||||||
|
id_column
|
||||||
|
column("Code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||||
|
column("Device") { |g| g.client_name }
|
||||||
|
column("State") { |g| status_tag g.state.to_s }
|
||||||
|
column("Who") do |g|
|
||||||
|
next "—" if g.subject_id.blank?
|
||||||
|
|
||||||
|
subject = g.subject
|
||||||
|
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||||
|
end
|
||||||
|
column :expires_at
|
||||||
|
column :created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
filter :client_name_cont, label: "Device"
|
||||||
|
filter :created_at
|
||||||
|
filter :expires_at
|
||||||
|
|
||||||
|
show do
|
||||||
|
attributes_table do
|
||||||
|
row("User code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||||
|
row("Device") { |g| g.client_name }
|
||||||
|
row("State") { |g| status_tag g.state.to_s }
|
||||||
|
row("Who") do |g|
|
||||||
|
next "—" if g.subject_id.blank?
|
||||||
|
|
||||||
|
subject = g.subject
|
||||||
|
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||||
|
end
|
||||||
|
# The device code itself is never shown: it is the client's live credential for as
|
||||||
|
# long as the grant is pending, and this page is not where it should leak from.
|
||||||
|
row("Token") do |g|
|
||||||
|
token = g.application_token
|
||||||
|
next "—" if token.nil?
|
||||||
|
|
||||||
|
link_to "#{token.token_prefix}… (#{token.name})", admin_application_token_path(token)
|
||||||
|
end
|
||||||
|
row :approved_at
|
||||||
|
row :denied_at
|
||||||
|
row :expires_at
|
||||||
|
row :created_at
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
module WarpEngine
|
||||||
|
# Who the caller is, on the read-only side of the API.
|
||||||
|
#
|
||||||
|
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
|
||||||
|
# pipeline write to the catalog", this one asks "whose library am I looking at". The
|
||||||
|
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
|
||||||
|
# and a catalog with no policy configured never needs one.
|
||||||
|
#
|
||||||
|
# The credential is a bearer token, because that is what a client can carry: it has no
|
||||||
|
# cookie jar and no browser session.
|
||||||
|
module SubjectAuthentication
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# The ApplicationToken behind the request, or nil.
|
||||||
|
def current_access_token
|
||||||
|
return @current_access_token if defined?(@current_access_token)
|
||||||
|
|
||||||
|
@current_access_token = resolve_access_token
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whoever that token belongs to — the host's own object. nil when the request is
|
||||||
|
# anonymous, when the token is unknown, or when no subject class is configured.
|
||||||
|
def current_subject
|
||||||
|
current_access_token&.owner
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_access_token
|
||||||
|
return nil unless WarpEngine.identity_configured?
|
||||||
|
|
||||||
|
token = bearer_token
|
||||||
|
return nil if token.blank?
|
||||||
|
|
||||||
|
record = WarpEngine::ApplicationToken.authenticate(
|
||||||
|
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
|
||||||
|
)
|
||||||
|
return nil if record.nil?
|
||||||
|
|
||||||
|
record.touch_last_used!
|
||||||
|
record
|
||||||
|
end
|
||||||
|
|
||||||
|
def bearer_token
|
||||||
|
header = request.headers["Authorization"].to_s
|
||||||
|
return nil unless header.start_with?("Bearer ")
|
||||||
|
|
||||||
|
header.delete_prefix("Bearer ").strip.presence
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
module WarpEngine
|
||||||
|
# The device authorization grant, client side (RFC 8628).
|
||||||
|
#
|
||||||
|
# Both actions are unauthenticated, and have to be: the whole point of the flow is
|
||||||
|
# that the caller has no credential yet. What protects it is that a device code is
|
||||||
|
# useless until a signed-in person approves it on the host's own page.
|
||||||
|
class Api::Auth::DevicesController < ApiController
|
||||||
|
before_action :ensure_identity_configured
|
||||||
|
|
||||||
|
resource_description do
|
||||||
|
short "Device sign-in"
|
||||||
|
end
|
||||||
|
|
||||||
|
api :POST, "/api/auth/device", "Start a device sign-in and get a code pair"
|
||||||
|
param :client_name, String, required: false, desc: "What to call this device in the person's account"
|
||||||
|
returns code: 200, desc: "The code pair and where to take it"
|
||||||
|
error code: 404, desc: "This deployment has no client sign-in"
|
||||||
|
def create
|
||||||
|
grant = service.request(client_name: params[:client_name])
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
deviceCode: grant.device_code,
|
||||||
|
userCode: grant.formatted_user_code,
|
||||||
|
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||||
|
interval: WarpEngine.config.device_code_interval.to_i,
|
||||||
|
expiresIn: (grant.expires_at - Time.current).to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
api :POST, "/api/auth/device/token", "Poll a device sign-in for its token"
|
||||||
|
param :device_code, String, required: true, desc: "The device code from POST /api/auth/device"
|
||||||
|
returns code: 200, desc: "state is one of pending, approved, denied, expired"
|
||||||
|
error code: 404, desc: "No such device code, or no client sign-in here"
|
||||||
|
def token
|
||||||
|
state, plain = service.poll(device_code: params[:device_code])
|
||||||
|
|
||||||
|
# The token rides on the one poll that finds the grant newly approved; a client
|
||||||
|
# that loses it starts the flow again. Keeping a plain token around to hand out
|
||||||
|
# twice would mean storing it, which is the thing this design avoids.
|
||||||
|
body = { state: state.to_s }
|
||||||
|
body[:token] = plain if plain.present?
|
||||||
|
render json: body
|
||||||
|
rescue WarpEngine::DeviceGrantService::UnknownCode
|
||||||
|
render json: { error: "Not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def service
|
||||||
|
@service ||= WarpEngine::DeviceGrantService.new
|
||||||
|
end
|
||||||
|
|
||||||
|
# A deployment with no configured subject class has no sign-in at all, and says so
|
||||||
|
# the same way GET /api/service does — by not offering it.
|
||||||
|
def ensure_identity_configured
|
||||||
|
return if WarpEngine.identity_configured?
|
||||||
|
|
||||||
|
render json: { error: "Not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
module WarpEngine
|
||||||
|
# Signing out: a client throws away its own token.
|
||||||
|
#
|
||||||
|
# Revocation is a soft delete on the ApplicationToken, so the record of which device
|
||||||
|
# signed in and when survives it. The person's own list of devices — where somebody
|
||||||
|
# revokes a token for a laptop they no longer have — is the host's page, because it
|
||||||
|
# needs a session and a browser.
|
||||||
|
class Api::Auth::TokensController < ApiController
|
||||||
|
resource_description do
|
||||||
|
short "Client tokens"
|
||||||
|
end
|
||||||
|
|
||||||
|
api :DELETE, "/api/auth/token", "Revoke the bearer token this request carries"
|
||||||
|
returns code: 204, desc: "Revoked"
|
||||||
|
error code: 401, desc: "No usable bearer token on the request"
|
||||||
|
def destroy
|
||||||
|
token = current_access_token
|
||||||
|
return head(:unauthorized) if token.nil?
|
||||||
|
|
||||||
|
WarpEngine::DeviceGrantService.new.revoke(token: token)
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,7 @@ module WarpEngine
|
|||||||
returns code: 200, desc: "File binary data"
|
returns code: 200, desc: "File binary data"
|
||||||
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
|
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
|
||||||
error code: 400, desc: "Path is blank"
|
error code: 400, desc: "Path is blank"
|
||||||
|
error code: 403, desc: "The access policy refused this caller"
|
||||||
error code: 404, desc: "File not found"
|
error code: 404, desc: "File not found"
|
||||||
def show
|
def show
|
||||||
path = params[:path]
|
path = params[:path]
|
||||||
@@ -19,7 +20,9 @@ module WarpEngine
|
|||||||
path: path,
|
path: path,
|
||||||
ip: request.remote_ip,
|
ip: request.remote_ip,
|
||||||
user_agent: request.user_agent,
|
user_agent: request.user_agent,
|
||||||
referer: request.referer
|
referer: request.referer,
|
||||||
|
subject: current_subject,
|
||||||
|
request: request
|
||||||
)
|
)
|
||||||
|
|
||||||
if location.nil?
|
if location.nil?
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
module WarpEngine
|
||||||
|
# What this deployment is and what it can do, in one unauthenticated request.
|
||||||
|
#
|
||||||
|
# This is how a client stops guessing. Before it existed, everything a client knew
|
||||||
|
# about a store was compiled into the client — which endpoints to call, whether
|
||||||
|
# signing in was a thing here, where to send somebody who wanted to buy something.
|
||||||
|
# Every one of those is a property of the *server*, and a client that carries them
|
||||||
|
# can only ever serve the one store it was built for.
|
||||||
|
class Api::ServiceController < ApiController
|
||||||
|
resource_description do
|
||||||
|
short "Service descriptor"
|
||||||
|
end
|
||||||
|
|
||||||
|
api :GET, "/api/service", "What this WarpEngine deployment offers"
|
||||||
|
desc <<~DESC
|
||||||
|
Public on purpose: a client reads this *before* it can have a credential.
|
||||||
|
|
||||||
|
`auth` is null where the host has configured no client identity — the catalog is
|
||||||
|
open, there is nobody to sign in as, and a client should not offer to. Where it is
|
||||||
|
present, `auth.device` describes the device authorization grant a client with no
|
||||||
|
browser of its own uses to sign in.
|
||||||
|
|
||||||
|
`catalog.gated` says whether any title here can require an entitlement. A client
|
||||||
|
can render a store that never gates differently from one that sometimes does,
|
||||||
|
without having to read the whole catalog first to find out.
|
||||||
|
DESC
|
||||||
|
returns code: 200, desc: "The descriptor" do
|
||||||
|
property :engine, String, desc: "Always 'warp_engine'"
|
||||||
|
property :version, String, desc: "Engine version, same value as the WarpEngine-Version header"
|
||||||
|
property :catalog, Hash, desc: "Catalog properties" do
|
||||||
|
property :gated, :boolean, desc: "Whether titles here can require an entitlement"
|
||||||
|
end
|
||||||
|
property :auth, Hash, desc: "How to sign in, or null where there is no sign-in"
|
||||||
|
end
|
||||||
|
def show
|
||||||
|
render json: {
|
||||||
|
engine: "warp_engine",
|
||||||
|
version: WarpEngine::VERSION,
|
||||||
|
catalog: { gated: !WarpEngine::AccessPolicy.open? },
|
||||||
|
auth: auth_descriptor
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def auth_descriptor
|
||||||
|
return nil unless WarpEngine.identity_configured?
|
||||||
|
|
||||||
|
service = WarpEngine::DeviceGrantService.new
|
||||||
|
{
|
||||||
|
schemes: [ "bearer" ],
|
||||||
|
device: {
|
||||||
|
authorizeUrl: warp_engine.api_auth_device_url,
|
||||||
|
tokenUrl: warp_engine.api_auth_device_token_url,
|
||||||
|
revokeUrl: warp_engine.api_auth_token_url,
|
||||||
|
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||||
|
interval: WarpEngine.config.device_code_interval.to_i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -56,7 +56,7 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
def index
|
def index
|
||||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
|
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id], subject: current_subject)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
error code: 404, desc: "No highlighted software found"
|
error code: 404, desc: "No highlighted software found"
|
||||||
def index
|
def index
|
||||||
result = WarpEngine::SoftwareHighlightedService.new.index
|
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||||
if result
|
if result
|
||||||
render json: result
|
render json: result
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ module WarpEngine
|
|||||||
# client needs the version most when something came back wrong.
|
# client needs the version most when something came back wrong.
|
||||||
before_action :set_version_header
|
before_action :set_version_header
|
||||||
|
|
||||||
|
# Every read-only endpoint may be called with a bearer token; none of them requires
|
||||||
|
# one. See WarpEngine::SubjectAuthentication.
|
||||||
|
include WarpEngine::SubjectAuthentication
|
||||||
|
|
||||||
rescue_from StandardError do |e|
|
rescue_from StandardError do |e|
|
||||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||||
@@ -28,6 +32,10 @@ module WarpEngine
|
|||||||
render json: { error: e.message }, status: :bad_request
|
render json: { error: e.message }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
|
rescue_from WarpEngine::DownloadService::Denied do
|
||||||
|
render json: { error: "Forbidden" }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_version_header
|
def set_version_header
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ module WarpEngine
|
|||||||
param :path, String, required: true, desc: "File path"
|
param :path, String, required: true, desc: "File path"
|
||||||
returns code: 200, desc: "File binary data"
|
returns code: 200, desc: "File binary data"
|
||||||
returns code: 301, desc: "Redirect to file URL"
|
returns code: 301, desc: "Redirect to file URL"
|
||||||
|
error code: 403, desc: "The access policy refused this caller"
|
||||||
error code: 404, desc: "File not found"
|
error code: 404, desc: "File not found"
|
||||||
def show
|
def show
|
||||||
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
|
result = WarpEngine::FileService.new.show(
|
||||||
|
WarpEngine::FileShowInputDto.new(path: params[:path]), subject: current_subject
|
||||||
|
)
|
||||||
case result.type
|
case result.type
|
||||||
when :redirect then redirect_to result.url, status: :moved_permanently
|
when :redirect then redirect_to result.url, status: :moved_permanently
|
||||||
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
|
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ module WarpEngine
|
|||||||
|
|
||||||
UPDATE_SCOPE = "update".freeze
|
UPDATE_SCOPE = "update".freeze
|
||||||
UPLOAD_SCOPE = "upload".freeze
|
UPLOAD_SCOPE = "upload".freeze
|
||||||
|
# A token held by a *client* rather than a publisher: it reads the catalog and
|
||||||
|
# downloads artifacts, and it never publishes anything.
|
||||||
|
CATALOG_SCOPE = "catalog".freeze
|
||||||
|
|
||||||
# The generated token is only available in memory at creation time — the DB
|
# The generated token is only available in memory at creation time — the DB
|
||||||
# stores nothing but the SHA256 digest and the non-secret prefix.
|
# stores nothing but the SHA256 digest and the non-secret prefix.
|
||||||
@@ -78,6 +81,15 @@ module WarpEngine
|
|||||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Publishing tokens and client tokens share this table but not their owners: one
|
||||||
|
# belongs to whoever ships software, the other to whoever buys it. Both classes are
|
||||||
|
# the host's to name, and either is acceptable here — which of the two a given token
|
||||||
|
# may do is decided by its scopes, not by its owner.
|
||||||
|
def self.permitted_owner_types
|
||||||
|
[ WarpEngine.config.application_token_owner_class,
|
||||||
|
WarpEngine.config.access_token_owner_class ].compact_blank
|
||||||
|
end
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
return if token_digest.present?
|
return if token_digest.present?
|
||||||
|
|
||||||
@@ -87,11 +99,11 @@ module WarpEngine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def owner_type_matches_configuration
|
def owner_type_matches_configuration
|
||||||
expected = WarpEngine.config.application_token_owner_class
|
permitted = self.class.permitted_owner_types
|
||||||
if expected.blank?
|
if permitted.empty?
|
||||||
errors.add(:base, "application_token_owner_class is not configured")
|
errors.add(:base, "application_token_owner_class is not configured")
|
||||||
elsif owner_type != expected
|
elsif !permitted.include?(owner_type)
|
||||||
errors.add(:owner_type, "must be #{expected}")
|
errors.add(:owner_type, "must be #{permitted.join(' or ')}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
module WarpEngine
|
||||||
|
# One pending sign-in from a client that has no browser of its own.
|
||||||
|
#
|
||||||
|
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
|
||||||
|
# desktop client cannot host a login form without asking a person to type a password
|
||||||
|
# into a window that is not a browser. So the client asks for a pair of codes, sends
|
||||||
|
# the person to the host's own page with the short one, and polls with the long one
|
||||||
|
# until somebody approves it.
|
||||||
|
#
|
||||||
|
# Short-lived by design: this row exists for the minute or two between "the client
|
||||||
|
# asked" and "the person answered". What survives it is the ApplicationToken.
|
||||||
|
class DeviceGrant < ApplicationRecord
|
||||||
|
self.table_name = "device_grants"
|
||||||
|
|
||||||
|
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
|
||||||
|
# those four are where that goes wrong.
|
||||||
|
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
|
||||||
|
USER_CODE_LENGTH = 8
|
||||||
|
|
||||||
|
belongs_to :application_token, class_name: "WarpEngine::ApplicationToken", optional: true
|
||||||
|
belongs_to :subject, polymorphic: true, optional: true
|
||||||
|
|
||||||
|
validates :device_code, presence: true, uniqueness: true
|
||||||
|
validates :user_code, presence: true, uniqueness: true
|
||||||
|
validates :expires_at, presence: true
|
||||||
|
|
||||||
|
scope :pending, -> { where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||||
|
|
||||||
|
before_validation :generate_codes, on: :create
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
|
def self.find_pending_by_user_code(code)
|
||||||
|
pending.find_by(user_code: normalize_user_code(code))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Typed by a person, so it arrives with whatever case and separators they used.
|
||||||
|
def self.normalize_user_code(code)
|
||||||
|
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired? = expires_at <= Time.current
|
||||||
|
def approved? = approved_at.present?
|
||||||
|
def denied? = denied_at.present?
|
||||||
|
|
||||||
|
# What the polling client is told. Order matters: a denied grant is denied even
|
||||||
|
# after it expires, because "somebody said no" is the more useful answer.
|
||||||
|
def state
|
||||||
|
return :denied if denied?
|
||||||
|
return :approved if approved?
|
||||||
|
return :expired if expired?
|
||||||
|
|
||||||
|
:pending
|
||||||
|
end
|
||||||
|
|
||||||
|
# Grouped for reading aloud and for typing: WARP-K7M2.
|
||||||
|
def formatted_user_code
|
||||||
|
user_code.to_s.scan(/.{1,4}/).join("-")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
|
||||||
|
# and its issued_token would be a live secret nobody is waiting for.
|
||||||
|
def self.sweep_expired!
|
||||||
|
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ransackable_attributes(auth_object = nil)
|
||||||
|
%w[approved_at client_name created_at denied_at expires_at id subject_id subject_type updated_at user_code]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ransackable_associations(auth_object = nil)
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_codes
|
||||||
|
self.device_code = SecureRandom.hex(32) if device_code.blank?
|
||||||
|
self.user_code = self.class.generate_user_code if user_code.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.generate_user_code
|
||||||
|
# Retried rather than trusted: the alphabet is small enough that a collision is
|
||||||
|
# a real, if rare, event, and a unique index would turn it into a 500.
|
||||||
|
10.times do
|
||||||
|
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
|
||||||
|
return candidate unless exists?(user_code: candidate)
|
||||||
|
end
|
||||||
|
raise "could not generate a free device user code"
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
self.expires_at ||= WarpEngine.config.device_code_ttl.to_i.seconds.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveSupport.run_load_hooks(:warp_engine_device_grant, self)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,5 +5,8 @@ module WarpEngine
|
|||||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
||||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
||||||
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
||||||
|
# Whether this title is gated, what it costs and where to get it. Always present —
|
||||||
|
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
|
||||||
|
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
module WarpEngine
|
||||||
class DownloadService
|
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
|
def self.container_base
|
||||||
WarpEngine.config.file_container_path
|
WarpEngine.config.file_container_path
|
||||||
end
|
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
|
# 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,
|
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
|
||||||
# tehát változatlanul lemezről.
|
# 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
|
relative = path.to_s
|
||||||
return nil unless storage.file?(relative)
|
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
|
end
|
||||||
|
|
||||||
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
|
# 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ó.
|
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
|
||||||
def create(path:, ip:, user_agent:, referer:)
|
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer)
|
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
|
||||||
|
subject: subject, request: request)
|
||||||
return nil if location.nil?
|
return nil if location.nil?
|
||||||
|
|
||||||
location.file? ? location.path : nil
|
location.file? ? location.path : nil
|
||||||
@@ -36,18 +50,41 @@ module WarpEngine
|
|||||||
WarpEngine.storage
|
WarpEngine.storage
|
||||||
end
|
end
|
||||||
|
|
||||||
def log_download(relative, ip:, user_agent:, referer:)
|
# A policy that refuses returns nil; one that raises is treated as a refusal too.
|
||||||
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
|
# An artifact served because the gatekeeper crashed is the one failure mode this
|
||||||
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
|
# engine must not have.
|
||||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
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,
|
file_path: relative,
|
||||||
release: asset&.release,
|
release: asset&.release,
|
||||||
ip_address: ip,
|
ip_address: ip,
|
||||||
user_agent: user_agent&.truncate(500),
|
user_agent: user_agent&.truncate(500),
|
||||||
referer: referer&.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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ module WarpEngine
|
|||||||
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
|
# 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
|
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
|
||||||
# patchelné. Lásd WarpEngine::Storage.
|
# 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
|
relative = input.path.to_s
|
||||||
|
|
||||||
if storage.directory?(relative)
|
if storage.directory?(relative)
|
||||||
@@ -20,11 +29,34 @@ module WarpEngine
|
|||||||
|
|
||||||
return FileResultDto.not_found unless storage.file?(relative)
|
return FileResultDto.not_found unless storage.file?(relative)
|
||||||
|
|
||||||
|
authorize!(relative, subject)
|
||||||
|
|
||||||
to_result(storage.locate(relative, filename: File.basename(relative)))
|
to_result(storage.locate(relative, filename: File.basename(relative)))
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def storage
|
||||||
WarpEngine.storage
|
WarpEngine.storage
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ module WarpEngine
|
|||||||
class SoftwareHighlightedService
|
class SoftwareHighlightedService
|
||||||
include SoftwareResponseBuilder
|
include SoftwareResponseBuilder
|
||||||
|
|
||||||
def index
|
def index(subject: nil)
|
||||||
software = WarpEngine::Software.includes(:external_links, :software_images)
|
software = visible_scope(subject).includes(:external_links, :software_images)
|
||||||
.where(highlighted: true)
|
.where(highlighted: true)
|
||||||
.order(id: :desc)
|
.order(id: :desc)
|
||||||
.first
|
.first
|
||||||
return nil unless software
|
return nil unless software
|
||||||
|
|
||||||
releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module WarpEngine
|
|||||||
module SoftwareResponseBuilder
|
module SoftwareResponseBuilder
|
||||||
private
|
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
|
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
|
||||||
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
||||||
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
||||||
@@ -14,10 +14,33 @@ module WarpEngine
|
|||||||
latest: latest,
|
latest: latest,
|
||||||
web_playable: web_playable,
|
web_playable: web_playable,
|
||||||
total_downloads: total_downloads,
|
total_downloads: total_downloads,
|
||||||
download_counts: download_counts
|
download_counts: download_counts,
|
||||||
|
access: access_for(software, subject)
|
||||||
)
|
)
|
||||||
end
|
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).
|
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
|
||||||
def download_counts_for(release_ids)
|
def download_counts_for(release_ids)
|
||||||
return {} if release_ids.empty?
|
return {} if release_ids.empty?
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ module WarpEngine
|
|||||||
class SoftwareService
|
class SoftwareService
|
||||||
include SoftwareResponseBuilder
|
include SoftwareResponseBuilder
|
||||||
|
|
||||||
def index(owner_id: nil)
|
# `subject` is whoever the request authenticated as, or nil. It decides two things:
|
||||||
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
|
# 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?
|
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
|
||||||
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
WarpEngine::Engine.routes.draw do
|
WarpEngine::Engine.routes.draw do
|
||||||
namespace :api do
|
namespace :api do
|
||||||
|
# What this deployment is and what it can do. A client reads it before it can have
|
||||||
|
# a credential, so it is public and cheap.
|
||||||
|
get "service", to: "service#show"
|
||||||
|
|
||||||
|
# Device sign-in, for clients that have no browser of their own (RFC 8628).
|
||||||
|
# Inactive — 404 on every action — unless the host configured a subject class.
|
||||||
|
namespace :auth do
|
||||||
|
post "device", to: "devices#create"
|
||||||
|
post "device/token", to: "devices#token"
|
||||||
|
delete "token", to: "tokens#destroy"
|
||||||
|
end
|
||||||
|
|
||||||
get "software", to: "software#index"
|
get "software", to: "software#index"
|
||||||
get "software/highlighted", to: "software_highlighted#index"
|
get "software/highlighted", to: "software_highlighted#index"
|
||||||
get "image/:id", to: "images#show"
|
get "image/:id", to: "images#show"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
class CreateDeviceGrants < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :device_grants, id: { type: :bigint, unsigned: true },
|
||||||
|
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||||
|
# Both codes are secrets in the sense that guessing one grants a session, but they
|
||||||
|
# have different jobs: the device code is long and never shown to a person, the
|
||||||
|
# user code is short enough to read off a screen and type into a browser.
|
||||||
|
t.string :device_code, limit: 64, null: false
|
||||||
|
t.string :user_code, limit: 16, null: false
|
||||||
|
# What the client called itself. Shown on the approval page, so a person can tell
|
||||||
|
# which machine is asking, and kept on the issued token as its name.
|
||||||
|
t.string :client_name, limit: 128
|
||||||
|
# The approving subject comes from the host
|
||||||
|
# (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as
|
||||||
|
# application_tokens.owner_type.
|
||||||
|
t.string :subject_type, limit: 128
|
||||||
|
t.bigint :subject_id, unsigned: true
|
||||||
|
t.bigint :application_token_id, unsigned: true
|
||||||
|
# The issued token, in the clear, for the seconds between "approved" and "the
|
||||||
|
# client's next poll". It is cleared on the poll that hands it over, so this
|
||||||
|
# column holds a live secret only while somebody is waiting for it. There is no
|
||||||
|
# way around storing it: the approval happens in a browser and the poll arrives on
|
||||||
|
# a different request, so the two cannot share memory. Everything else about a
|
||||||
|
# token is stored as a digest — this is the one exception, and it is temporary.
|
||||||
|
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, name: "idx_device_grants_device_code", unique: true
|
||||||
|
t.index :user_code, name: "idx_device_grants_user_code", unique: true
|
||||||
|
t.index :expires_at, name: "idx_device_grants_expires_at"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -98,6 +98,28 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
|
|||||||
t.index :deleted_at
|
t.index :deleted_at
|
||||||
end
|
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|
|
create_table :downloads do |t|
|
||||||
t.string :file_path, null: false
|
t.string :file_path, null: false
|
||||||
t.references :release, foreign_key: { on_delete: :nullify }, index: 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)!
|
# softwares got an owner (backfill)!
|
||||||
# c.enforce_software_ownership = true
|
# 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
|
# If host models also reference catalog images, register them so the
|
||||||
# admin Images page's orphan detection takes them into account:
|
# admin Images page's orphan detection takes them into account:
|
||||||
# c.image_owners = [
|
# c.image_owners = [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require "apipie-rails"
|
|||||||
require "warp_engine/version"
|
require "warp_engine/version"
|
||||||
require "warp_engine/configuration"
|
require "warp_engine/configuration"
|
||||||
require "warp_engine/storage"
|
require "warp_engine/storage"
|
||||||
|
require "warp_engine/access"
|
||||||
|
|
||||||
module WarpEngine
|
module WarpEngine
|
||||||
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
|
# A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace
|
||||||
@@ -42,6 +43,17 @@ module WarpEngine
|
|||||||
def self.storage
|
def self.storage
|
||||||
Storage.adapter
|
Storage.adapter
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
require "warp_engine/engine"
|
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).
|
# 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.
|
# 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.
|
# 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_url / woodpecker_api_token / woodpecker_repo_owner:
|
||||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
|
# Woodpecker CI management (repo sync, secret provisioning, pipeline control).
|
||||||
# All nil → the management features are inactive.
|
# All nil → the management features are inactive.
|
||||||
@@ -44,7 +60,12 @@ module WarpEngine
|
|||||||
:woodpecker_api_token,
|
:woodpecker_api_token,
|
||||||
:woodpecker_repo_owner,
|
:woodpecker_repo_owner,
|
||||||
:image_owners,
|
:image_owners,
|
||||||
:storage_adapter
|
:storage_adapter,
|
||||||
|
:access_policy,
|
||||||
|
:access_token_owner_class,
|
||||||
|
:identity_verification_url,
|
||||||
|
:device_code_ttl,
|
||||||
|
:device_code_interval
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||||
@@ -63,6 +84,11 @@ module WarpEngine
|
|||||||
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
|
@woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
|
||||||
@image_owners = []
|
@image_owners = []
|
||||||
@storage_adapter = :local
|
@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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
module WarpEngine
|
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
|
# 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
|
# place that sets it: clients read it, the README documents it, and a string in three
|
||||||
|
|||||||
+19
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
ActiveRecord::Schema[8.1].define(version: 2026_08_19_000001) do
|
||||||
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.datetime "created_at", precision: 3
|
t.datetime "created_at", precision: 3
|
||||||
t.datetime "deleted_at", precision: 3
|
t.datetime "deleted_at", precision: 3
|
||||||
@@ -29,6 +29,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
|||||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.bigint "application_token_id", unsigned: true
|
||||||
|
t.datetime "approved_at", precision: 3
|
||||||
|
t.string "client_name", limit: 128
|
||||||
|
t.datetime "created_at", precision: 3
|
||||||
|
t.datetime "denied_at", precision: 3
|
||||||
|
t.string "device_code", limit: 64, null: false
|
||||||
|
t.datetime "expires_at", precision: 3, null: false
|
||||||
|
t.string "issued_token", limit: 64
|
||||||
|
t.bigint "subject_id", unsigned: true
|
||||||
|
t.string "subject_type", limit: 128
|
||||||
|
t.datetime "updated_at", precision: 3
|
||||||
|
t.string "user_code", limit: 16, null: false
|
||||||
|
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
|
||||||
|
t.index ["expires_at"], name: "idx_device_grants_expires_at"
|
||||||
|
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.datetime "created_at", precision: 3
|
t.datetime "created_at", precision: 3
|
||||||
t.datetime "deleted_at", precision: 3
|
t.datetime "deleted_at", precision: 3
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# Signing a client in, end to end: the client asks for a code, a person approves it on
|
||||||
|
# the host's page, the client's next poll carries the token away, and the token then
|
||||||
|
# works as a bearer credential on the read-only API.
|
||||||
|
RSpec.describe "Device sign-in", type: :request do
|
||||||
|
let(:owner) { create(:test_owner) }
|
||||||
|
|
||||||
|
# The identity seam is off by default. Configuring the subject class is what turns
|
||||||
|
# the whole flow on — including whether it exists at all.
|
||||||
|
def configure_identity!(verification: "/devices")
|
||||||
|
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||||
|
allow(WarpEngine.config).to receive(:identity_verification_url).and_return(verification)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when the host configured no client identity" do
|
||||||
|
it "has no device endpoint at all" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "says so in the service descriptor rather than by erroring" do
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)["auth"]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "the full flow" do
|
||||||
|
before { configure_identity! }
|
||||||
|
|
||||||
|
it "issues a code pair a person can read off a screen" do
|
||||||
|
post "/api/auth/device", params: { client_name: "Zsolt's laptop" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["deviceCode"]).to be_present
|
||||||
|
# Grouped and free of I/O/0/1, because it is typed by hand into a browser.
|
||||||
|
expect(json["userCode"]).to match(/\A[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}\z/)
|
||||||
|
expect(json["verificationUrl"]).to eq("http://www.example.com/devices")
|
||||||
|
expect(json["interval"]).to eq(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "keeps the client waiting until somebody approves" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
device_code = JSON.parse(response.body)["deviceCode"]
|
||||||
|
|
||||||
|
post "/api/auth/device/token", params: { device_code: device_code }
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)).to eq("state" => "pending")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "hands over the token on the first poll after approval" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["state"]).to eq("approved")
|
||||||
|
expect(body["token"]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
# The plain token is never stored, so it cannot be handed out twice. A client that
|
||||||
|
# loses it starts again — which is cheaper than a database full of live secrets.
|
||||||
|
it "does not repeat the token on a second poll" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["state"]).to eq("approved")
|
||||||
|
expect(body).not_to have_key("token")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports a denied grant as denied" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
WarpEngine::DeviceGrantService.new.deny(user_code: json["userCode"])
|
||||||
|
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["state"]).to eq("denied")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports an expired grant as expired" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
WarpEngine::DeviceGrant.last.update!(expires_at: 1.minute.ago)
|
||||||
|
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["state"]).to eq("expired")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "404s an unknown device code" do
|
||||||
|
post "/api/auth/device/token", params: { device_code: "nope" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "will not approve the same code twice" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
code = JSON.parse(response.body)["userCode"]
|
||||||
|
WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner)
|
||||||
|
|
||||||
|
expect { WarpEngine::DeviceGrantService.new.approve(user_code: code, subject: owner) }
|
||||||
|
.to raise_error(WarpEngine::DeviceGrantService::UnknownCode)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts the user code however a person typed it" do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
code = JSON.parse(response.body)["userCode"]
|
||||||
|
|
||||||
|
grant = WarpEngine::DeviceGrantService.new.approve(
|
||||||
|
user_code: code.downcase.delete("-"), subject: owner
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(grant).to be_approved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "the issued token" do
|
||||||
|
before { configure_identity! }
|
||||||
|
|
||||||
|
let(:token) do
|
||||||
|
post "/api/auth/device", params: { client_name: "laptop" }
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
WarpEngine::DeviceGrantService.new.approve(user_code: json["userCode"], subject: owner)
|
||||||
|
post "/api/auth/device/token", params: { device_code: json["deviceCode"] }
|
||||||
|
JSON.parse(response.body)["token"]
|
||||||
|
end
|
||||||
|
|
||||||
|
it "belongs to the subject who approved it, and may only read the catalog" do
|
||||||
|
token
|
||||||
|
record = WarpEngine::ApplicationToken.last
|
||||||
|
|
||||||
|
expect(record.owner).to eq(owner)
|
||||||
|
expect(record.scopes).to eq([ "catalog" ])
|
||||||
|
end
|
||||||
|
|
||||||
|
# A publishing token must not become a client token by accident, and vice versa:
|
||||||
|
# the scope is what separates them, and the catalog endpoint requires its own.
|
||||||
|
it "is not accepted as a publishing credential" do
|
||||||
|
token
|
||||||
|
|
||||||
|
expect(WarpEngine::ApplicationToken.authenticate(token, required_scope: "update")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "identifies the subject on a catalog request" do
|
||||||
|
create(:software)
|
||||||
|
seen = nil
|
||||||
|
policy = Class.new do
|
||||||
|
def initialize(sink) = @sink = sink
|
||||||
|
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||||
|
def access_for(software:, subject: nil)
|
||||||
|
@sink.call(subject)
|
||||||
|
WarpEngine::Access::OPEN
|
||||||
|
end
|
||||||
|
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||||
|
end.new(->(s) { seen = s })
|
||||||
|
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||||
|
|
||||||
|
get "/api/software", headers: { "Authorization" => "Bearer #{token}" }
|
||||||
|
|
||||||
|
expect(seen).to eq(owner)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is ignored when it is not a bearer credential" do
|
||||||
|
value = token
|
||||||
|
|
||||||
|
get "/api/software", headers: { "Authorization" => value }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(WarpEngine::ApplicationToken.last.last_used_at).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "stops working once revoked" do
|
||||||
|
value = token
|
||||||
|
|
||||||
|
delete "/api/auth/token", headers: { "Authorization" => "Bearer #{value}" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
expect(WarpEngine::ApplicationToken.authenticate(value, required_scope: "catalog")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refuses to revoke without a token" do
|
||||||
|
delete "/api/auth/token"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
# The descriptor is how a client stops being built for one particular store: everything
|
||||||
|
# it used to have compiled in — is there a sign-in, where does it live, can titles be
|
||||||
|
# gated — is answered here instead.
|
||||||
|
RSpec.describe "GET /api/service", type: :request do
|
||||||
|
after { WarpEngine::AccessPolicy.reset! }
|
||||||
|
|
||||||
|
it "names the engine and its version" do
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["engine"]).to eq("warp_engine")
|
||||||
|
expect(json["version"]).to eq(WarpEngine::VERSION)
|
||||||
|
expect(response.headers["WarpEngine-Version"]).to eq(WarpEngine::VERSION)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports an open catalog as ungated and offering no sign-in" do
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
expect(json["catalog"]).to eq("gated" => false)
|
||||||
|
expect(json["auth"]).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports a configured policy as a catalog that can gate" do
|
||||||
|
policy = Class.new do
|
||||||
|
def visible_software_scope(subject: nil) = WarpEngine::Software.all
|
||||||
|
def access_for(software:, subject: nil) = WarpEngine::Access::OPEN
|
||||||
|
def authorize_download(asset: nil, subject: nil, request: nil) = WarpEngine::Access::Grant::OPEN
|
||||||
|
end.new
|
||||||
|
allow(WarpEngine.config).to receive(:access_policy).and_return(policy)
|
||||||
|
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["catalog"]).to eq("gated" => true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "with a client identity configured" do
|
||||||
|
before do
|
||||||
|
allow(WarpEngine.config).to receive(:access_token_owner_class).and_return("TestOwner")
|
||||||
|
allow(WarpEngine.config).to receive(:identity_verification_url).and_return("/devices")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "describes the device flow, so a client needs no addresses of its own" do
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
auth = JSON.parse(response.body)["auth"]
|
||||||
|
expect(auth["schemes"]).to eq([ "bearer" ])
|
||||||
|
expect(auth["device"]["authorizeUrl"]).to eq("http://www.example.com/api/auth/device")
|
||||||
|
expect(auth["device"]["tokenUrl"]).to eq("http://www.example.com/api/auth/device/token")
|
||||||
|
expect(auth["device"]["revokeUrl"]).to eq("http://www.example.com/api/auth/token")
|
||||||
|
expect(auth["device"]["interval"]).to eq(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
# A host that configured a bare path should not have to know its own hostname; one
|
||||||
|
# that put the approval page on another domain should keep it.
|
||||||
|
it "makes a configured path absolute against the request" do
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||||
|
.to eq("http://www.example.com/devices")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "leaves an absolute verification URL alone" do
|
||||||
|
allow(WarpEngine.config).to receive(:identity_verification_url)
|
||||||
|
.and_return("https://accounts.example.org/devices")
|
||||||
|
|
||||||
|
get "/api/service"
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body)["auth"]["device"]["verificationUrl"])
|
||||||
|
.to eq("https://accounts.example.org/devices")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
# The access seam, from both sides: what the catalog says about a title, and whether an
|
||||||
|
# artifact is handed over. The load-bearing case is the *default* one — a catalog with
|
||||||
|
# no policy configured has to behave exactly as it did before this existed.
|
||||||
|
RSpec.describe "The access policy" do
|
||||||
|
let(:tmpdir) { Dir.mktmpdir }
|
||||||
|
|
||||||
|
# A policy that gates everything except what the subject is named after. Small enough
|
||||||
|
# to read, and it exercises every method of the contract.
|
||||||
|
let(:gating_policy) do
|
||||||
|
Class.new do
|
||||||
|
def initialize(open_name) = @open_name = open_name
|
||||||
|
|
||||||
|
def visible_software_scope(subject: nil)
|
||||||
|
WarpEngine::Software.where.not(status: "development")
|
||||||
|
end
|
||||||
|
|
||||||
|
def access_for(software:, subject: nil)
|
||||||
|
return WarpEngine::Access.new if software.name == @open_name
|
||||||
|
|
||||||
|
WarpEngine::Access.new(
|
||||||
|
gated: true, entitled: subject.present?, price_cents: 1490, currency: "EUR",
|
||||||
|
purchase_url: "https://shop.example/#{software.name}",
|
||||||
|
web_url: "https://shop.example/play/#{software.name}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_download(asset: nil, subject: nil, request: nil)
|
||||||
|
return WarpEngine::Access::Grant.new if asset&.release&.software&.name == @open_name
|
||||||
|
|
||||||
|
subject.nil? ? nil : WarpEngine::Access::Grant.new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
|
||||||
|
WarpEngine::Storage.reset!
|
||||||
|
WarpEngine::AccessPolicy.reset!
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
FileUtils.rm_rf(tmpdir)
|
||||||
|
WarpEngine::AccessPolicy.reset!
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "the default (:open) policy" do
|
||||||
|
it "lists every software, whatever its status" do
|
||||||
|
create(:software, status: "development")
|
||||||
|
create(:software, status: "released")
|
||||||
|
|
||||||
|
result = WarpEngine::SoftwareService.new.index
|
||||||
|
|
||||||
|
expect(result[:softwares].size).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports every title as open, so a client never has to guess" do
|
||||||
|
create(:software)
|
||||||
|
|
||||||
|
entry = WarpEngine::SoftwareService.new.index[:softwares].first
|
||||||
|
|
||||||
|
expect(entry[:access]).to eq(
|
||||||
|
gated: false, entitled: true, price: nil, purchaseUrl: nil, webUrl: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "hands over an artifact with no subject at all" do
|
||||||
|
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
|
||||||
|
|
||||||
|
path = WarpEngine::DownloadService.new.create(
|
||||||
|
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(path).to eq(File.join(tmpdir, "game-1.0.zip"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "a configured policy" do
|
||||||
|
let(:open_software) { create(:software, name: "free-game", status: "released") }
|
||||||
|
let(:gated_software) { create(:software, name: "paid-game", status: "released") }
|
||||||
|
let(:subject_record) { create(:test_owner) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
open_software
|
||||||
|
gated_software
|
||||||
|
create(:software, name: "draft-game", status: "development")
|
||||||
|
allow(WarpEngine.config).to receive(:access_policy).and_return(gating_policy.new("free-game"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "narrows the catalog to what the policy scope allows" do
|
||||||
|
names = WarpEngine::SoftwareService.new.index[:softwares].map { |e| e[:software][:name] }
|
||||||
|
|
||||||
|
expect(names).to contain_exactly("free-game", "paid-game")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "describes a gated title with its price and where to buy it" do
|
||||||
|
entry = WarpEngine::SoftwareService.new.index[:softwares]
|
||||||
|
.find { |e| e[:software][:name] == "paid-game" }
|
||||||
|
|
||||||
|
expect(entry[:access]).to eq(
|
||||||
|
gated: true, entitled: false,
|
||||||
|
price: { amountCents: 1490, currency: "EUR" },
|
||||||
|
purchaseUrl: "https://shop.example/paid-game",
|
||||||
|
webUrl: "https://shop.example/play/paid-game"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "reports entitlement against the authenticated subject" do
|
||||||
|
entry = WarpEngine::SoftwareService.new.index(subject: subject_record)[:softwares]
|
||||||
|
.find { |e| e[:software][:name] == "paid-game" }
|
||||||
|
|
||||||
|
expect(entry[:access][:entitled]).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refuses an artifact the policy will not authorise" do
|
||||||
|
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
|
||||||
|
release = create(:release, software: gated_software)
|
||||||
|
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
|
||||||
|
path: File.join(tmpdir, "paid-game-1.0.zip"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
WarpEngine::DownloadService.new.create(
|
||||||
|
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||||
|
)
|
||||||
|
}.to raise_error(WarpEngine::DownloadService::Denied)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "hands the same artifact over to a subject the policy accepts" do
|
||||||
|
File.write(File.join(tmpdir, "paid-game-1.0.zip"), "zip")
|
||||||
|
release = create(:release, software: gated_software)
|
||||||
|
WarpEngine::ReleaseAsset.create!(release: release, kind: "win_x64",
|
||||||
|
path: File.join(tmpdir, "paid-game-1.0.zip"))
|
||||||
|
|
||||||
|
path = WarpEngine::DownloadService.new.create(
|
||||||
|
path: "paid-game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil,
|
||||||
|
subject: subject_record
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(path).to eq(File.join(tmpdir, "paid-game-1.0.zip"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "a policy that raises" do
|
||||||
|
let(:broken_policy) do
|
||||||
|
Class.new do
|
||||||
|
def visible_software_scope(subject: nil) = raise("boom")
|
||||||
|
def access_for(software:, subject: nil) = raise("boom")
|
||||||
|
def authorize_download(asset: nil, subject: nil, request: nil) = raise("boom")
|
||||||
|
end.new
|
||||||
|
end
|
||||||
|
|
||||||
|
before { allow(WarpEngine.config).to receive(:access_policy).and_return(broken_policy) }
|
||||||
|
|
||||||
|
# The direction of the failure is the point. A broken gatekeeper must not become an
|
||||||
|
# open one: an empty catalog is recoverable, a paid title given away is not.
|
||||||
|
it "empties the catalog rather than leaking it" do
|
||||||
|
create(:software, status: "released")
|
||||||
|
|
||||||
|
expect(WarpEngine::SoftwareService.new.index[:softwares]).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "refuses the download rather than serving it" do
|
||||||
|
File.write(File.join(tmpdir, "game-1.0.zip"), "zip")
|
||||||
|
|
||||||
|
expect {
|
||||||
|
WarpEngine::DownloadService.new.create(
|
||||||
|
path: "game-1.0.zip", ip: "127.0.0.1", user_agent: "rspec", referer: nil
|
||||||
|
)
|
||||||
|
}.to raise_error(WarpEngine::DownloadService::Denied)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user