Add DB-backed application tokens for the update endpoint

This commit is contained in:
2026-08-05 18:30:20 +02:00
parent 60e196a03e
commit 710594efda
14 changed files with 591 additions and 10 deletions
+31 -1
View File
@@ -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 |
+96
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = [
+13 -4
View File
@@ -4,16 +4,25 @@ module WarpEngine
# label: String
# image_ids: -> { Array<Integer> } — 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
+3
View File
@@ -0,0 +1,3 @@
# Csak a dummy app tesztjeihez: az ApplicationToken owner szerepét tölti be.
class TestOwner < ActiveRecord::Base
end
@@ -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
+36 -2
View File
@@ -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
+17
View File
@@ -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
+124
View File
@@ -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
+89
View File
@@ -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