diff --git a/.config/cucumber.yml b/.config/cucumber.yml deleted file mode 100644 index ecb6857..0000000 --- a/.config/cucumber.yml +++ /dev/null @@ -1 +0,0 @@ -default: --format progress diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a12ece..5aeb7b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,137 +2,37 @@ name: CI on: push: - branches: [ master ] + branches: [ master, "upgrade/**" ] pull_request: - branches: [ master ] permissions: contents: read jobs: test: - name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}, PG ${{ matrix.postgres-version }} + name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - ruby-version: ['3.1'] - gemfile: [rails_6.1] - postgres-version: ['12'] - include: - - ruby-version: '2.1' - gemfile: rails_3.2 - postgres-version: 10 - - ruby-version: '2.1' - gemfile: rails_4.0 - postgres-version: 10 - - ruby-version: '2.1' - gemfile: rails_4.1 - postgres-version: 10 - - ruby-version: '2.1' - gemfile: rails_4.2 - postgres-version: 12 - - - ruby-version: '2.2' - gemfile: rails_3.2 - postgres-version: 10 - - ruby-version: '2.2' - gemfile: rails_4.0 - postgres-version: 10 - - ruby-version: '2.2' - gemfile: rails_4.1 - postgres-version: 10 - - ruby-version: '2.2' - gemfile: rails_4.2 - postgres-version: 12 - - ruby-version: '2.2' - gemfile: rails_5.0 - postgres-version: 12 - - ruby-version: '2.2' - gemfile: rails_5.1 - postgres-version: 12 - - ruby-version: '2.2' - gemfile: rails_5.2 - postgres-version: 12 - - - ruby-version: '2.3' - gemfile: rails_3.2 - postgres-version: 10 - - ruby-version: '2.3' - gemfile: rails_4.2 - postgres-version: 12 - - ruby-version: '2.3' - gemfile: rails_5.0 - postgres-version: 12 - - ruby-version: '2.3' - gemfile: rails_5.1 - postgres-version: 12 - - ruby-version: '2.3' - gemfile: rails_5.2 - postgres-version: 12 - - - ruby-version: '2.4' - gemfile: rails_4.2 - postgres-version: 12 - - ruby-version: '2.4' - gemfile: rails_5.0 - postgres-version: 12 - - ruby-version: '2.4' - gemfile: rails_5.1 - postgres-version: 12 - - ruby-version: '2.4' - gemfile: rails_5.2 - postgres-version: 12 - - - ruby-version: '2.5' - gemfile: rails_5.0 - postgres-version: 12 - - ruby-version: '2.5' - gemfile: rails_5.1 - postgres-version: 12 - - ruby-version: '2.5' - gemfile: rails_5.2 - postgres-version: 12 - - ruby-version: '2.5' - gemfile: rails_6.0 - postgres-version: 12 - - ruby-version: '2.5' - gemfile: rails_6.1 - postgres-version: 12 - - - ruby-version: '2.6' - gemfile: rails_5.0 - postgres-version: 12 - - ruby-version: '2.6' - gemfile: rails_5.1 - postgres-version: 12 - - ruby-version: '2.6' - gemfile: rails_5.2 - postgres-version: 12 - - ruby-version: '2.6' - gemfile: rails_6.0 - postgres-version: 12 - - ruby-version: '2.6' - gemfile: rails_6.1 - postgres-version: 12 - - - ruby-version: '2.7' - gemfile: rails_5.2 - postgres-version: 12 - - ruby-version: '2.7' - gemfile: rails_6.0 - postgres-version: 12 - - ruby-version: '2.7' - gemfile: rails_6.1 - postgres-version: 12 - - - ruby-version: '3.0' - gemfile: rails_6.0 - postgres-version: 12 - - ruby-version: '3.0' - gemfile: rails_6.1 - postgres-version: 12 + # upgrade target + - { ruby-version: '4.0.5', gemfile: rails_8.1 } + - { ruby-version: '3.4', gemfile: rails_8.1 } + - { ruby-version: '3.4', gemfile: rails_8.0 } + - { ruby-version: '3.4', gemfile: rails_7.2 } + - { ruby-version: '3.3', gemfile: rails_7.1 } + - { ruby-version: '3.3', gemfile: rails_7.0 } + # backward compat — explicit Vxx modules. i18n < 1.15 and cucumber 8.x + # are held back (gemspec) for Ruby < 3.2 / < 3.0. Ruby 2.1–2.6 are not + # tested: Bundler 2 needs Ruby >= 2.3 and 2.3–2.6 no longer assemble on + # current runners. + - { ruby-version: '3.2', gemfile: rails_6.1 } + - { ruby-version: '3.1', gemfile: rails_6.1 } + - { ruby-version: '3.0', gemfile: rails_6.1 } + - { ruby-version: '2.7', gemfile: rails_6.1 } + - { ruby-version: '2.7', gemfile: rails_5.2 } env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile @@ -143,40 +43,17 @@ jobs: - uses: ankane/setup-postgres@v1 with: - postgres-version: ${{ matrix.postgres-version }} + postgres-version: 16 database: eaco - - name: Decide RubyGems - id: setup-params - run: | - rv="${{ matrix.ruby-version }}" - gf="${{ matrix.gemfile }}" - - # Default RubyGems - rubygems=default - - # Only for: - # - Ruby 2.3 + rails_3.2 - # - Ruby 2.3 + rails_4.2 - # - Ruby 2.4 + rails_4.2 - if { [ "$rv" = "2.3" ] && { [ "$gf" = "rails_3.2" ] || [ "$gf" = "rails_4.2" ]; }; } || - { [ "$rv" = "2.4" ] && [ "$gf" = "rails_4.2" ]; }; then - rubygems=2.7.11 - fi - - echo "version=$rubygems" >> "$GITHUB_OUTPUT" - - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - rubygems: ${{ steps.setup-params.outputs.version }} - name: Run Specs - run: | - bundle exec rspec + run: bundle exec rspec - name: Run Cucumber features - run: | - bundle exec cucumber -f pretty + run: bundle exec cucumber -f progress diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..4c41601 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,42 @@ +name: Smoke + +# Fast, Postgres-free authorization smoke for the Ruby 4 / Rails 8.1 upgrade. +# The full pg + cucumber matrix lives in ci.yml; this proves the compatibility +# layer installs and Actor#can? resolves on the new stack (and still on the old +# one) using an in-memory ACL over sqlite. + +on: + push: + branches: [ master, "upgrade/**" ] + pull_request: + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # upgrade target + - { ruby: "4.0.5", rails: "~> 8.1.0" } + - { ruby: "3.4", rails: "~> 8.1.0" } + - { ruby: "3.4", rails: "~> 7.2.0" } + # backward compat — consumers still on the old stack (explicit V61 + # support module, sqlite3 1.4 for Ruby 2.7) + - { ruby: "2.7", rails: "~> 6.1.0", sqlite3: "~> 1.4.0" } + name: "ruby ${{ matrix.ruby }} / rails ${{ matrix.rails }}" + env: + BUNDLE_GEMFILE: test/Gemfile.ci + RAILS_VERSION: ${{ matrix.rails }} + SQLITE3_VERSION: ${{ matrix.sqlite3 }} + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Smoke (authorize on the version stack) + run: bundle exec ruby test/smoke.rb diff --git a/.gitignore b/.gitignore index dfdea9c..2c019bf 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ build-iPhoneSimulator/ # for a library or gem, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: Gemfile.lock +test/*.lock .ruby-version .ruby-gemset @@ -64,3 +65,6 @@ spec/debug.log features/active_record.yml features/active_record.log + +# Local-only Ruby 4.0.5 smoke runner (AI scratch) — not committed +Dockerfile.ai diff --git a/Appraisals b/Appraisals index 5c9b211..e0c80ec 100644 --- a/Appraisals +++ b/Appraisals @@ -64,3 +64,11 @@ end appraise 'rails-7.2' do gem 'rails', '~> 7.2.0' end + +appraise 'rails-8.0' do + gem 'rails', '~> 8.0.0' +end + +appraise 'rails-8.1' do + gem 'rails', '~> 8.1.0' +end diff --git a/CHANGELOG.md b/CHANGELOG.md index d25b9b7..e5171d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ project adheres to [Semantic Versioning](https://semver.org/) - Remove curly braces from `@see` tags (causes rendering issues) - Use fully-qualified constant names for `ACL#find_by_role` references (`Eaco::ACL#find_by_role`) +## 1.2.0 - 2026-06-18 + +### Added +* Rails 8.1 / Ruby 4.0.5 support. +* `Adapters::ActiveRecord::Compatibility::Modern` support module, used for + every Active Record major >= 7. Previously the compatibility layer raised + "Unsupported Active Record version" on anything past 6.1; new Rails majors + no longer require an identical per-version `Vxx` module. +* Self-contained smoke harness (`test/smoke.rb`) plus a CI matrix sweeping + Ruby 4.0.5/3.4/2.7 across Rails 8.1/7.2/6.1. + ## 1.1.1 - 2017-03-08 ### Fixed diff --git a/eaco.gemspec b/eaco.gemspec index d227ee9..1170335 100644 --- a/eaco.gemspec +++ b/eaco.gemspec @@ -25,7 +25,15 @@ Gem::Specification.new do |spec| spec.add_development_dependency "appraisal" spec.add_development_dependency "rspec" spec.add_development_dependency "guard-rspec" - spec.add_development_dependency "cucumber" + # Pin cucumber: unpinned, the resolver picks wildly different versions per row + # (10.x on some, the ancient 3.2.0 on Ruby 4) — 3.2.0 needs ostruct and the + # profile ERB path breaks on Ruby 3.4. ~> 9.2 is consistent and Ruby-4 clean, + # but cucumber 9 needs Ruby >= 3.0; the 2.7 row uses the last 8.x line. + if RUBY_VERSION >= '3.0' + spec.add_development_dependency "cucumber", "~> 9.2" + else + spec.add_development_dependency "cucumber", "~> 8.0" + end spec.add_development_dependency "guard-cucumber" spec.add_development_dependency "yard-cucumber" spec.add_development_dependency "coveralls" @@ -33,4 +41,14 @@ Gem::Specification.new do |spec| spec.add_development_dependency "multi_json" spec.add_development_dependency "rails" spec.add_development_dependency "pg" + # Left the default gems on Ruby 3.4+ — declare them there (and only there, so + # older rows don't pull a Ruby-3+ build of them). + if RUBY_VERSION >= '3.4' + spec.add_development_dependency "ostruct" + spec.add_development_dependency "base64" + spec.add_development_dependency "bigdecimal" + end + # i18n 1.15 uses Fiber storage (needs Ruby 3.2+); hold it back on the older + # backward-compat rows so Rails 6.1 boots there. + spec.add_development_dependency "i18n", "< 1.15" if RUBY_VERSION < '3.2' end diff --git a/features/authorization_parse_error.feature b/features/authorization_parse_error.feature index 5724225..ea150a3 100644 --- a/features/authorization_parse_error.feature +++ b/features/authorization_parse_error.feature @@ -10,7 +10,7 @@ Feature: Authorization rules error handling """ Then I should receive a DSL error SyntaxError saying """ - \(feature\):1: syntax error, unexpected '=', expecting end-of-input + \(feature\):1:.+unexpected '=' """ Scenario: Referencing a non-existing model diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile new file mode 100644 index 0000000..3b3765b --- /dev/null +++ b/gemfiles/rails_8.0.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 8.0.0" + +gemspec path: "../" diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile new file mode 100644 index 0000000..cedb65f --- /dev/null +++ b/gemfiles/rails_8.1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rails", "~> 8.1.0" + +gemspec path: "../" diff --git a/lib/eaco/adapters/active_record/compatibility.rb b/lib/eaco/adapters/active_record/compatibility.rb index ae1ee94..02a6706 100644 --- a/lib/eaco/adapters/active_record/compatibility.rb +++ b/lib/eaco/adapters/active_record/compatibility.rb @@ -16,6 +16,8 @@ class Compatibility autoload :V60, 'eaco/adapters/active_record/compatibility/v60.rb' autoload :V61, 'eaco/adapters/active_record/compatibility/v61.rb' + autoload :Modern, 'eaco/adapters/active_record/compatibility/modern.rb' + autoload :Scoped, 'eaco/adapters/active_record/compatibility/scoped.rb' autoload :Sanitized, 'eaco/adapters/active_record/compatibility/sanitized.rb' @@ -66,13 +68,28 @@ def active_record_version # @see check! # def support_module - unless self.class.const_defined?(support_module_name) - raise Eaco::Error, <<-EOF - Unsupported Active Record version: #{active_record_version} - EOF - end + return self.class.const_get(support_module_name) if + self.class.const_defined?(support_module_name) + + # No exact Vxx module: Active Record 7.0+ all share the same + # requirements, so fall back to {Modern} rather than raising on every + # new Rails release. Genuinely unknown/old versions still raise. + # The major is taken from the looked-up version (so a stubbed + # active_record_version is honoured), not the live constant. + return Modern if active_record_major >= 7 + + raise Eaco::Error, <<-EOF + Unsupported Active Record version: #{active_record_version} + EOF + end - self.class.const_get support_module_name + ## + # @return [Integer] the major of the {#active_record_version} being + # looked up. The minor is always a single digit in Rails, so the + # major is everything but the last character of the joined version. + # + def active_record_major + active_record_version.to_s[0..-2].to_i end ## diff --git a/lib/eaco/adapters/active_record/compatibility/modern.rb b/lib/eaco/adapters/active_record/compatibility/modern.rb new file mode 100644 index 0000000..d764103 --- /dev/null +++ b/lib/eaco/adapters/active_record/compatibility/modern.rb @@ -0,0 +1,31 @@ +module Eaco + module Adapters + module ActiveRecord + class Compatibility + + ## + # Active Record 7.0 and up support module. + # + # From 5.2 through the current releases the story is unchanged: JSONB + # works natively, while +.scoped+ and +sanitize+ stay removed and are + # revived through the {Scoped} and {Sanitized} support modules. + # + # This module is the fallback for every Active Record major >= 7, so a + # new identical +Vxx+ module is no longer needed on each Rails release. + # + # @see Scoped + # @see Sanitized + # + module Modern + extend ActiveSupport::Concern + + included do + extend Scoped + extend Sanitized + end + end + + end + end + end +end diff --git a/lib/eaco/railtie.rb b/lib/eaco/railtie.rb index f92fd02..99bd813 100644 --- a/lib/eaco/railtie.rb +++ b/lib/eaco/railtie.rb @@ -17,17 +17,14 @@ class Railtie < ::Rails::Railtie # # @!method parse_rules # - initializer 'eaco.parse_rules' do + initializer 'eaco.parse_rules' do |app| # :nocov: - Eaco.parse_default_rules_file! - - unless Rails.configuration.cache_classes - if defined? ActiveSupport::Reloader - ActiveSupport::Reloader.to_prepare { Eaco.parse_default_rules_file! } - else - ActionDispatch::Reloader.to_prepare { Eaco.parse_default_rules_file! } - end - end + # Parse in a to_prepare hook, not inline: the rules reference application + # models (e.g. ::Dossier), which under Zeitwerk (Rails 7+) cannot be + # autoloaded during initialization. to_prepare runs after the app is + # initialized (once at boot in every env, and again on each dev reload), + # by which point the autoloaders are ready. + app.config.to_prepare { Eaco.parse_default_rules_file! } # :nocov: end diff --git a/lib/eaco/version.rb b/lib/eaco/version.rb index 2e25f50..0cb5346 100644 --- a/lib/eaco/version.rb +++ b/lib/eaco/version.rb @@ -2,6 +2,6 @@ module Eaco # Current version # - VERSION = '1.1.2' + VERSION = '1.2.0' end diff --git a/spec/eaco/acl_spec.rb b/spec/eaco/acl_spec.rb index 1c9f876..d25bf6a 100644 --- a/spec/eaco/acl_spec.rb +++ b/spec/eaco/acl_spec.rb @@ -167,7 +167,9 @@ subject { acl.inspect } - it { expect(subject).to eq('#:bar}>') } + # Hash#inspect spacing changed in Ruby 3.4 ("a"=>1 -> "a" => 1); derive the + # expectation from the same Ruby so it holds on every version. + it { expect(subject).to eq("# :bar }.inspect }>") } end describe '#pretty_inspect' do @@ -177,7 +179,7 @@ subject { acl.pretty_inspect } - it { expect(subject).to eq("Eaco::ACL\n{\"foo\"=>:bar}\n") } + it { expect(subject).to eq("Eaco::ACL\n#{ { 'foo' => :bar }.pretty_inspect }") } end end diff --git a/test/Gemfile.ci b/test/Gemfile.ci new file mode 100644 index 0000000..c1bb496 --- /dev/null +++ b/test/Gemfile.ci @@ -0,0 +1,13 @@ +# CI / smoke Gemfile. eaco declares no runtime deps (the host app brings Rails), +# so we pin the stack here. RAILS_VERSION / SQLITE3_VERSION swept by the matrix. +source 'https://rubygems.org' + +# GitHub Actions sets an unspecified matrix var to "" (not unset), so a bare +# ENV.fetch(...) returns "" and `gem 'x', ""` raises Illformed requirement. +# Treat blank as absent. +ci = ->(name, default = nil) { v = ENV[name].to_s.strip; v.empty? ? default : v } + +gem 'eaco', path: '..' # runtime deps only (eaco declares none); skips pg/guard/etc. + +gem 'rails', ci.('RAILS_VERSION', '~> 8.1.0') +gem 'sqlite3', ci.('SQLITE3_VERSION', '>= 0') diff --git a/test/smoke.rb b/test/smoke.rb new file mode 100644 index 0000000..701158c --- /dev/null +++ b/test/smoke.rb @@ -0,0 +1,103 @@ +# Boot Active Record on the upgrade target (Ruby 4 / Rails 8.1) and exercise +# eaco's load-bearing path: the AR compatibility layer that USED to raise +# "Unsupported Active Record version" on anything past 6.1, plus an end-to-end +# in-memory authorization check (ACL grant -> Actor#can?). +# +# No PostgreSQL: the pg_jsonb adapter only needs a json/jsonb `acl` column to +# install, which sqlite's :json type satisfies. We assert the authorization +# decision (in-memory ACL eval), not the pg-specific accessible_by SQL. +$stdout.sync = true +require 'logger' +require 'active_record' +require 'eaco' + +# Reuse the gem's own example Actor/Resource/Designator fixtures. +require 'eaco/cucumber/active_record/document' +require 'eaco/cucumber/active_record/user' + +AR = Eaco::Cucumber::ActiveRecord +User = AR::User +Doc = AR::Document + +$failed = false +def check(label) + yield + puts "[ok] #{label}" +rescue => e + puts "[FAIL] #{label}: #{e.class}: #{e.message}" + puts e.backtrace.first(6).map { |l| " #{l}" } + $failed = true +end + +ActiveRecord::Base.logger = Logger.new(IO::NULL) +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Schema.verbose = false +ActiveRecord::Schema.define do + create_table :documents do |t| + t.string :name + t.json :acl # sqlite :json satisfies the adapter's column check + end + create_table :users do |t| + t.string :name + t.boolean :admin, default: false + end +end + +puts "stack: ruby #{RUBY_VERSION} / Active Record #{ActiveRecord::VERSION::STRING}" + +# 1. THE blocker: installing the pg_jsonb adapter runs Compatibility#check!, +# which raised on AR > 6.1 before this upgrade. Configure via the real DSL. +check "eaco authorization DSL installs on Active Record #{ActiveRecord::VERSION::MAJOR}" do + Eaco.eval! <<~DSL, '(smoke)' + actor #{User} do + admin do |user| + user.admin? + end + + designators do + authenticated from: :class + user from: :id + end + end + + authorize #{Doc}, using: :pg_jsonb do + roles :writer, :reader + + permissions do + reader :read + writer reader, :write + end + end + DSL + + raise "Document not a Resource" unless Doc.include?(Eaco::Resource) + raise "User not an Actor" unless User.include?(Eaco::Actor) +end + +# 2. End-to-end authorization decision (in-memory ACL eval, no pg query). +check "ACL grant + Actor#can? resolves the right permissions" do + granted = User.new(id: 1, name: 'Granted') + other = User.new(id: 2, name: 'Other') + + doc = Doc.new(name: 'Spec') + doc.acl = Doc.acl.new.tap { |acl| acl.add(:reader, :user, 1) } + + raise "granted user cannot read" unless granted.can?(:read, doc) + raise "reader unexpectedly can write" if granted.can?(:write, doc) + raise "ungranted user can read" if other.can?(:read, doc) +end + +# 3. Admin bypass (admin_logic short-circuits to allow). +check "admin actor is allowed regardless of ACL" do + admin = User.new(id: 3, name: 'Boss', admin: true) + doc = Doc.new(name: 'Locked') # empty ACL + raise "admin denied" unless admin.can?(:write, doc) +end + +if $failed + puts "\nSMOKE FAILED" + exit 1 +else + puts "\nSMOKE OK — eaco authorizes on Ruby #{RUBY_VERSION} / " \ + "Active Record #{ActiveRecord::VERSION::STRING}" +end