diff --git a/Gemfile b/Gemfile
index 727920a..e2a5d8c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,7 +20,12 @@ gem "tailwindcss-rails"
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
-# gem "bcrypt", "~> 3.1.7"
+gem "bcrypt", "~> 3.1.7"
+
+# OpenID Connect authentication support
+gem "openid_connect", "~> 2.2"
+gem "omniauth", "~> 2.1"
+gem "omniauth_openid_connect", "~> 0.8"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
diff --git a/Gemfile.lock b/Gemfile.lock
index 3046f34..c9668c0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,10 +77,14 @@ GEM
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
+ aes_key_wrap (1.1.0)
ast (2.4.3)
+ attr_required (1.0.2)
base64 (0.3.0)
+ bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1)
+ bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@@ -110,10 +114,20 @@ GEM
dotenv (3.1.8)
drb (2.2.3)
ed25519 (1.4.0)
+ email_validator (2.2.4)
+ activemodel
erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
+ faraday (2.14.0)
+ faraday-net_http (>= 2.0, < 3.5)
+ json
+ logger
+ faraday-follow_redirects (0.4.0)
+ faraday (>= 1, < 3)
+ faraday-net_http (3.4.1)
+ net-http (>= 0.5.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
@@ -126,6 +140,7 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
+ hashie (5.0.0)
httparty (0.23.2)
csv
mini_mime (>= 1.0.0)
@@ -148,6 +163,13 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.15.2)
+ json-jwt (1.17.0)
+ activesupport (>= 4.2)
+ aes_key_wrap
+ base64
+ bindata
+ faraday (~> 2.0)
+ faraday-follow_redirects
kamal (2.8.2)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -181,6 +203,8 @@ GEM
msgpack (1.8.0)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
+ net-http (0.7.0)
+ uri
net-imap (0.5.12)
date
net-protocol
@@ -210,6 +234,27 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
+ omniauth (2.1.4)
+ hashie (>= 3.4.6)
+ logger
+ rack (>= 2.2.3)
+ rack-protection
+ omniauth_openid_connect (0.8.0)
+ omniauth (>= 1.9, < 3)
+ openid_connect (~> 2.2)
+ openid_connect (2.3.1)
+ activemodel
+ attr_required (>= 1.0.0)
+ email_validator
+ faraday (~> 2.0)
+ faraday-follow_redirects
+ json-jwt (>= 1.16)
+ mail
+ rack-oauth2 (~> 2.2)
+ swd (~> 2.0)
+ tzinfo
+ validate_url
+ webfinger (~> 2.0)
ostruct (0.6.3)
pagy (9.4.0)
parallel (1.27.0)
@@ -233,6 +278,17 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
+ rack-oauth2 (2.2.1)
+ activesupport
+ attr_required
+ faraday (~> 2.0)
+ faraday-follow_redirects
+ json-jwt (>= 1.11.0)
+ rack (>= 2.1.0)
+ rack-protection (4.2.1)
+ base64 (>= 0.1.0)
+ logger (>= 1.6.0)
+ rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -353,6 +409,11 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
+ swd (2.0.3)
+ activesupport (>= 3)
+ attr_required (>= 0.0.5)
+ faraday (~> 2.0)
+ faraday-follow_redirects
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
@@ -379,11 +440,18 @@ GEM
unicode-emoji (4.1.0)
uri (1.1.0)
useragent (0.16.11)
+ validate_url (1.0.15)
+ activemodel (>= 3.0.0)
+ public_suffix
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
+ webfinger (2.1.3)
+ activesupport
+ faraday (~> 2.0)
+ faraday-follow_redirects
websocket (1.2.11)
websocket-driver (0.8.0)
base64
@@ -405,6 +473,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
+ bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
@@ -416,6 +485,9 @@ DEPENDENCIES
jbuilder
kamal
maxmind-db
+ omniauth (~> 2.1)
+ omniauth_openid_connect (~> 0.8)
+ openid_connect (~> 2.2)
pagy
propshaft
puma (>= 5.0)
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 0000000..4264c74
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,16 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user
+
+ def connect
+ set_current_user || reject_unauthorized_connection
+ end
+
+ private
+ def set_current_user
+ if session = Session.find_by(id: cookies.signed[:session_id])
+ self.current_user = session.user
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/rules_controller.rb b/app/controllers/api/rules_controller.rb
index 45c03a5..11742ce 100644
--- a/app/controllers/api/rules_controller.rb
+++ b/app/controllers/api/rules_controller.rb
@@ -2,6 +2,10 @@
module Api
class RulesController < ApplicationController
+ # NOTE: This controller is now SECONDARY/UNUSED for primary agent synchronization
+ # Agents get rule updates via event responses (see Api::EventsController)
+ # These endpoints are kept for administrative/debugging purposes only
+
skip_before_action :verify_authenticity_token
before_action :authenticate_project!
before_action :check_project_enabled
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index d0bc7e8..e70156e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,5 @@
class ApplicationController < ActionController::Base
+ include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
@@ -6,4 +7,40 @@ class ApplicationController < ActionController::Base
stale_when_importmap_changes
include Pagy::Backend
+
+ helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer?
+
+ private
+
+ def current_user
+ Current.session&.user
+ end
+
+ def user_signed_in?
+ current_user.present?
+ end
+
+ def current_user_admin?
+ current_user&.admin?
+ end
+
+ def current_user_viewer?
+ current_user&.viewer?
+ end
+
+ def require_admin
+ unless current_user_admin?
+ redirect_to root_path, alert: "Admin access required"
+ end
+ end
+
+ def require_write_access
+ if current_user_viewer?
+ redirect_to root_path, alert: "Viewer access - cannot make changes"
+ end
+ end
+
+ def after_authentication_url
+ session.delete(:return_to_after_authenticating) || root_url
+ end
end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
new file mode 100644
index 0000000..3538f48
--- /dev/null
+++ b/app/controllers/concerns/authentication.rb
@@ -0,0 +1,52 @@
+module Authentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :require_authentication
+ helper_method :authenticated?
+ end
+
+ class_methods do
+ def allow_unauthenticated_access(**options)
+ skip_before_action :require_authentication, **options
+ end
+ end
+
+ private
+ def authenticated?
+ resume_session
+ end
+
+ def require_authentication
+ resume_session || request_authentication
+ end
+
+ def resume_session
+ Current.session ||= find_session_by_cookie
+ end
+
+ def find_session_by_cookie
+ Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
+ end
+
+ def request_authentication
+ session[:return_to_after_authenticating] = request.url
+ redirect_to new_session_path
+ end
+
+ def after_authentication_url
+ session.delete(:return_to_after_authenticating) || root_url
+ end
+
+ def start_new_session_for(user)
+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
+ Current.session = session
+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
+ end
+ end
+
+ def terminate_session
+ Current.session.destroy
+ cookies.delete(:session_id)
+ end
+end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
new file mode 100644
index 0000000..45bb1f6
--- /dev/null
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -0,0 +1,26 @@
+class OmniauthCallbacksController < ApplicationController
+ allow_unauthenticated_access only: [:oidc, :failure]
+
+ def oidc
+ auth_hash = request.env['omniauth.auth']
+
+ user = User.from_oidc(auth_hash)
+
+ if user
+ start_new_session_for(user)
+ redirect_to after_login_path, notice: "Successfully signed in via OIDC"
+ else
+ redirect_to new_session_path, alert: "Failed to sign in via OIDC - email not found"
+ end
+ end
+
+ def failure
+ redirect_to new_session_path, alert: "Authentication failed: #{params[:message]}"
+ end
+
+ private
+
+ def after_login_path
+ session.delete(:return_to_after_authenticating) || root_url
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 0000000..f95ec78
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,35 @@
+class PasswordsController < ApplicationController
+ allow_unauthenticated_access
+ before_action :set_user_by_token, only: %i[ edit update ]
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
+
+ def new
+ end
+
+ def create
+ if user = User.find_by(email_address: params[:email_address])
+ PasswordsMailer.reset(user).deliver_later
+ end
+
+ redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
+ end
+
+ def edit
+ end
+
+ def update
+ if @user.update(params.permit(:password, :password_confirmation))
+ @user.sessions.destroy_all
+ redirect_to new_session_path, notice: "Password has been reset."
+ else
+ redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
+ end
+ end
+
+ private
+ def set_user_by_token
+ @user = User.find_by_password_reset_token!(params[:token])
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
+ end
+end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
new file mode 100644
index 0000000..8f2ae50
--- /dev/null
+++ b/app/controllers/registrations_controller.rb
@@ -0,0 +1,31 @@
+class RegistrationsController < ApplicationController
+ allow_unauthenticated_access only: [:new, :create]
+ before_action :ensure_no_users_exist, only: [:new, :create]
+
+ def new
+ @user = User.new
+ end
+
+ def create
+ @user = User.new(user_params)
+
+ if @user.save
+ start_new_session_for(@user)
+ redirect_to root_path, notice: "Welcome! Your admin account has been created successfully."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def user_params
+ params.require(:user).permit(:email_address, :password, :password_confirmation)
+ end
+
+ def ensure_no_users_exist
+ if User.exists?
+ redirect_to new_session_path, alert: "Registration is not allowed. Users already exist."
+ end
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..903e444
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,32 @@
+class SessionsController < ApplicationController
+ allow_unauthenticated_access only: %i[ new create ]
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
+
+ def new
+ @show_oidc_login = oidc_configured?
+ @oidc_provider_name = ENV['OIDC_PROVIDER_NAME'] || 'OpenID Connect'
+ @show_registration = User.none?
+ end
+
+ def create
+ if user = User.authenticate_by(params.permit(:email_address, :password))
+ start_new_session_for user
+ redirect_to after_authentication_url
+ else
+ redirect_to new_session_path, alert: "Try another email address or password."
+ end
+ end
+
+ def destroy
+ terminate_session
+ redirect_to new_session_path, status: :see_other
+ end
+
+ private
+
+ def oidc_configured?
+ ENV['OIDC_DISCOVERY_URL'].present? &&
+ ENV['OIDC_CLIENT_ID'].present? &&
+ ENV['OIDC_CLIENT_SECRET'].present?
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
new file mode 100644
index 0000000..f3ab1a1
--- /dev/null
+++ b/app/controllers/users_controller.rb
@@ -0,0 +1,32 @@
+class UsersController < ApplicationController
+ before_action :require_admin
+ before_action :set_user, only: [:show, :edit, :update]
+
+ def index
+ @users = User.order(created_at: :desc)
+ end
+
+ def show
+ end
+
+ def edit
+ end
+
+ def update
+ if @user.update(user_params)
+ redirect_to @user, notice: "User was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def set_user
+ @user = User.find(params[:id])
+ end
+
+ def user_params
+ params.require(:user).permit(:role)
+ end
+end
diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb
new file mode 100644
index 0000000..b100376
--- /dev/null
+++ b/app/helpers/registrations_helper.rb
@@ -0,0 +1,2 @@
+module RegistrationsHelper
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
new file mode 100644
index 0000000..2310a24
--- /dev/null
+++ b/app/helpers/users_helper.rb
@@ -0,0 +1,2 @@
+module UsersHelper
+end
diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb
new file mode 100644
index 0000000..4f0ac7f
--- /dev/null
+++ b/app/mailers/passwords_mailer.rb
@@ -0,0 +1,6 @@
+class PasswordsMailer < ApplicationMailer
+ def reset(user)
+ @user = user
+ mail subject: "Reset your password", to: user.email_address
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
index 3381b50..2bef56d 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,16 +1,4 @@
-# frozen_string_literal: true
-
class Current < ActiveSupport::CurrentAttributes
- attribute :baffle_host
- attribute :baffle_internal_host
- attribute :project
- attribute :ip
-
- def baffle_host
- @baffle_host || ENV.fetch("BAFFLE_HOST", "localhost:3000")
- end
-
- def baffle_internal_host
- @baffle_internal_host || ENV.fetch("BAFFLE_INTERNAL_HOST", nil)
- end
+ attribute :session
+ delegate :user, to: :session, allow_nil: true
end
diff --git a/app/models/session.rb b/app/models/session.rb
new file mode 100644
index 0000000..cf376fb
--- /dev/null
+++ b/app/models/session.rb
@@ -0,0 +1,3 @@
+class Session < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..b38dc54
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,64 @@
+class User < ApplicationRecord
+ has_secure_password
+ has_many :sessions, dependent: :destroy
+
+ normalizes :email_address, with: ->(e) { e.strip.downcase }
+
+ enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user
+
+ validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
+ validates :role, presence: true
+
+ before_validation :set_first_user_as_admin, on: :create
+
+ def self.from_oidc(auth_hash)
+ # Extract user info from OIDC auth hash
+ email = auth_hash.dig('info', 'email')
+ return nil unless email
+
+ user = find_or_initialize_by(email_address: email)
+
+ # Map OIDC groups to role
+ if auth_hash.dig('extra', 'raw_info', 'groups')
+ user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups'))
+ end
+
+ # Don't override password for OIDC users
+ user.save!(validate: false) if user.new_record?
+ user
+ end
+
+ def admin?
+ role == 'admin'
+ end
+
+ def viewer?
+ role == 'viewer'
+ end
+
+ private
+
+ def set_first_user_as_admin
+ return if User.any?
+ self.role = 'admin'
+ end
+
+ def self.map_oidc_groups_to_role(groups)
+ groups = Array(groups)
+
+ # Check admin groups first
+ admin_groups = ENV['OIDC_ADMIN_GROUPS']&.split(',')&.map(&:strip)
+ return 'admin' if admin_groups && (admin_groups & groups).any?
+
+ # Check user groups
+ user_groups = ENV['OIDC_USER_GROUPS']&.split(',')&.map(&:strip)
+ return 'user' if user_groups && (user_groups & groups).any?
+
+ # Check viewer groups
+ viewer_groups = ENV['OIDC_VIEWER_GROUPS']&.split(',')&.map(&:strip)
+ return 'viewer' if viewer_groups && (viewer_groups & groups).any?
+
+ # Default to user if no group matches
+ 'user'
+ end
+end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 73ade44..5252740 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -54,8 +54,41 @@
<%= link_to "Projects", projects_path, class: "nav-link" %>
- <%= link_to "Rule Sets", rule_sets_path, class: "nav-link" %>
+ <%= link_to "Rules", rules_path, class: "nav-link" %>
+ <% if user_signed_in? && current_user_admin? %>
+
+ <%= link_to "Users", users_path, class: "nav-link" %>
+
+ <% end %>
+
+
+
+ <% if user_signed_in? %>
+
+
+ <%= current_user.email_address %>
+ <%= current_user.role %>
+
+
+
+ <% else %>
+ <% if User.none? %>
+
+ <%= link_to "Create Admin Account", new_registration_path, class: "nav-link btn btn-success text-white" %>
+
+ <% else %>
+
+ <%= link_to "Sign In", new_session_path, class: "nav-link" %>
+
+ <% end %>
+ <% end %>
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
new file mode 100644
index 0000000..65798f8
--- /dev/null
+++ b/app/views/passwords/edit.html.erb
@@ -0,0 +1,21 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Update your password
+
+ <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
+
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
+
+ <% end %>
+
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb
new file mode 100644
index 0000000..8360e02
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,17 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Forgot your password?
+
+ <%= form_with url: passwords_path, class: "contents" do |form| %>
+
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
+
+ <% end %>
+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb
new file mode 100644
index 0000000..1b09154
--- /dev/null
+++ b/app/views/passwords_mailer/reset.html.erb
@@ -0,0 +1,6 @@
+
+ You can reset your password on
+ <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
+
+ This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
+
diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb
new file mode 100644
index 0000000..aecee82
--- /dev/null
+++ b/app/views/passwords_mailer/reset.text.erb
@@ -0,0 +1,4 @@
+You can reset your password on
+<%= edit_password_url(@user.password_reset_token) %>
+
+This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
diff --git a/app/views/registrations/create.html.erb b/app/views/registrations/create.html.erb
new file mode 100644
index 0000000..174fd9e
--- /dev/null
+++ b/app/views/registrations/create.html.erb
@@ -0,0 +1,4 @@
+
+
Registrations#create
+
Find me in app/views/registrations/create.html.erb
+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
new file mode 100644
index 0000000..15f264c
--- /dev/null
+++ b/app/views/registrations/new.html.erb
@@ -0,0 +1,61 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+ <% if notice = flash[:notice] %>
+
<%= notice %>
+ <% end %>
+
+
+
🎉 Welcome to Baffle Hub!
+
+ This is the first time Baffle Hub is being set up. You'll create the initial administrator account
+ that will have full access to manage users, projects, and system settings.
+
+
+
+
Create Administrator Account
+
+ <%= form_with(model: @user, url: registration_path, class: "contents") do |form| %>
+ <% if @user.errors.any? %>
+
+
+ <%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:
+
+
+ <% @user.errors.each do |error| %>
+ <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "email", placeholder: "Enter your email address", class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Create a password", minlength: 8, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
Minimum 8 characters
+
+
+
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", minlength: 8, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.submit "Create Administrator Account", class: "rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer" %>
+
+ <% end %>
+
+
+
This administrator account will have:
+
+ Full system access and control
+ Ability to manage other users
+ Permission to create and manage projects
+ Access to system configuration and analytics
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..0de0912
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,57 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+ <% if notice = flash[:notice] %>
+
<%= notice %>
+ <% end %>
+
+ <% if @show_registration %>
+
+
🚀 First Time Setup
+
+ No administrator account exists yet. Create the first administrator account to get started with Baffle Hub.
+
+ <%= link_to "Create Administrator Account", new_registration_path,
+ class: "inline-block rounded-md px-4 py-2 bg-green-600 hover:bg-green-500 text-white font-medium" %>
+
+
+
+ or
+
+ <% end %>
+
+
Sign in
+
+ <% if @show_oidc_login && !@show_registration %>
+
+ <%= link_to "Sign in with #{@oidc_provider_name}", "/auth/oidc",
+ class: "w-full block text-center rounded-md px-3.5 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white font-medium cursor-pointer" %>
+
+
+ or
+
+
+ <% end %>
+
+ <%= form_with url: session_url, class: "contents" do |form| %>
+
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
+
+
+
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
+
+
+ <% end %>
+
diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb
new file mode 100644
index 0000000..96778a1
--- /dev/null
+++ b/app/views/users/edit.html.erb
@@ -0,0 +1,63 @@
+
+
+ <%= link_to "← Back to Users", users_path, class: "text-blue-600 hover:text-blue-800" %>
+
+
+
Edit User
+
+ <% if notice = flash[:notice] %>
+
<%= notice %>
+ <% end %>
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
+
+
User Information
+
+
+
+
Email Address
+
<%= @user.email_address %>
+
+
+
+
Created
+
<%= @user.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
+
+ <%= form_with(model: @user, class: "contents") do |form| %>
+
+
Role Assignment
+
+
+
+ <%= form.radio_button :role, "admin", class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300" %>
+ <%= form.label :role_admin, "Admin", class: "ml-3 block text-sm font-medium text-gray-700" %>
+ - Full system access, user management, project creation
+
+
+
+ <%= form.radio_button :role, "user", class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
+ <%= form.label :role_user, "User", class: "ml-3 block text-sm font-medium text-gray-700" %>
+ - Read/write access to projects
+
+
+
+ <%= form.radio_button :role, "viewer", class: "h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300" %>
+ <%= form.label :role_viewer, "Viewer", class: "ml-3 block text-sm font-medium text-gray-700" %>
+ - Read-only access to all projects
+
+
+
+
+
+ <%= form.submit "Update User", class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
+
+ <% end %>
+
+
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
new file mode 100644
index 0000000..4221ebf
--- /dev/null
+++ b/app/views/users/index.html.erb
@@ -0,0 +1,62 @@
+
+
+
User Management
+
+ Total users: <%= @users.count %>
+
+
+
+ <% if notice = flash[:notice] %>
+
<%= notice %>
+ <% end %>
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
+
+ <% @users.each do |user| %>
+
+
+
+
+
+
+
+ <%= user.email_address.first.upcase %>
+
+
+
+
+
+ <%= user.email_address %>
+
+
+ Joined <%= user.created_at.strftime("%B %d, %Y") %>
+
+
+
+
+
+ <%= user.role.capitalize %>
+
+ <%= link_to "Edit", edit_user_path(user),
+ class: "inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
+
+
+
+
+ <% end %>
+
+
+
+ <% if @users.empty? %>
+
+ <% end %>
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
new file mode 100644
index 0000000..8f05314
--- /dev/null
+++ b/app/views/users/show.html.erb
@@ -0,0 +1,4 @@
+
+
Users#show
+
Find me in app/views/users/show.html.erb
+
diff --git a/app/views/users/update.html.erb b/app/views/users/update.html.erb
new file mode 100644
index 0000000..7ead44d
--- /dev/null
+++ b/app/views/users/update.html.erb
@@ -0,0 +1,4 @@
+
+
Users#update
+
Find me in app/views/users/update.html.erb
+
diff --git a/config/cache.yml b/config/cache.yml
index 19d4908..2535887 100644
--- a/config/cache.yml
+++ b/config/cache.yml
@@ -6,6 +6,7 @@ default: &default
namespace: <%= Rails.env %>
development:
+ database: cache
<<: *default
test:
diff --git a/config/database.yml b/config/database.yml
index 693252b..1233d7a 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -10,8 +10,21 @@ default: &default
timeout: 5000
development:
- <<: *default
- database: storage/development.sqlite3
+ primary:
+ <<: *default
+ database: storage/development.sqlite3
+ cache:
+ <<: *default
+ database: storage/development_cache.sqlite3
+ migrations_paths: db/cache_migrate
+ queue:
+ <<: *default
+ database: storage/development_queue.sqlite3
+ migrations_paths: db/queue_migrate
+ cable:
+ <<: *default
+ database: storage/development_cable.sqlite3
+ migrations_paths: db/cable_migrate
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
diff --git a/config/environments/production.rb b/config/environments/production.rb
index f5763e0..b44d856 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -87,4 +87,18 @@ Rails.application.configure do
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+
+ # Docker Compose friendly settings
+ config.log_level = :info
+ config.log_tags = [ :request_id ]
+
+ # Log to stdout for Docker container logging
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
+ logger = ActiveSupport::Logger.new(STDOUT)
+ logger.formatter = config.log_formatter
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
+ end
+
+ # Serve static files (Docker Compose deployments typically don't have a separate web server)
+ config.public_file_server.enabled = true
end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
new file mode 100644
index 0000000..514bc45
--- /dev/null
+++ b/config/initializers/omniauth.rb
@@ -0,0 +1,29 @@
+Rails.application.config.middleware.use OmniAuth::Builder do
+ # Only configure OIDC if environment variables are present
+ if ENV['OIDC_DISCOVERY_URL'].present? && ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present?
+ provider :openid_connect, {
+ name: :oidc,
+ scope: [:openid, :email, :groups],
+ response_type: :code,
+ client_options: {
+ identifier: ENV['OIDC_CLIENT_ID'],
+ secret: ENV['OIDC_CLIENT_SECRET'],
+ redirect_uri: ENV['OIDC_REDIRECT_URI'] || "#{Rails.application.routes.url_helpers.root_url}auth/oidc/callback",
+ discovery: true,
+ authorization_endpoint: nil,
+ token_endpoint: nil,
+ userinfo_endpoint: nil,
+ jwks_uri: nil
+ },
+ discovery_document: {
+ issuer: ENV['OIDC_ISSUER'] # Optional, defaults to discovery URL issuer
+ }
+ }
+ end
+end
+
+# Disable OmniAuth logging in production
+OmniAuth.config.logger = Rails.logger if Rails.env.production?
+
+# Set OmniAuth failure mode
+OmniAuth.config.failure_raise_out_environments = %w[development test]
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 50a323f..70c5bfe 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,4 +1,16 @@
Rails.application.routes.draw do
+ # Registration only allowed when no users exist
+ resource :registration, only: [:new, :create]
+ resource :session
+ resources :passwords, param: :token
+
+ # OIDC authentication routes
+ get "/auth/failure", to: "omniauth_callbacks#failure"
+ get "/auth/:provider/callback", to: "omniauth_callbacks#oidc"
+
+ # Admin user management (admin only)
+ resources :users, only: [:index, :show, :edit, :update]
+
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
@@ -7,10 +19,11 @@ Rails.application.routes.draw do
# WAF API
namespace :api, defaults: { format: :json } do
- # Event ingestion
+ # Event ingestion (PRIMARY method - includes rule updates in response)
post ":project_id/events", to: "events#create"
- # Rule synchronization
+ # Rule synchronization (SECONDARY - for admin/debugging only)
+ # Note: Agents should use event responses for rule synchronization
get ":public_key/rules/version", to: "rules#version"
get ":public_key/rules", to: "rules#index"
end
diff --git a/db/cable_schema.rb b/db/cable_schema.rb
index 2366660..3aefc38 100644
--- a/db/cable_schema.rb
+++ b/db/cable_schema.rb
@@ -1,9 +1,21 @@
-ActiveRecord::Schema[7.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: 1) do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
- t.binary "payload", limit: 536870912, null: false
- t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
+ t.datetime "created_at", null: false
+ t.binary "payload", limit: 536870912, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
diff --git a/db/cache_schema.rb b/db/cache_schema.rb
index 6005a29..2016467 100644
--- a/db/cache_schema.rb
+++ b/db/cache_schema.rb
@@ -1,12 +1,22 @@
-# frozen_string_literal: true
+# 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[7.2].define(version: 1) do
+ActiveRecord::Schema[8.1].define(version: 1) do
create_table "solid_cache_entries", force: :cascade do |t|
- t.binary "key", limit: 1024, null: false
- t.binary "value", limit: 536870912, null: false
- t.datetime "created_at", null: false
- t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
+ t.datetime "created_at", null: false
+ t.binary "key", limit: 1024, null: false
+ t.integer "key_hash", limit: 8, null: false
+ t.binary "value", limit: 536870912, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
diff --git a/db/geoip/GeoLite2-Country.mmdb b/db/geoip/GeoLite2-Country.mmdb
new file mode 100644
index 0000000..a113c95
Binary files /dev/null and b/db/geoip/GeoLite2-Country.mmdb differ
diff --git a/db/migrate/20251103225239_create_users.rb b/db/migrate/20251103225239_create_users.rb
new file mode 100644
index 0000000..71f2ff1
--- /dev/null
+++ b/db/migrate/20251103225239_create_users.rb
@@ -0,0 +1,11 @@
+class CreateUsers < ActiveRecord::Migration[8.1]
+ def change
+ create_table :users do |t|
+ t.string :email_address, null: false
+ t.string :password_digest, null: false
+
+ t.timestamps
+ end
+ add_index :users, :email_address, unique: true
+ end
+end
diff --git a/db/migrate/20251103225240_create_sessions.rb b/db/migrate/20251103225240_create_sessions.rb
new file mode 100644
index 0000000..ec9efdb
--- /dev/null
+++ b/db/migrate/20251103225240_create_sessions.rb
@@ -0,0 +1,11 @@
+class CreateSessions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :sessions do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :ip_address
+ t.string :user_agent
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20251103225251_add_role_to_users.rb b/db/migrate/20251103225251_add_role_to_users.rb
new file mode 100644
index 0000000..c38592e
--- /dev/null
+++ b/db/migrate/20251103225251_add_role_to_users.rb
@@ -0,0 +1,5 @@
+class AddRoleToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :role, :integer, default: 1, null: false
+ end
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
index 85194b6..f56798c 100644
--- a/db/queue_schema.rb
+++ b/db/queue_schema.rb
@@ -1,123 +1,135 @@
-ActiveRecord::Schema[7.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: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
- t.bigint "job_id", null: false
- t.string "queue_name", null: false
- t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
- t.datetime "expires_at", null: false
t.datetime "created_at", null: false
- t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
- t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
- t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
+ t.datetime "expires_at", null: false
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
+ t.datetime "created_at", null: false
t.bigint "job_id", null: false
t.bigint "process_id"
- t.datetime "created_at", null: false
- t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
- t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
- t.bigint "job_id", null: false
- t.text "error"
t.datetime "created_at", null: false
- t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
+ t.text "error"
+ t.bigint "job_id", null: false
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
- t.string "queue_name", null: false
- t.string "class_name", null: false
- t.text "arguments"
- t.integer "priority", default: 0, null: false
t.string "active_job_id"
- t.datetime "scheduled_at"
- t.datetime "finished_at"
+ t.text "arguments"
+ t.string "class_name", null: false
t.string "concurrency_key"
t.datetime "created_at", null: false
+ t.datetime "finished_at"
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at"
t.datetime "updated_at", null: false
- t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
- t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
- t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
- t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
- t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
- t.string "queue_name", null: false
t.datetime "created_at", null: false
- t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
+ t.string "queue_name", null: false
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "hostname"
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
- t.bigint "supervisor_id"
- t.integer "pid", null: false
- t.string "hostname"
t.text "metadata"
- t.datetime "created_at", null: false
t.string "name", null: false
- t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
- t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
- t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
+ t.integer "pid", null: false
+ t.bigint "supervisor_id"
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
- t.bigint "job_id", null: false
- t.string "queue_name", null: false
- t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
- t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
- t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
- t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
- t.bigint "job_id", null: false
- t.string "task_key", null: false
- t.datetime "run_at", null: false
t.datetime "created_at", null: false
- t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
- t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
+ t.bigint "job_id", null: false
+ t.datetime "run_at", null: false
+ t.string "task_key", null: false
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
- t.string "key", null: false
- t.string "schedule", null: false
- t.string "command", limit: 2048
- t.string "class_name"
t.text "arguments"
- t.string "queue_name"
- t.integer "priority", default: 0
- t.boolean "static", default: true, null: false
- t.text "description"
+ t.string "class_name"
+ t.string "command", limit: 2048
t.datetime "created_at", null: false
+ t.text "description"
+ t.string "key", null: false
+ t.integer "priority", default: 0
+ t.string "queue_name"
+ t.string "schedule", null: false
+ t.boolean "static", default: true, null: false
t.datetime "updated_at", null: false
- t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
- t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
- t.bigint "job_id", null: false
- t.string "queue_name", null: false
- t.integer "priority", default: 0, null: false
- t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
- t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
- t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
+ t.bigint "job_id", null: false
+ t.integer "priority", default: 0, null: false
+ t.string "queue_name", null: false
+ t.datetime "scheduled_at", null: false
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
- t.string "key", null: false
- t.integer "value", default: 1, null: false
- t.datetime "expires_at", null: false
t.datetime "created_at", null: false
+ t.datetime "expires_at", null: false
+ t.string "key", null: false
t.datetime "updated_at", null: false
- t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
- t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
- t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
+ t.integer "value", default: 1, null: false
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
diff --git a/db/schema.rb b/db/schema.rb
index 4a0a627..5ba33b8 100644
--- a/db/schema.rb
+++ b/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: 2025_11_03_130430) do
+ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
create_table "events", force: :cascade do |t|
t.string "agent_name"
t.string "agent_version"
@@ -190,6 +190,25 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_130430) do
t.index ["updated_at", "id"], name: "idx_rules_sync"
end
+ create_table "sessions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "ip_address"
+ t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.integer "user_id", null: false
+ t.index ["user_id"], name: "index_sessions_on_user_id"
+ end
+
+ create_table "users", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "email_address", null: false
+ t.string "password_digest", null: false
+ t.integer "role", default: 1, null: false
+ t.datetime "updated_at", null: false
+ t.index ["email_address"], name: "index_users_on_email_address", unique: true
+ end
+
add_foreign_key "events", "projects"
add_foreign_key "events", "request_hosts"
+ add_foreign_key "sessions", "users"
end
diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb
new file mode 100644
index 0000000..e1a1b03
--- /dev/null
+++ b/test/controllers/passwords_controller_test.rb
@@ -0,0 +1,67 @@
+require "test_helper"
+
+class PasswordsControllerTest < ActionDispatch::IntegrationTest
+ setup { @user = User.take }
+
+ test "new" do
+ get new_password_path
+ assert_response :success
+ end
+
+ test "create" do
+ post passwords_path, params: { email_address: @user.email_address }
+ assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
+ assert_redirected_to new_session_path
+
+ follow_redirect!
+ assert_notice "reset instructions sent"
+ end
+
+ test "create for an unknown user redirects but sends no mail" do
+ post passwords_path, params: { email_address: "missing-user@example.com" }
+ assert_enqueued_emails 0
+ assert_redirected_to new_session_path
+
+ follow_redirect!
+ assert_notice "reset instructions sent"
+ end
+
+ test "edit" do
+ get edit_password_path(@user.password_reset_token)
+ assert_response :success
+ end
+
+ test "edit with invalid password reset token" do
+ get edit_password_path("invalid token")
+ assert_redirected_to new_password_path
+
+ follow_redirect!
+ assert_notice "reset link is invalid"
+ end
+
+ test "update" do
+ assert_changes -> { @user.reload.password_digest } do
+ put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
+ assert_redirected_to new_session_path
+ end
+
+ follow_redirect!
+ assert_notice "Password has been reset"
+ end
+
+ test "update with non matching passwords" do
+ token = @user.password_reset_token
+ assert_no_changes -> { @user.reload.password_digest } do
+ put password_path(token), params: { password: "no", password_confirmation: "match" }
+ assert_redirected_to edit_password_path(token)
+ end
+
+ follow_redirect!
+ assert_notice "Passwords did not match"
+ end
+
+ private
+ def assert_notice(text)
+ assert_select "div", /#{text}/
+ end
+end
diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb
new file mode 100644
index 0000000..9cfcd19
--- /dev/null
+++ b/test/controllers/registrations_controller_test.rb
@@ -0,0 +1,13 @@
+require "test_helper"
+
+class RegistrationsControllerTest < ActionDispatch::IntegrationTest
+ test "should get new" do
+ get registrations_new_url
+ assert_response :success
+ end
+
+ test "should get create" do
+ get registrations_create_url
+ assert_response :success
+ end
+end
diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb
new file mode 100644
index 0000000..07d72ef
--- /dev/null
+++ b/test/controllers/sessions_controller_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class SessionsControllerTest < ActionDispatch::IntegrationTest
+ setup { @user = User.take }
+
+ test "new" do
+ get new_session_path
+ assert_response :success
+ end
+
+ test "create with valid credentials" do
+ post session_path, params: { email_address: @user.email_address, password: "password" }
+
+ assert_redirected_to root_path
+ assert cookies[:session_id]
+ end
+
+ test "create with invalid credentials" do
+ post session_path, params: { email_address: @user.email_address, password: "wrong" }
+
+ assert_redirected_to new_session_path
+ assert_nil cookies[:session_id]
+ end
+
+ test "destroy" do
+ sign_in_as(User.take)
+
+ delete session_path
+
+ assert_redirected_to new_session_path
+ assert_empty cookies[:session_id]
+ end
+end
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
new file mode 100644
index 0000000..5f92176
--- /dev/null
+++ b/test/controllers/users_controller_test.rb
@@ -0,0 +1,23 @@
+require "test_helper"
+
+class UsersControllerTest < ActionDispatch::IntegrationTest
+ test "should get index" do
+ get users_index_url
+ assert_response :success
+ end
+
+ test "should get show" do
+ get users_show_url
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get users_edit_url
+ assert_response :success
+ end
+
+ test "should get update" do
+ get users_update_url
+ assert_response :success
+ end
+end
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
new file mode 100644
index 0000000..0951563
--- /dev/null
+++ b/test/fixtures/users.yml
@@ -0,0 +1,9 @@
+<% password_digest = BCrypt::Password.create("password") %>
+
+one:
+ email_address: one@example.com
+ password_digest: <%= password_digest %>
+
+two:
+ email_address: two@example.com
+ password_digest: <%= password_digest %>
diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb
new file mode 100644
index 0000000..01d07ec
--- /dev/null
+++ b/test/mailers/previews/passwords_mailer_preview.rb
@@ -0,0 +1,7 @@
+# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
+class PasswordsMailerPreview < ActionMailer::Preview
+ # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
+ def reset
+ PasswordsMailer.reset(User.take)
+ end
+end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
new file mode 100644
index 0000000..83445c4
--- /dev/null
+++ b/test/models/user_test.rb
@@ -0,0 +1,8 @@
+require "test_helper"
+
+class UserTest < ActiveSupport::TestCase
+ test "downcases and strips email_address" do
+ user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ")
+ assert_equal("downcased@example.com", user.email_address)
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 0c22470..85c54c6 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,6 +1,7 @@
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
+require_relative "test_helpers/session_test_helper"
module ActiveSupport
class TestCase
diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb
new file mode 100644
index 0000000..0686378
--- /dev/null
+++ b/test/test_helpers/session_test_helper.rb
@@ -0,0 +1,19 @@
+module SessionTestHelper
+ def sign_in_as(user)
+ Current.session = user.sessions.create!
+
+ ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar|
+ cookie_jar.signed[:session_id] = Current.session.id
+ cookies["session_id"] = cookie_jar[:session_id]
+ end
+ end
+
+ def sign_out
+ Current.session&.destroy!
+ cookies.delete("session_id")
+ end
+end
+
+ActiveSupport.on_load(:action_dispatch_integration_test) do
+ include SessionTestHelper
+end