diff --git a/apps/api/Gemfile.lock b/apps/api/Gemfile.lock index 54c462d..4690bb5 100644 --- a/apps/api/Gemfile.lock +++ b/apps/api/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../libs/ruby/warp_engine specs: - warp_engine (0.4.0) + warp_engine (0.5.0) apipie-rails blueprinter rails (>= 8.0) diff --git a/libs/ruby/warp_engine/README.md b/libs/ruby/warp_engine/README.md index 3d30fa8..8dc0104 100644 --- a/libs/ruby/warp_engine/README.md +++ b/libs/ruby/warp_engine/README.md @@ -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 your app's existing admin. -Repository: `https://git.teletypegames.org/tools/warp_engine` +Repository: `https://git.teletypegames.org/engines/warp_engine` ## 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 a shared secret or by per-owner database tokens with expiry and scopes (`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 (`:local` by default); a host can serve them from an object store without patching the engine. @@ -152,7 +158,7 @@ From the git repository: ```ruby # 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): @@ -206,6 +212,15 @@ Rails.application.config.to_prepare do # after backfilling owners — ownerless softwares are claimable by anyone. # 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 # admin Images page counts them as "in use": # 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 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 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 `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 | 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/builds` | Expected asset kinds per platform (build matrix) | | `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release | | `GET /api/image/:id` | Serves catalog images | | `GET /api/download?path=` | Serves an artifact and logs a download record | | `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` @@ -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 -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 diff --git a/libs/ruby/warp_engine/app/admin/application_tokens.rb b/libs/ruby/warp_engine/app/admin/application_tokens.rb index dcc9a66..cc24fba 100644 --- a/libs/ruby/warp_engine/app/admin/application_tokens.rb +++ b/libs/ruby/warp_engine/app/admin/application_tokens.rb @@ -10,6 +10,12 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do scope :all, default: true scope("Active") { |scope| scope.where("expires_at IS NULL OR 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 id_column @@ -45,7 +51,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do end f.input :name 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 :expires_at, hint: "Leave empty for a token that never expires." end diff --git a/libs/ruby/warp_engine/app/admin/device_grants.rb b/libs/ruby/warp_engine/app/admin/device_grants.rb new file mode 100644 index 0000000..8a8a28d --- /dev/null +++ b/libs/ruby/warp_engine/app/admin/device_grants.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/controllers/concerns/warp_engine/subject_authentication.rb b/libs/ruby/warp_engine/app/controllers/concerns/warp_engine/subject_authentication.rb new file mode 100644 index 0000000..0de8e4b --- /dev/null +++ b/libs/ruby/warp_engine/app/controllers/concerns/warp_engine/subject_authentication.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/devices_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/devices_controller.rb new file mode 100644 index 0000000..20aebaa --- /dev/null +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/devices_controller.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/tokens_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/tokens_controller.rb new file mode 100644 index 0000000..5c1914f --- /dev/null +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/auth/tokens_controller.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/downloads_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/downloads_controller.rb index 950169d..821daa8 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/api/downloads_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/downloads_controller.rb @@ -10,6 +10,7 @@ module WarpEngine returns code: 200, desc: "File binary data" returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)" error code: 400, desc: "Path is blank" + error code: 403, desc: "The access policy refused this caller" error code: 404, desc: "File not found" def show path = params[:path] @@ -19,7 +20,9 @@ module WarpEngine path: path, ip: request.remote_ip, user_agent: request.user_agent, - referer: request.referer + referer: request.referer, + subject: current_subject, + request: request ) if location.nil? diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/service_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/service_controller.rb new file mode 100644 index 0000000..09b01e3 --- /dev/null +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/service_controller.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_controller.rb index b45d038..1322e8e 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_controller.rb @@ -56,7 +56,7 @@ module WarpEngine end end 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 diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_highlighted_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_highlighted_controller.rb index 9e0640f..903c5f8 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_highlighted_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api/software_highlighted_controller.rb @@ -34,7 +34,7 @@ module WarpEngine end error code: 404, desc: "No highlighted software found" def index - result = WarpEngine::SoftwareHighlightedService.new.index + result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject) if result render json: result else diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/api_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/api_controller.rb index 78a93af..78923d2 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/api_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/api_controller.rb @@ -11,6 +11,10 @@ module WarpEngine # client needs the version most when something came back wrong. 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| Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}") render json: { error: "Internal server error" }, status: :internal_server_error @@ -28,6 +32,10 @@ module WarpEngine render json: { error: e.message }, status: :bad_request end + rescue_from WarpEngine::DownloadService::Denied do + render json: { error: "Forbidden" }, status: :forbidden + end + private def set_version_header diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/files_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/files_controller.rb index 17cd56d..c5cc320 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/files_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/files_controller.rb @@ -9,9 +9,12 @@ module WarpEngine param :path, String, required: true, desc: "File path" returns code: 200, desc: "File binary data" 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" 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 when :redirect then redirect_to result.url, status: :moved_permanently when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path) diff --git a/libs/ruby/warp_engine/app/models/warp_engine/application_token.rb b/libs/ruby/warp_engine/app/models/warp_engine/application_token.rb index a9a4d86..8ea307e 100644 --- a/libs/ruby/warp_engine/app/models/warp_engine/application_token.rb +++ b/libs/ruby/warp_engine/app/models/warp_engine/application_token.rb @@ -6,6 +6,9 @@ module WarpEngine UPDATE_SCOPE = "update".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 # 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? 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 return if token_digest.present? @@ -87,11 +99,11 @@ module WarpEngine end def owner_type_matches_configuration - expected = WarpEngine.config.application_token_owner_class - if expected.blank? + permitted = self.class.permitted_owner_types + if permitted.empty? errors.add(:base, "application_token_owner_class is not configured") - elsif owner_type != expected - errors.add(:owner_type, "must be #{expected}") + elsif !permitted.include?(owner_type) + errors.add(:owner_type, "must be #{permitted.join(' or ')}") end end diff --git a/libs/ruby/warp_engine/app/models/warp_engine/device_grant.rb b/libs/ruby/warp_engine/app/models/warp_engine/device_grant.rb new file mode 100644 index 0000000..fb7050d --- /dev/null +++ b/libs/ruby/warp_engine/app/models/warp_engine/device_grant.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/serializers/warp_engine/software_detail_serializer.rb b/libs/ruby/warp_engine/app/serializers/warp_engine/software_detail_serializer.rb index d553be4..e7c97fa 100644 --- a/libs/ruby/warp_engine/app/serializers/warp_engine/software_detail_serializer.rb +++ b/libs/ruby/warp_engine/app/serializers/warp_engine/software_detail_serializer.rb @@ -5,5 +5,8 @@ module WarpEngine 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(: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 diff --git a/libs/ruby/warp_engine/app/services/warp_engine/device_grant_service.rb b/libs/ruby/warp_engine/app/services/warp_engine/device_grant_service.rb new file mode 100644 index 0000000..6722820 --- /dev/null +++ b/libs/ruby/warp_engine/app/services/warp_engine/device_grant_service.rb @@ -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 diff --git a/libs/ruby/warp_engine/app/services/warp_engine/download_service.rb b/libs/ruby/warp_engine/app/services/warp_engine/download_service.rb index cfcfb2c..fca42ea 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/download_service.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/download_service.rb @@ -1,5 +1,10 @@ module WarpEngine class DownloadService + # The access policy said no. The controller turns this into a 403 — distinct from + # the nil that means "no such file", because telling a person their file is missing + # when it is merely locked sends them looking for the wrong problem. + class Denied < StandardError; end + def self.container_base WarpEngine.config.file_container_path end @@ -12,19 +17,28 @@ module WarpEngine # A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a # letöltést. A hely feloldása a storage adapteren megy — alapból :local, # tehát változatlanul lemezről. - def locate(path:, ip:, user_agent:, referer:) + # + # `subject` is whoever the request authenticated as, or nil. Under the open policy + # it is ignored and every file is served, exactly as before. + def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil) relative = path.to_s return nil unless storage.file?(relative) - log_download(relative, ip: ip, user_agent: user_agent, referer: referer) + asset = find_asset(relative) + grant = authorize!(asset, subject, request) - storage.locate(relative, filename: File.basename(relative)) + log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject) + + storage.locate(relative, + filename: grant.filename.presence || File.basename(relative), + expires_in: grant.expires_in) end # Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza # (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való. - def create(path:, ip:, user_agent:, referer:) - location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer) + def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil) + location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer, + subject: subject, request: request) return nil if location.nil? location.file? ? location.path : nil @@ -36,18 +50,41 @@ module WarpEngine WarpEngine.storage end - def log_download(relative, ip:, user_agent:, referer:) - escaped = relative.gsub("%", "\\%").gsub("_", "\\_") - asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) || - WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first + # A policy that refuses returns nil; one that raises is treated as a refusal too. + # An artifact served because the gatekeeper crashed is the one failure mode this + # engine must not have. + def authorize!(asset, subject, request) + grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request) + raise Denied if grant.nil? - WarpEngine::Download.create!( + grant + rescue Denied + raise + rescue StandardError => e + Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") + raise Denied + end + + def find_asset(relative) + escaped = relative.gsub("%", "\\%").gsub("_", "\\_") + WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) || + WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first + end + + def log_download(relative, asset:, ip:, user_agent:, referer:, subject:) + download = WarpEngine::Download.create!( file_path: relative, release: asset&.release, ip_address: ip, user_agent: user_agent&.truncate(500), referer: referer&.truncate(500) ) + + # The same seam the publish side has: a host that wants its own record of who + # downloaded what subscribes rather than reaching into this class. + ActiveSupport::Notifications.instrument("warp_engine.download", + path: relative, asset: asset, release: asset&.release, + software: asset&.release&.software, subject: subject, download: download, ip: ip) end end end diff --git a/libs/ruby/warp_engine/app/services/warp_engine/file_service.rb b/libs/ruby/warp_engine/app/services/warp_engine/file_service.rb index 2198c2c..794a247 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/file_service.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/file_service.rb @@ -8,7 +8,16 @@ module WarpEngine # A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) — # így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t # patchelné. Lásd WarpEngine::Storage. - def show(input) + # `subject` is whoever the request authenticated as, or nil. Under the open policy + # it is ignored and every file is served, exactly as before. + # + # A hosted (browser) build reaches this path as hundreds of relative requests for + # js, wasm and images, which is why the gate here is the policy's plain yes/no + # rather than anything signed: there is nothing to sign per file. A host serving + # gated web builds to browsers will usually want its own session-based route in + # front of this one — a browser has a session, and a redirect to a login page is a + # better answer there than a bare 403. + def show(input, subject: nil) relative = input.path.to_s if storage.directory?(relative) @@ -20,11 +29,34 @@ module WarpEngine return FileResultDto.not_found unless storage.file?(relative) + authorize!(relative, subject) + to_result(storage.locate(relative, filename: File.basename(relative))) end private + # Same rule and same failure mode as DownloadService: a policy that refuses or + # raises means no file. An artifact served because the gatekeeper crashed is the + # one failure mode this engine must not have. + def authorize!(relative, subject) + # The open policy authorises everything, and this path serves a browser build as + # hundreds of requests for js, wasm and images. Asking it per file would mean a + # LIKE query per asset for an answer that is always yes. + return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open? + + asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first + grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil) + raise WarpEngine::DownloadService::Denied if grant.nil? + + grant + rescue WarpEngine::DownloadService::Denied + raise + rescue StandardError => e + Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") + raise WarpEngine::DownloadService::Denied + end + def storage WarpEngine.storage end diff --git a/libs/ruby/warp_engine/app/services/warp_engine/software_highlighted_service.rb b/libs/ruby/warp_engine/app/services/warp_engine/software_highlighted_service.rb index b898109..0947efa 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/software_highlighted_service.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/software_highlighted_service.rb @@ -2,15 +2,15 @@ module WarpEngine class SoftwareHighlightedService include SoftwareResponseBuilder - def index - software = WarpEngine::Software.includes(:external_links, :software_images) + def index(subject: nil) + software = visible_scope(subject).includes(:external_links, :software_images) .where(highlighted: true) .order(id: :desc) .first return nil unless software releases = WarpEngine::Release.includes(:release_assets).where(software_id: software.id).to_a - build_response(software, releases, download_counts_for(releases.map(&:id))) + build_response(software, releases, download_counts_for(releases.map(&:id)), subject: subject) end end end diff --git a/libs/ruby/warp_engine/app/services/warp_engine/software_response_builder.rb b/libs/ruby/warp_engine/app/services/warp_engine/software_response_builder.rb index a895ef1..e62e967 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/software_response_builder.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/software_response_builder.rb @@ -2,7 +2,7 @@ module WarpEngine module SoftwareResponseBuilder private - def build_response(software, releases, download_counts) + def build_response(software, releases, download_counts, subject: nil) sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first # web-playable, ha az utolsó (stabil) release-nek van webes assetje @@ -14,10 +14,33 @@ module WarpEngine latest: latest, web_playable: web_playable, total_downloads: total_downloads, - download_counts: download_counts + download_counts: download_counts, + access: access_for(software, subject) ) end + # Always present, even under the open policy: a client should never have to tell + # "this catalog says nothing about access" from "this title is not gated". One of + # those is a question and the other is an answer. + def access_for(software, subject) + WarpEngine.access_policy.access_for(software: software, subject: subject) + rescue StandardError => e + # A policy that raises must not take the catalog down with it. The open answer is + # wrong here, so the closed one is what a broken policy gets: a title nobody can + # download is recoverable, a paid title handed out for free is not. + Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") + WarpEngine::Access.new(gated: true, entitled: false) + end + + # The titles this subject may see. A policy that raises empties the catalog rather + # than leaking it — the safe direction — but it is still a bug, so it is logged. + def visible_scope(subject) + WarpEngine.access_policy.visible_software_scope(subject: subject) + rescue StandardError => e + Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}") + WarpEngine::Software.none + end + # Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett). def download_counts_for(release_ids) return {} if release_ids.empty? diff --git a/libs/ruby/warp_engine/app/services/warp_engine/software_service.rb b/libs/ruby/warp_engine/app/services/warp_engine/software_service.rb index 06ad14e..e3791a8 100644 --- a/libs/ruby/warp_engine/app/services/warp_engine/software_service.rb +++ b/libs/ruby/warp_engine/app/services/warp_engine/software_service.rb @@ -2,11 +2,16 @@ module WarpEngine class SoftwareService include SoftwareResponseBuilder - def index(owner_id: nil) - softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all + # `subject` is whoever the request authenticated as, or nil. It decides two things: + # which titles are listed at all (the policy's scope) and what each one's `access` + # block says about entitlement. + def index(owner_id: nil, subject: nil) + softwares = visible_scope(subject) + .includes(releases: [ :release_assets ]) + .includes(:external_links, :software_images) softwares = softwares.where(owner_id: owner_id) if owner_id.present? counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) }) - { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } } + { softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts, subject: subject) } } end end end diff --git a/libs/ruby/warp_engine/config/routes.rb b/libs/ruby/warp_engine/config/routes.rb index 410efa4..517907c 100644 --- a/libs/ruby/warp_engine/config/routes.rb +++ b/libs/ruby/warp_engine/config/routes.rb @@ -1,5 +1,17 @@ WarpEngine::Engine.routes.draw 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/highlighted", to: "software_highlighted#index" get "image/:id", to: "images#show" diff --git a/libs/ruby/warp_engine/db/migrate/20260819000001_create_device_grants.rb b/libs/ruby/warp_engine/db/migrate/20260819000001_create_device_grants.rb new file mode 100644 index 0000000..204d8d1 --- /dev/null +++ b/libs/ruby/warp_engine/db/migrate/20260819000001_create_device_grants.rb @@ -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 diff --git a/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb b/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb index 73b6901..6ed7bf7 100644 --- a/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb +++ b/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/create_warp_engine_tables.rb @@ -98,6 +98,28 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.index :deleted_at end + # Device sign-in for clients that have no browser of their own (RFC 8628). + # Short-lived rows: one exists for the minute or two between "the client asked" + # and "the person answered". Only used where access_token_owner_class is set. + create_table :device_grants do |t| + t.string :device_code, limit: 64, null: false + t.string :user_code, limit: 16, null: false + t.string :client_name, limit: 128 + t.string :subject_type, limit: 128 + t.bigint :subject_id + t.bigint :application_token_id + # The issued token in the clear, cleared on the poll that hands it over — see + # the model. Everything else about a token is stored as a digest. + t.string :issued_token, limit: 64 + t.datetime :approved_at, precision: 3 + t.datetime :denied_at, precision: 3 + t.datetime :expires_at, precision: 3, null: false + t.timestamps precision: 3, null: true + t.index :device_code, unique: true + t.index :user_code, unique: true + t.index :expires_at + end + create_table :downloads do |t| t.string :file_path, null: false t.references :release, foreign_key: { on_delete: :nullify }, index: false diff --git a/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/initializer.rb b/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/initializer.rb index 89d69a7..e160600 100644 --- a/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/initializer.rb +++ b/libs/ruby/warp_engine/lib/generators/warp_engine/install/templates/initializer.rb @@ -43,6 +43,26 @@ Rails.application.config.to_prepare do # softwares got an owner (backfill)! # c.enforce_software_ownership = true + # Who may see a title and who may download it. :open (the default) lists every + # software and serves every artifact — the behaviour of a catalog nobody sells + # from. A host that does sell supplies a policy answering three methods; see + # WarpEngine::AccessPolicy. What it returns is what clients are told, so a paid + # title can announce itself as paid instead of failing at the download. + # c.access_policy = MyStore::AccessPolicy.new + + # Client sign-in. nil (default) means there is none: /api/auth/* is inactive and + # GET /api/service reports auth: null, so a client offers no sign-in at all. Set + # the class a *client* token belongs to — usually your user model — to turn on + # the device authorization grant. + # c.access_token_owner_class = "User" + # + # Your own page where a signed-in person types the code their client displayed. + # It calls WarpEngine::DeviceGrantService#approve. A path is made absolute + # against the request, so you need not know your own hostname (default "/devices"). + # c.identity_verification_url = "/devices" + # c.device_code_ttl = 600 # seconds a pending code lives + # c.device_code_interval = 5 # seconds a client is told to wait between polls + # If host models also reference catalog images, register them so the # admin Images page's orphan detection takes them into account: # c.image_owners = [ diff --git a/libs/ruby/warp_engine/lib/warp_engine.rb b/libs/ruby/warp_engine/lib/warp_engine.rb index 7ba007b..12f415e 100644 --- a/libs/ruby/warp_engine/lib/warp_engine.rb +++ b/libs/ruby/warp_engine/lib/warp_engine.rb @@ -10,6 +10,7 @@ require "apipie-rails" require "warp_engine/version" require "warp_engine/configuration" require "warp_engine/storage" +require "warp_engine/access" module WarpEngine # A tábláink prefix nélküliek (softwares, releases, ...) — az isolate_namespace @@ -42,6 +43,17 @@ module WarpEngine def self.storage Storage.adapter end + + # Who may see and download what. :open by default — see WarpEngine::AccessPolicy. + def self.access_policy + AccessPolicy.current + end + + # The host has configured a subject class, so client sign-in is available. A client + # asks GET /api/service rather than this, but the engine's own controllers need it. + def self.identity_configured? + config.access_token_owner_class.present? + end end require "warp_engine/engine" diff --git a/libs/ruby/warp_engine/lib/warp_engine/access.rb b/libs/ruby/warp_engine/lib/warp_engine/access.rb new file mode 100644 index 0000000..ff7cb26 --- /dev/null +++ b/libs/ruby/warp_engine/lib/warp_engine/access.rb @@ -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 diff --git a/libs/ruby/warp_engine/lib/warp_engine/configuration.rb b/libs/ruby/warp_engine/lib/warp_engine/configuration.rb index 502782c..1e8790c 100644 --- a/libs/ruby/warp_engine/lib/warp_engine/configuration.rb +++ b/libs/ruby/warp_engine/lib/warp_engine/configuration.rb @@ -26,6 +26,22 @@ module WarpEngine # PEM, or a URL to fetch it from (e.g. https://ci.../api/signature/public-key). # With neither set, POST /build/config rejects every request. # ci_update_server: server URL written into the upload/publish steps; nil → the request's base_url. + # access_policy: who may see a title and who may download it. + # :open (default) — every software listed, every artifact served, no prices: + # byte for byte the previous behaviour; + # any object — must answer visible_software_scope/access_for/ + # authorize_download, see WarpEngine::AccessPolicy. + # access_token_owner_class: class name of the subject a *client* token belongs to + # (e.g. "Accounts::User"). nil (default) means no client sign-in: /api/auth/* is + # inactive and GET /api/service reports auth: null. Deliberately separate from + # application_token_owner_class, which owns *publishing* tokens — a publisher and + # a customer are rarely the same kind of thing. + # identity_verification_url: the host's own page where a person approves a device + # code. Path or absolute URL; nil falls back to "/devices". The page is the host's + # because approving needs a session, a login and HTML — none of which is the + # engine's business. + # device_code_ttl / device_code_interval: how long a pending device code lives, and + # how often a client is told to poll for it. # woodpecker_url / woodpecker_api_token / woodpecker_repo_owner: # Woodpecker CI management (repo sync, secret provisioning, pipeline control). # All nil → the management features are inactive. @@ -44,7 +60,12 @@ module WarpEngine :woodpecker_api_token, :woodpecker_repo_owner, :image_owners, - :storage_adapter + :storage_adapter, + :access_policy, + :access_token_owner_class, + :identity_verification_url, + :device_code_ttl, + :device_code_interval def initialize @file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") @@ -63,6 +84,11 @@ module WarpEngine @woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] @image_owners = [] @storage_adapter = :local + @access_policy = :open + @access_token_owner_class = nil + @identity_verification_url = nil + @device_code_ttl = 600 + @device_code_interval = 5 end end end diff --git a/libs/ruby/warp_engine/lib/warp_engine/version.rb b/libs/ruby/warp_engine/lib/warp_engine/version.rb index 02a8188..991e73b 100644 --- a/libs/ruby/warp_engine/lib/warp_engine/version.rb +++ b/libs/ruby/warp_engine/lib/warp_engine/version.rb @@ -1,5 +1,5 @@ module WarpEngine - VERSION = "0.4.0" + VERSION = "0.5.0" # The header every API response carries. Named here rather than written out at the one # place that sets it: clients read it, the README documents it, and a string in three diff --git a/libs/ruby/warp_engine/spec/dummy/db/schema.rb b/libs/ruby/warp_engine/spec/dummy/db/schema.rb index cfb98ef..6dbcfdf 100644 --- a/libs/ruby/warp_engine/spec/dummy/db/schema.rb +++ b/libs/ruby/warp_engine/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.datetime "created_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 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| t.datetime "created_at", precision: 3 t.datetime "deleted_at", precision: 3 diff --git a/libs/ruby/warp_engine/spec/requests/device_auth_spec.rb b/libs/ruby/warp_engine/spec/requests/device_auth_spec.rb new file mode 100644 index 0000000..4583810 --- /dev/null +++ b/libs/ruby/warp_engine/spec/requests/device_auth_spec.rb @@ -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 diff --git a/libs/ruby/warp_engine/spec/requests/service_controller_spec.rb b/libs/ruby/warp_engine/spec/requests/service_controller_spec.rb new file mode 100644 index 0000000..4c9bf04 --- /dev/null +++ b/libs/ruby/warp_engine/spec/requests/service_controller_spec.rb @@ -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 diff --git a/libs/ruby/warp_engine/spec/services/access_policy_spec.rb b/libs/ruby/warp_engine/spec/services/access_policy_spec.rb new file mode 100644 index 0000000..14f0054 --- /dev/null +++ b/libs/ruby/warp_engine/spec/services/access_policy_spec.rb @@ -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