Much work.

This commit is contained in:
Dan Milne
2025-11-04 10:32:05 +11:00
parent c72d83acda
commit 85252a1a07
51 changed files with 1170 additions and 97 deletions

View File

@@ -20,7 +20,12 @@ gem "tailwindcss-rails"
gem "jbuilder" gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # 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 # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ] gem "tzinfo-data", platforms: %i[ windows jruby ]

View File

@@ -77,10 +77,14 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.3) ast (2.4.3)
attr_required (1.0.2)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1) bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -110,10 +114,20 @@ GEM
dotenv (3.1.8) dotenv (3.1.8)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
email_validator (2.2.4)
activemodel
erb (5.1.3) erb (5.1.3)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo 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-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-gnu)
@@ -126,6 +140,7 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
hashie (5.0.0)
httparty (0.23.2) httparty (0.23.2)
csv csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
@@ -148,6 +163,13 @@ GEM
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.15.2) 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) kamal (2.8.2)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -181,6 +203,8 @@ GEM
msgpack (1.8.0) msgpack (1.8.0)
multi_xml (0.7.2) multi_xml (0.7.2)
bigdecimal (~> 3.1) bigdecimal (~> 3.1)
net-http (0.7.0)
uri
net-imap (0.5.12) net-imap (0.5.12)
date date
net-protocol net-protocol
@@ -210,6 +234,27 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4) 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) ostruct (0.6.3)
pagy (9.4.0) pagy (9.4.0)
parallel (1.27.0) parallel (1.27.0)
@@ -233,6 +278,17 @@ GEM
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) 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) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -353,6 +409,11 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.7) 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) tailwindcss-rails (4.4.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
@@ -379,11 +440,18 @@ GEM
unicode-emoji (4.1.0) unicode-emoji (4.1.0)
uri (1.1.0) uri (1.1.0)
useragent (0.16.11) useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.0)
base64 base64
@@ -405,6 +473,7 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap bootsnap
brakeman brakeman
bundler-audit bundler-audit
@@ -416,6 +485,9 @@ DEPENDENCIES
jbuilder jbuilder
kamal kamal
maxmind-db maxmind-db
omniauth (~> 2.1)
omniauth_openid_connect (~> 0.8)
openid_connect (~> 2.2)
pagy pagy
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)

View File

@@ -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

View File

@@ -2,6 +2,10 @@
module Api module Api
class RulesController < ApplicationController 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 skip_before_action :verify_authenticity_token
before_action :authenticate_project! before_action :authenticate_project!
before_action :check_project_enabled before_action :check_project_enabled

View File

@@ -1,4 +1,5 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern
@@ -6,4 +7,40 @@ class ApplicationController < ActionController::Base
stale_when_importmap_changes stale_when_importmap_changes
include Pagy::Backend 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 end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,2 @@
module RegistrationsHelper
end

View File

@@ -0,0 +1,2 @@
module UsersHelper
end

View File

@@ -0,0 +1,6 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end

View File

@@ -1,16 +1,4 @@
# frozen_string_literal: true
class Current < ActiveSupport::CurrentAttributes class Current < ActiveSupport::CurrentAttributes
attribute :baffle_host attribute :session
attribute :baffle_internal_host delegate :user, to: :session, allow_nil: true
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
end end

3
app/models/session.rb Normal file
View File

@@ -0,0 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
end

64
app/models/user.rb Normal file
View File

@@ -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

View File

@@ -54,8 +54,41 @@
<%= link_to "Projects", projects_path, class: "nav-link" %> <%= link_to "Projects", projects_path, class: "nav-link" %>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<%= link_to "Rule Sets", rule_sets_path, class: "nav-link" %> <%= link_to "Rules", rules_path, class: "nav-link" %>
</li> </li>
<% if user_signed_in? && current_user_admin? %>
<li class="nav-item">
<%= link_to "Users", users_path, class: "nav-link" %>
</li>
<% end %>
</ul>
<ul class="navbar-nav">
<% if user_signed_in? %>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<%= current_user.email_address %>
<span class="badge bg-secondary ms-1"><%= current_user.role %></span>
</a>
<ul class="dropdown-menu">
<% if current_user_admin? %>
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
<li><hr class="dropdown-divider"></li>
<% end %>
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li>
</ul>
</li>
<% else %>
<% if User.none? %>
<li class="nav-item">
<%= link_to "Create Admin Account", new_registration_path, class: "nav-link btn btn-success text-white" %>
</li>
<% else %>
<li class="nav-item">
<%= link_to "Sign In", new_session_path, class: "nav-link" %>
</li>
<% end %>
<% end %>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,21 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Update your password</h1>
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="my-5">
<%= 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" %>
</div>
<div class="inline">
<%= 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" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,17 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="inline">
<%= 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" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,6 @@
<p>
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) %>.
</p>

View File

@@ -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) %>.

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">Registrations#create</h1>
<p>Find me in app/views/registrations/create.html.erb</p>
</div>

View File

@@ -0,0 +1,61 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-blue-900 mb-2">🎉 Welcome to Baffle Hub!</h2>
<p class="text-blue-700">
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.
</p>
</div>
<h1 class="font-bold text-3xl mb-6">Create Administrator Account</h1>
<%= form_with(model: @user, url: registration_path, class: "contents") do |form| %>
<% if @user.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<h2 class="text-red-800 font-medium mb-2">
<%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:
</h2>
<ul class="list-disc list-inside text-red-700">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5">
<%= 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" %>
</div>
<div class="my-5">
<%= 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" %>
<p class="mt-1 text-sm text-gray-600">Minimum 8 characters</p>
</div>
<div class="my-5">
<%= 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" %>
</div>
<div class="inline">
<%= 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" %>
</div>
<% end %>
<div class="mt-8 text-sm text-gray-600">
<p class="mb-2">This administrator account will have:</p>
<ul class="list-disc list-inside space-y-1">
<li>Full system access and control</li>
<li>Ability to manage other users</li>
<li>Permission to create and manage projects</li>
<li>Access to system configuration and analytics</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,57 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<% if @show_registration %>
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-green-900 mb-2">🚀 First Time Setup</h2>
<p class="text-green-700 mb-4">
No administrator account exists yet. Create the first administrator account to get started with Baffle Hub.
</p>
<%= 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" %>
</div>
<div class="mt-6 text-center">
<span class="text-gray-500">or</span>
</div>
<% end %>
<h1 class="font-bold text-4xl">Sign in</h1>
<% if @show_oidc_login && !@show_registration %>
<div class="my-6">
<%= 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" %>
<div class="mt-4 text-center">
<span class="text-gray-500">or</span>
</div>
</div>
<% end %>
<%= form_with url: session_url, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="my-5">
<%= 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" %>
</div>
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="inline">
<%= 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" %>
</div>
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,63 @@
<div class="mx-auto md:w-2/3 w-full">
<div class="flex items-center mb-6">
<%= link_to "← Back to Users", users_path, class: "text-blue-600 hover:text-blue-800" %>
</div>
<h1 class="font-bold text-3xl mb-6">Edit User</h1>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<div class="bg-white shadow rounded-lg p-6">
<div class="mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">User Information</h2>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Email Address</label>
<div class="mt-1 text-sm text-gray-900"><%= @user.email_address %></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Created</label>
<div class="mt-1 text-sm text-gray-900"><%= @user.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
</div>
</div>
</div>
<%= form_with(model: @user, class: "contents") do |form| %>
<div class="mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Role Assignment</h2>
<div class="space-y-3">
<div class="flex items-center">
<%= 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" %>
<span class="ml-2 text-sm text-gray-500">- Full system access, user management, project creation</span>
</div>
<div class="flex items-center">
<%= 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" %>
<span class="ml-2 text-sm text-gray-500">- Read/write access to projects</span>
</div>
<div class="flex items-center">
<%= 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" %>
<span class="ml-2 text-sm text-gray-500">- Read-only access to all projects</span>
</div>
</div>
</div>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<div class="mx-auto md:w-4/5 w-full">
<div class="flex justify-between items-center mb-6">
<h1 class="font-bold text-3xl">User Management</h1>
<div class="text-sm text-gray-600">
Total users: <%= @users.count %>
</div>
</div>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200">
<% @users.each do |user| %>
<li>
<div class="px-4 py-4 sm:px-6 hover:bg-gray-50">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-sm font-medium text-gray-700">
<%= user.email_address.first.upcase %>
</span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
<%= user.email_address %>
</div>
<div class="text-sm text-gray-500">
Joined <%= user.created_at.strftime("%B %d, %Y") %>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<% if user.admin? %>bg-purple-100 text-purple-800
<% elsif user.viewer? %>bg-gray-100 text-gray-800
<% else %>bg-blue-100 text-blue-800<% end %>">
<%= user.role.capitalize %>
</span>
<%= 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" %>
</div>
</div>
</div>
</li>
<% end %>
</ul>
</div>
<% if @users.empty? %>
<div class="text-center py-12">
<p class="text-gray-500">No users found.</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">Users#show</h1>
<p>Find me in app/views/users/show.html.erb</p>
</div>

View File

@@ -0,0 +1,4 @@
<div>
<h1 class="font-bold text-4xl">Users#update</h1>
<p>Find me in app/views/users/update.html.erb</p>
</div>

View File

@@ -6,6 +6,7 @@ default: &default
namespace: <%= Rails.env %> namespace: <%= Rails.env %>
development: development:
database: cache
<<: *default <<: *default
test: test:

View File

@@ -10,8 +10,21 @@ default: &default
timeout: 5000 timeout: 5000
development: development:
<<: *default primary:
database: storage/development.sqlite3 <<: *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 # Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake". # re-generated from your development database when you run "rake".

View File

@@ -87,4 +87,18 @@ Rails.application.configure do
# #
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } } # 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 end

View File

@@ -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]

View File

@@ -1,4 +1,16 @@
Rails.application.routes.draw do 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 # 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. # 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 # WAF API
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
# Event ingestion # Event ingestion (PRIMARY method - includes rule updates in response)
post ":project_id/events", to: "events#create" 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/version", to: "rules#version"
get ":public_key/rules", to: "rules#index" get ":public_key/rules", to: "rules#index"
end end

View File

@@ -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| create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false 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.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"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"

View File

@@ -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| 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.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 ["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", "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 t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddRoleToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :role, :integer, default: 1, null: false
end
end

View File

@@ -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| 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.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_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.datetime "expires_at", null: false
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" t.bigint "job_id", null: false
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true 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 end
create_table "solid_queue_claimed_executions", force: :cascade do |t| create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "job_id", null: false t.bigint "job_id", null: false
t.bigint "process_id" 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 [ "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 [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end end
create_table "solid_queue_failed_executions", force: :cascade do |t| 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.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 end
create_table "solid_queue_jobs", force: :cascade do |t| 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.string "active_job_id"
t.datetime "scheduled_at" t.text "arguments"
t.datetime "finished_at" t.string "class_name", null: false
t.string "concurrency_key" t.string "concurrency_key"
t.datetime "created_at", null: false 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.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" 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 ["class_name"], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" 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 ["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 ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
end end
create_table "solid_queue_pauses", force: :cascade do |t| create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", 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 end
create_table "solid_queue_processes", force: :cascade do |t| create_table "solid_queue_processes", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "hostname"
t.string "kind", null: false t.string "kind", null: false
t.datetime "last_heartbeat_at", 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.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" t.integer "pid", null: false
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.bigint "supervisor_id"
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_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 end
create_table "solid_queue_ready_executions", force: :cascade do |t| 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.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.bigint "job_id", null: false
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" t.integer "priority", default: 0, null: false
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" 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 end
create_table "solid_queue_recurring_executions", force: :cascade do |t| 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.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.bigint "job_id", null: false
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true 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 end
create_table "solid_queue_recurring_tasks", force: :cascade do |t| 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.text "arguments"
t.string "queue_name" t.string "class_name"
t.integer "priority", default: 0 t.string "command", limit: 2048
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false 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.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true 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 ["static"], name: "index_solid_queue_recurring_tasks_on_static"
end end
create_table "solid_queue_scheduled_executions", force: :cascade do |t| 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.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.bigint "job_id", null: false
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" 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 end
create_table "solid_queue_semaphores", force: :cascade do |t| 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 "created_at", null: false
t.datetime "expires_at", null: false
t.string "key", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" t.integer "value", default: 1, null: false
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true 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 end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "events", force: :cascade do |t|
t.string "agent_name" t.string "agent_name"
t.string "agent_version" 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" t.index ["updated_at", "id"], name: "idx_rules_sync"
end 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", "projects"
add_foreign_key "events", "request_hosts" add_foreign_key "events", "request_hosts"
add_foreign_key "sessions", "users"
end end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

9
test/fixtures/users.yml vendored Normal file
View File

@@ -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 %>

View File

@@ -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

8
test/models/user_test.rb Normal file
View File

@@ -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

View File

@@ -1,6 +1,7 @@
ENV["RAILS_ENV"] ||= "test" ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment" require_relative "../config/environment"
require "rails/test_help" require "rails/test_help"
require_relative "test_helpers/session_test_helper"
module ActiveSupport module ActiveSupport
class TestCase class TestCase

View File

@@ -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