From a217bcc14e71a4f1d520211dffe18f3a0fab8350 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 5 Aug 2026 18:30:20 +0200 Subject: [PATCH] Add DB-backed application tokens for the update endpoint --- apps/api/config/initializers/warp_engine.rb | 5 + apps/api/db/schema.rb | 19 ++- libs/ruby/warp_engine/README.md | 32 ++++- .../app/admin/application_tokens.rb | 96 ++++++++++++++ .../warp_engine/update_controller.rb | 30 ++++- .../models/warp_engine/application_token.rb | 99 ++++++++++++++ ...0260805000001_create_application_tokens.rb | 22 ++++ .../templates/create_warp_engine_tables.rb | 16 +++ .../install/templates/initializer.rb | 8 ++ .../lib/warp_engine/configuration.rb | 17 ++- .../spec/dummy/app/models/test_owner.rb | 3 + .../20260805000002_create_test_owners.rb | 10 ++ libs/ruby/warp_engine/spec/dummy/db/schema.rb | 38 +++++- .../spec/factories/application_tokens.rb | 17 +++ .../spec/models/application_token_spec.rb | 124 ++++++++++++++++++ .../spec/requests/update_controller_spec.rb | 89 +++++++++++++ 16 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 libs/ruby/warp_engine/app/admin/application_tokens.rb create mode 100644 libs/ruby/warp_engine/app/models/warp_engine/application_token.rb create mode 100644 libs/ruby/warp_engine/db/migrate/20260805000001_create_application_tokens.rb create mode 100644 libs/ruby/warp_engine/spec/dummy/app/models/test_owner.rb create mode 100644 libs/ruby/warp_engine/spec/dummy/db/migrate/20260805000002_create_test_owners.rb create mode 100644 libs/ruby/warp_engine/spec/factories/application_tokens.rb create mode 100644 libs/ruby/warp_engine/spec/models/application_token_spec.rb diff --git a/apps/api/config/initializers/warp_engine.rb b/apps/api/config/initializers/warp_engine.rb index fa2278f..5574e30 100644 --- a/apps/api/config/initializers/warp_engine.rb +++ b/apps/api/config/initializers/warp_engine.rb @@ -2,6 +2,11 @@ # értékadás (nem <<), hogy idempotens legyen. Rails.application.config.to_prepare do WarpEngine.configure do |c| + # A /update DB-tokenjeinek tulajdonosa. A :database módra váltás + # (c.update_secret_source = :database) csak azután jöhet, hogy a CI már + # DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et. + c.application_token_owner_class = "AdminUser" + c.image_owners = [ { label: "member", diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 6265d83..f8472db 100644 --- a/apps/api/db/schema.rb +++ b/apps/api/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_04_000002) do +ActiveRecord::Schema[8.1].define(version: 2026_08_05_000001) do create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "deleted_at", precision: 3 @@ -27,6 +27,23 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true end + 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 + t.datetime "expires_at", precision: 3 + t.datetime "last_used_at", precision: 3 + t.string "name", limit: 128, null: false + t.bigint "owner_id", null: false, unsigned: true + t.string "owner_type", limit: 128, null: false + t.json "scopes" + t.string "token_digest", limit: 64, null: false + t.string "token_prefix", limit: 12, null: false + t.datetime "updated_at", precision: 3 + t.index ["deleted_at"], name: "idx_application_tokens_deleted_at" + t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner" + t.index ["token_digest"], name: "idx_application_tokens_token_digest", 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/README.md b/libs/ruby/warp_engine/README.md index 2cef9ad..b9ae77b 100644 --- a/libs/ruby/warp_engine/README.md +++ b/libs/ruby/warp_engine/README.md @@ -15,7 +15,9 @@ Repository: `https://git.teletypegames.org/tools/warp_engine` - **CI-callable updater**: your build pipeline drops artifacts into a directory and calls one endpoint — WarpEngine extracts archives, parses metadata and upserts the catalog records. Supported platforms out of the - box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. + 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). - **Public JSON API**: catalog listing, highlighted title, per-platform build matrix, image serving, download tracking, and a static file server for web-playable builds. @@ -177,6 +179,15 @@ Rails.application.config.to_prepare do # nil => the endpoint rejects every request. c.update_secret = ENV["UPDATE_SECRET"] + # Authentication source for /update — an exclusive choice: + # :env — the shared secret above is accepted (default) + # :database — only WarpEngine::ApplicationToken records with the + # "update" scope are accepted; the shared secret stops + # working the moment you switch. + # :database mode also requires the owner class every token belongs to: + # c.update_secret_source = :database + # c.application_token_owner_class = "AdminUser" + # If your app's own models reference catalog images, register them so the # admin Images page counts them as "in use": # c.image_owners = [ @@ -210,6 +221,25 @@ comment header for TIC-80), and upserts the `Software`, `ExternalLink`, `Release` and `ReleaseAsset` records in a single transaction. Previously deleted records are resurrected on re-ingest. +### Updater authentication + +The `X-Update-Secret` header (or the `?secret=` query param) carries one of +two credentials, selected by `update_secret_source` — the modes are +exclusive, the endpoint never accepts both: + +- **`:env`** (default): the single shared secret from `update_secret`. +- **`:database`**: `WarpEngine::ApplicationToken` records. Each token + belongs to an owner (the class named by `application_token_owner_class`, + e.g. `AdminUser`), carries a free-form scope list — `/update` requires the + `"update"` scope — and an optional expiry. Tokens are created in the admin + (*App Tokens*): the plain token is generated server-side and shown exactly + once after creation; only its SHA256 digest is stored. Deleting a token in + the admin revokes it (soft delete), and `last_used_at` records when each + token last authenticated successfully. + +When switching to `:database`, create the tokens and move your pipelines to +them first — the flip invalidates the shared secret immediately. + ## Public API | Endpoint | Purpose | diff --git a/libs/ruby/warp_engine/app/admin/application_tokens.rb b/libs/ruby/warp_engine/app/admin/application_tokens.rb new file mode 100644 index 0000000..5caa103 --- /dev/null +++ b/libs/ruby/warp_engine/app/admin/application_tokens.rb @@ -0,0 +1,96 @@ +ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do + actions :index, :show, :new, :create, :edit, :update, :destroy + permit_params :name, :owner_id, :expires_at, :scopes_string + + menu priority: 9, label: "🎟️ App Tokens" + + config.sort_order = "created_at_desc" + config.batch_actions = false + + 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) } + + index do + id_column + column :name + column("Token") { |t| code "#{t.token_prefix}…", style: "font-family:monospace;" } + column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" } + column("Scopes") { |t| t.scopes_string } + column :expires_at + column :last_used_at + column :created_at + actions + end + + filter :name_cont, label: "Name" + filter :token_prefix_cont, label: "Token prefix" + filter :expires_at + filter :last_used_at + + form do |f| + owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize + f.inputs do + if f.object.new_record? + if owner_class + f.input :owner_id, as: :select, label: owner_class.name, + collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] }, + include_blank: false + else + f.template.concat(f.template.content_tag(:li, + "application_token_owner_class nincs beállítva — token nem hozható létre.", + class: "flash flash_error")) + end + end + f.input :name + f.input :scopes_string, label: "Scopes (comma separated)", + hint: %(A /update végponthoz az "update" scope kell.) + f.input :expires_at, hint: "Üresen hagyva sosem jár le." + end + f.actions + end + + show do + if (plain = controller.instance_variable_get(:@plain_token)) + panel "⚠️ Token — csak most látható, másold ki!" do + pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;" + end + end + attributes_table do + row :id + row :name + row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest tárolva)" } + row("Owner") { |t| "#{t.owner_type} ##{t.owner_id} — #{t.owner.try(:email) || t.owner.try(:name)}" } + row("Scopes") { |t| t.scopes_string } + row :expires_at + row :last_used_at + row :created_at + row :updated_at + end + end + + controller do + # A plain token csak közvetlenül a létrehozás után létezik; a session-ön át + # jut el az egyszeri megjelenítésig (a flash nem jó: az AA layout minden + # flash kulcsot üzenetsávként renderel). + def create + create! do |success, _failure| + success.html do + session[:warp_engine_plain_token] = resource.plain_token + redirect_to resource_path(resource) and return + end + end + end + + def show + @plain_token = session.delete(:warp_engine_plain_token) + show! + end + + # Revoke = soft delete, audit-nyommal. + def destroy + resource.revoke! + redirect_to collection_path, notice: "Token revoked." + end + end +end diff --git a/libs/ruby/warp_engine/app/controllers/warp_engine/update_controller.rb b/libs/ruby/warp_engine/app/controllers/warp_engine/update_controller.rb index 0f69c54..dc4f6fa 100644 --- a/libs/ruby/warp_engine/app/controllers/warp_engine/update_controller.rb +++ b/libs/ruby/warp_engine/app/controllers/warp_engine/update_controller.rb @@ -15,7 +15,7 @@ module WarpEngine end api :GET, "/update", "Update software version in database" - param :secret, String, required: true, desc: "Authorization secret" + param :secret, String, required: true, desc: "Shared secret or application token (X-Update-Secret header preferred)" param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)" param :name, String, required: false, desc: "Software name" param :version, String, required: true, desc: "Version string" @@ -39,11 +39,35 @@ module WarpEngine private + # A hitelesítési forrás kizárólagos: :database módban a shared secret nem + # érvényes, :env módban a DB-tokenek nem. def authorized? - secret = request.headers["X-Update-Secret"].presence || params[:secret] + token = request.headers["X-Update-Secret"].presence || params[:secret].presence + return false if token.blank? + + case WarpEngine.config.update_secret_source + when :database then database_token_authorized?(token) + else env_secret_authorized?(token) + end + end + + def env_secret_authorized?(token) expected = WarpEngine.config.update_secret # Konfigurálatlan secret esetén az endpoint zárva marad. - expected.present? && secret == expected + expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected) + end + + def database_token_authorized?(token) + if WarpEngine.config.application_token_owner_class.blank? + Rails.logger.error("[UpdateController] update_secret_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva") + return false + end + + record = WarpEngine::ApplicationToken.authenticate(token, required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE) + return false if record.nil? + + record.touch_last_used! + true end end end 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 new file mode 100644 index 0000000..a6916d5 --- /dev/null +++ b/libs/ruby/warp_engine/app/models/warp_engine/application_token.rb @@ -0,0 +1,99 @@ +require "digest" + +module WarpEngine + class ApplicationToken < ApplicationRecord + self.table_name = "application_tokens" + + UPDATE_SCOPE = "update".freeze + + # A generált token csak létrehozáskor, memóriában érhető el — a DB-ben + # kizárólag a SHA256 digest és a nem-titkos prefix tárolódik. + attr_reader :plain_token + + belongs_to :owner, polymorphic: true + + default_scope { where(deleted_at: nil) } + scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } + + before_validation :assign_owner_type, on: :create + before_validation :generate_token, on: :create + after_initialize { self.scopes = [] if new_record? && scopes.nil? } + + validates :name, presence: true + validates :token_digest, presence: true, uniqueness: true + validates :token_prefix, presence: true + validates :scopes, presence: true + validate :owner_type_matches_configuration + + def self.digest(token) + Digest::SHA256.hexdigest(token) + end + + # Az élő (nem törölt, nem lejárt), a kért scope-pal rendelkező token, különben nil. + def self.authenticate(token, required_scope: nil) + return nil if token.blank? + + record = active.find_by(token_digest: digest(token)) + return nil if record.nil? + return nil if required_scope.present? && !Array(record.scopes).include?(required_scope) + + record + end + + def expired? + expires_at.present? && expires_at <= Time.current + end + + # Visszavonás = soft delete, az audit-nyom megmarad. + def revoke! + update_column(:deleted_at, Time.current) + end + + def touch_last_used! + update_column(:last_used_at, Time.current) + end + + # Admin form: vesszővel elválasztott scope-lista + def scopes_string + Array(scopes).join(", ") + end + + def scopes_string=(value) + self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq + end + + def self.ransackable_attributes(auth_object = nil) + %w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix updated_at] + end + + # A polimorf owner asszociációra a Ransack nem tud szűrni. + def self.ransackable_associations(auth_object = nil) + [] + end + + private + + def assign_owner_type + self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank? + end + + def generate_token + return if token_digest.present? + + @plain_token = SecureRandom.hex(24) + self.token_prefix = @plain_token.first(8) + self.token_digest = self.class.digest(@plain_token) + end + + def owner_type_matches_configuration + expected = WarpEngine.config.application_token_owner_class + if expected.blank? + errors.add(:base, "application_token_owner_class is not configured") + elsif owner_type != expected + errors.add(:owner_type, "must be #{expected}") + end + end + + ActiveSupport.run_load_hooks(:warp_engine_application_token, self) + end +end diff --git a/libs/ruby/warp_engine/db/migrate/20260805000001_create_application_tokens.rb b/libs/ruby/warp_engine/db/migrate/20260805000001_create_application_tokens.rb new file mode 100644 index 0000000..7212a9b --- /dev/null +++ b/libs/ruby/warp_engine/db/migrate/20260805000001_create_application_tokens.rb @@ -0,0 +1,22 @@ +class CreateApplicationTokens < ActiveRecord::Migration[8.1] + def change + create_table :application_tokens, id: { type: :bigint, unsigned: true }, + charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| + t.string :name, limit: 128, null: false + # Az owner osztályát a host adja (WarpEngine.config.application_token_owner_class), + # ezért nem lehet FK. + t.string :owner_type, limit: 128, null: false + t.bigint :owner_id, null: false, unsigned: true + t.string :token_digest, limit: 64, null: false + t.string :token_prefix, limit: 12, null: false + t.json :scopes + t.datetime :expires_at, precision: 3 + t.datetime :last_used_at, precision: 3 + t.datetime :deleted_at, precision: 3 + t.timestamps precision: 3, null: true + t.index :token_digest, name: "idx_application_tokens_token_digest", unique: true + t.index [ :owner_type, :owner_id ], name: "idx_application_tokens_owner" + t.index :deleted_at, name: "idx_application_tokens_deleted_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 47f52e7..cf69239 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 @@ -75,6 +75,22 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0] t.index [ :software_id, :position ] end + create_table :application_tokens do |t| + t.string :name, limit: 128, null: false + t.string :owner_type, limit: 128, null: false + t.bigint :owner_id, null: false + t.string :token_digest, limit: 64, null: false + t.string :token_prefix, limit: 12, null: false + t.json :scopes + t.datetime :expires_at, precision: 3 + t.datetime :last_used_at, precision: 3 + t.datetime :deleted_at, precision: 3 + t.timestamps precision: 3, null: true + t.index :token_digest, unique: true + t.index [ :owner_type, :owner_id ] + t.index :deleted_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 24657a6..ea2cb88 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 @@ -9,6 +9,14 @@ Rails.application.config.to_prepare do # Beállítatlan secret esetén az endpoint minden kérést elutasít. # c.update_secret = ENV["UPDATE_SECRET"] + # A /update hitelesítési forrása — kizárólagos választás: + # :env — a fenti shared secret érvényes (default) + # :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes + # ("update" scope-pal); a shared secret ilyenkor NEM működik. + # A :database módhoz kötelező a tokenek tulajdonos-osztálya is: + # c.update_secret_source = :database + # c.application_token_owner_class = "AdminUser" + # Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket, # hogy az admin Images oldal orphan-detektálása figyelembe vegye: # c.image_owners = [ diff --git a/libs/ruby/warp_engine/lib/warp_engine/configuration.rb b/libs/ruby/warp_engine/lib/warp_engine/configuration.rb index 581b322..e629785 100644 --- a/libs/ruby/warp_engine/lib/warp_engine/configuration.rb +++ b/libs/ruby/warp_engine/lib/warp_engine/configuration.rb @@ -4,16 +4,25 @@ module WarpEngine # label: String # image_ids: -> { Array } — az owner által használt image id-k # usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja + # update_secret_source: a /update endpoint hitelesítési forrása, kizárólagos. + # :env — a shared secret (update_secret) érvényes, a DB-tokenek nem + # :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem + # application_token_owner_class: a tokenek kötelező tulajdonosának osztályneve + # (pl. "AdminUser"); nil esetén a :database mód minden kérést elutasít. attr_accessor :file_container_path, :image_container_path, :update_secret, + :update_secret_source, + :application_token_owner_class, :image_owners def initialize - @file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") - @image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") - @update_secret = ENV["UPDATE_SECRET"] - @image_owners = [] + @file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares") + @image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images") + @update_secret = ENV["UPDATE_SECRET"] + @update_secret_source = :env + @application_token_owner_class = nil + @image_owners = [] end end end diff --git a/libs/ruby/warp_engine/spec/dummy/app/models/test_owner.rb b/libs/ruby/warp_engine/spec/dummy/app/models/test_owner.rb new file mode 100644 index 0000000..992c803 --- /dev/null +++ b/libs/ruby/warp_engine/spec/dummy/app/models/test_owner.rb @@ -0,0 +1,3 @@ +# Csak a dummy app tesztjeihez: az ApplicationToken owner szerepét tölti be. +class TestOwner < ActiveRecord::Base +end diff --git a/libs/ruby/warp_engine/spec/dummy/db/migrate/20260805000002_create_test_owners.rb b/libs/ruby/warp_engine/spec/dummy/db/migrate/20260805000002_create_test_owners.rb new file mode 100644 index 0000000..f08a18c --- /dev/null +++ b/libs/ruby/warp_engine/spec/dummy/db/migrate/20260805000002_create_test_owners.rb @@ -0,0 +1,10 @@ +# Csak a tesztekhez: az ApplicationToken owner-e (a hostban ez pl. AdminUser). +class CreateTestOwners < ActiveRecord::Migration[8.1] + def change + create_table :test_owners, id: { type: :bigint, unsigned: true }, + charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| + t.string :name, limit: 128 + t.timestamps precision: 3, null: true + end + end +end diff --git a/libs/ruby/warp_engine/spec/dummy/db/schema.rb b/libs/ruby/warp_engine/spec/dummy/db/schema.rb index 32f8c5c..d16be21 100644 --- a/libs/ruby/warp_engine/spec/dummy/db/schema.rb +++ b/libs/ruby/warp_engine/spec/dummy/db/schema.rb @@ -1,5 +1,33 @@ -# A katalógus-táblák a host schema.rb-vel megegyező definícióval. -ActiveRecord::Schema[8.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_08_05_000002) 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 + t.datetime "expires_at", precision: 3 + t.datetime "last_used_at", precision: 3 + t.string "name", limit: 128, null: false + t.bigint "owner_id", null: false, unsigned: true + t.string "owner_type", limit: 128, null: false + t.json "scopes" + t.string "token_digest", limit: 64, null: false + t.string "token_prefix", limit: 12, null: false + t.datetime "updated_at", precision: 3 + t.index ["deleted_at"], name: "idx_application_tokens_deleted_at" + t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner" + t.index ["token_digest"], name: "idx_application_tokens_token_digest", 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 @@ -99,6 +127,12 @@ ActiveRecord::Schema[8.1].define(version: 1) do t.index ["name"], name: "idx_softwares_name", unique: true end + create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", precision: 3 + t.string "name", limit: 128 + t.datetime "updated_at", precision: 3 + end + add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade diff --git a/libs/ruby/warp_engine/spec/factories/application_tokens.rb b/libs/ruby/warp_engine/spec/factories/application_tokens.rb new file mode 100644 index 0000000..43dc525 --- /dev/null +++ b/libs/ruby/warp_engine/spec/factories/application_tokens.rb @@ -0,0 +1,17 @@ +# A TestOwner csak a dummy appban létezik — host-oldali használatnál az owner-t +# felül kell írni (pl. owner: create(:admin_user)). +FactoryBot.define do + factory :test_owner, class: "TestOwner" do + name { "test owner" } + end + + factory :application_token, class: "WarpEngine::ApplicationToken" do + name { "CI token" } + scopes { [ "update" ] } + association :owner, factory: :test_owner + + trait :expired do + expires_at { 1.hour.ago } + end + end +end diff --git a/libs/ruby/warp_engine/spec/models/application_token_spec.rb b/libs/ruby/warp_engine/spec/models/application_token_spec.rb new file mode 100644 index 0000000..6419d55 --- /dev/null +++ b/libs/ruby/warp_engine/spec/models/application_token_spec.rb @@ -0,0 +1,124 @@ +require "rails_helper" + +RSpec.describe WarpEngine::ApplicationToken, type: :model do + before do + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + end + + describe "token generation" do + it "generates a plain token on create and stores only its digest and prefix" do + token = create(:application_token) + + expect(token.plain_token).to match(/\A\h{48}\z/) + expect(token.token_prefix).to eq(token.plain_token.first(8)) + expect(token.token_digest).to eq(Digest::SHA256.hexdigest(token.plain_token)) + end + + it "does not expose the plain token on a reloaded record" do + token = create(:application_token) + + expect(described_class.find(token.id).plain_token).to be_nil + end + end + + describe "validations" do + it "requires an owner" do + token = build(:application_token, owner: nil) + + expect(token).not_to be_valid + expect(token.errors[:owner]).to be_present + end + + it "requires a name" do + expect(build(:application_token, name: nil)).not_to be_valid + end + + it "requires at least one scope" do + expect(build(:application_token, scopes: [])).not_to be_valid + end + + it "fills owner_type from the configuration" do + token = create(:application_token) + + expect(token.owner_type).to eq("TestOwner") + end + + it "rejects an owner_type differing from the configuration" do + token = build(:application_token, owner_type: "WarpEngine::Software") + + expect(token).not_to be_valid + expect(token.errors[:owner_type]).to be_present + end + + it "rejects creation when no owner class is configured" do + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil) + token = build(:application_token, owner_type: "TestOwner") + + expect(token).not_to be_valid + expect(token.errors[:base]).to be_present + end + end + + describe "#scopes_string" do + it "round-trips a comma separated list" do + token = build(:application_token) + token.scopes_string = "update, deploy,update ," + + expect(token.scopes).to eq(%w[update deploy]) + expect(token.scopes_string).to eq("update, deploy") + end + end + + describe ".authenticate" do + it "returns the token for a valid plain token and scope" do + token = create(:application_token) + + expect(described_class.authenticate(token.plain_token, required_scope: "update")).to eq(token) + end + + it "returns nil for a blank or unknown token" do + create(:application_token) + + expect(described_class.authenticate(nil)).to be_nil + expect(described_class.authenticate("")).to be_nil + expect(described_class.authenticate("nem-letezo")).to be_nil + end + + it "returns nil when the required scope is missing" do + token = create(:application_token, scopes: [ "deploy" ]) + + expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil + end + + it "returns nil for an expired token" do + token = create(:application_token, :expired) + + expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil + end + + it "returns nil for a revoked token" do + token = create(:application_token) + token.revoke! + + expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil + end + end + + describe "#revoke!" do + it "soft deletes the token" do + token = create(:application_token) + token.revoke! + + expect(described_class.find_by(id: token.id)).to be_nil + expect(described_class.unscoped.find(token.id).deleted_at).to be_present + end + end + + describe "#touch_last_used!" do + it "stamps last_used_at" do + token = create(:application_token) + + expect { token.touch_last_used! }.to change { token.reload.last_used_at }.from(nil) + end + end +end diff --git a/libs/ruby/warp_engine/spec/requests/update_controller_spec.rb b/libs/ruby/warp_engine/spec/requests/update_controller_spec.rb index b623386..d730bd3 100644 --- a/libs/ruby/warp_engine/spec/requests/update_controller_spec.rb +++ b/libs/ruby/warp_engine/spec/requests/update_controller_spec.rb @@ -43,4 +43,93 @@ RSpec.describe "GET /update", type: :request do expect(response).to have_http_status(:ok) expect(response.body).to eq("Updated") end + + it "rejects a database token in :env mode" do + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + token = create(:application_token) + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + context "with update_secret_source :database" do + before do + allow(WarpEngine.config).to receive(:update_secret_source).and_return(:database) + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner") + end + + def stub_updater + updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service) + allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater) + allow(updater).to receive(:update) + end + + it "runs the updater with a valid token and stamps last_used_at" do + stub_updater + token = create(:application_token) + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:ok) + expect(response.body).to eq("Updated") + expect(token.reload.last_used_at).to be_present + end + + it "accepts the token via the secret param" do + stub_updater + token = create(:application_token) + + get "/update", params: { secret: token.plain_token, platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:ok) + end + + it "rejects the ENV shared secret" do + get "/update", headers: { "X-Update-Secret" => "s3cret" }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects a token without the update scope" do + token = create(:application_token, scopes: [ "deploy" ]) + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects an expired token" do + token = create(:application_token, :expired) + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects a revoked token" do + token = create(:application_token) + token.revoke! + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects every request when no owner class is configured" do + token = create(:application_token) + allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil) + + get "/update", headers: { "X-Update-Secret" => token.plain_token }, + params: { platform: "tic80", name: "game", version: "1.0" } + + expect(response).to have_http_status(:unauthorized) + end + end end