first pass on ip address roles
This commit is contained in:
@@ -16,6 +16,14 @@ class ApplicationController < ActionController::Base
|
||||
# Pundit authorization error handling
|
||||
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
||||
|
||||
# Returns the IpAddressRole for the current request's IP address
|
||||
# This is similar to current_user but based on IP address
|
||||
sig { returns(T.nilable(IpAddressRole)) }
|
||||
def current_ip_address_role
|
||||
@current_ip_address_role ||= IpAddressRole.for_ip(request.remote_ip)
|
||||
end
|
||||
helper_method :current_ip_address_role
|
||||
|
||||
protected
|
||||
|
||||
def prometheus_client
|
||||
|
||||
49
app/models/ip_address_role.rb
Normal file
49
app/models/ip_address_role.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# typed: strict
|
||||
class IpAddressRole < ReduxApplicationRecord
|
||||
extend T::Sig
|
||||
|
||||
# Use the same role enum as User for consistency
|
||||
enum :role, User.roles, default: :user
|
||||
|
||||
validates :ip_address, presence: true, uniqueness: true
|
||||
validates :role, presence: true
|
||||
validate :no_overlapping_ip_ranges
|
||||
|
||||
# Find an IpAddressRole for a given IP address
|
||||
# If no match is found, returns nil (similar to current_user behavior)
|
||||
sig { params(ip_addr: String).returns(T.nilable(IpAddressRole)) }
|
||||
def self.for_ip(ip_addr)
|
||||
begin
|
||||
# Find the first active role where the IP is contained in the range
|
||||
# Using PostgreSQL's >>= operator (contains or equals)
|
||||
where("ip_address >>= ?::inet", ip_addr).where(active: true).first
|
||||
rescue ActiveRecord::StatementInvalid
|
||||
# If the IP address is invalid, return nil
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Custom validation to prevent overlapping IP ranges
|
||||
sig { void }
|
||||
def no_overlapping_ip_ranges
|
||||
return if ip_address.blank? # Skip if IP address is not provided
|
||||
|
||||
# Skip validation if this record is already persisted and IP hasn't changed
|
||||
return if persisted? && !ip_address_changed?
|
||||
|
||||
# Check for any existing IP ranges that overlap with this one
|
||||
# The PostgreSQL && operator returns true if there is any overlap between the CIDR ranges
|
||||
# Use string for the IP address to avoid the IPAddr casting issue
|
||||
overlapping_roles =
|
||||
IpAddressRole.where("ip_address && '#{ip_address}'::inet")
|
||||
|
||||
# Exclude self if updating an existing record
|
||||
overlapping_roles = overlapping_roles.where.not(id: id) if persisted?
|
||||
|
||||
if overlapping_roles.exists?
|
||||
errors.add(:ip_address, "overlaps with an existing IP range")
|
||||
end
|
||||
end
|
||||
end
|
||||
13
db/migrate/20250302074924_create_ip_address_roles.rb
Normal file
13
db/migrate/20250302074924_create_ip_address_roles.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class CreateIpAddressRoles < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :ip_address_roles do |t|
|
||||
t.inet :ip_address, null: false
|
||||
t.string :role, null: false, default: "user"
|
||||
t.string :description
|
||||
t.boolean :active, null: false, default: true
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :ip_address_roles, :ip_address, unique: true
|
||||
end
|
||||
end
|
||||
@@ -3412,6 +3412,40 @@ ALTER SEQUENCE public.indexed_posts_id_seq OWNED BY public.indexed_posts.id;
|
||||
|
||||
SET default_tablespace = '';
|
||||
|
||||
--
|
||||
-- Name: ip_address_roles; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.ip_address_roles (
|
||||
id bigint NOT NULL,
|
||||
ip_address inet NOT NULL,
|
||||
role character varying DEFAULT 'user'::character varying NOT NULL,
|
||||
description character varying,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ip_address_roles_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.ip_address_roles_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: ip_address_roles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.ip_address_roles_id_seq OWNED BY public.ip_address_roles.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: log_store_sst_entries; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
@@ -4652,6 +4686,13 @@ ALTER TABLE ONLY public.http_log_entry_headers ALTER COLUMN id SET DEFAULT nextv
|
||||
ALTER TABLE ONLY public.indexed_posts ALTER COLUMN id SET DEFAULT nextval('public.indexed_posts_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ip_address_roles id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.ip_address_roles ALTER COLUMN id SET DEFAULT nextval('public.ip_address_roles_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pghero_query_stats id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5493,6 +5534,14 @@ ALTER TABLE ONLY public.indexed_posts
|
||||
ADD CONSTRAINT indexed_posts_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ip_address_roles ip_address_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.ip_address_roles
|
||||
ADD CONSTRAINT ip_address_roles_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
SET default_tablespace = mirai;
|
||||
|
||||
--
|
||||
@@ -7403,6 +7452,13 @@ SET default_tablespace = '';
|
||||
CREATE INDEX index_indexed_posts_on_posted_at ON public.indexed_posts USING btree (posted_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_ip_address_roles_on_ip_address; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX index_ip_address_roles_on_ip_address ON public.ip_address_roles USING btree (ip_address);
|
||||
|
||||
|
||||
SET default_tablespace = mirai;
|
||||
|
||||
--
|
||||
@@ -8729,6 +8785,7 @@ ALTER TABLE ONLY public.domain_twitter_tweets
|
||||
SET search_path TO "$user", public;
|
||||
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20250302074924'),
|
||||
('20250226003653'),
|
||||
('20250222035939'),
|
||||
('20250206224121'),
|
||||
|
||||
5
sorbet/rbi/dsl/application_controller.rbi
generated
5
sorbet/rbi/dsl/application_controller.rbi
generated
@@ -31,11 +31,11 @@ class ApplicationController
|
||||
include ::ApplicationHelper
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::DescriptionsHelper
|
||||
include ::Domain::E621::PostsHelper
|
||||
@@ -55,6 +55,9 @@ class ApplicationController
|
||||
include ::ReactOnRailsHelper
|
||||
include ::Pundit::Helper
|
||||
|
||||
sig { returns(T.nilable(::IpAddressRole)) }
|
||||
def current_ip_address_role; end
|
||||
|
||||
sig { params(record: T.untyped).returns(T.untyped) }
|
||||
def policy(record); end
|
||||
|
||||
|
||||
2
sorbet/rbi/dsl/devise_controller.rbi
generated
2
sorbet/rbi/dsl/devise_controller.rbi
generated
@@ -28,11 +28,11 @@ class DeviseController
|
||||
include ::ApplicationHelper
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::DescriptionsHelper
|
||||
include ::Domain::E621::PostsHelper
|
||||
|
||||
2
sorbet/rbi/dsl/good_job/jobs_controller.rbi
generated
2
sorbet/rbi/dsl/good_job/jobs_controller.rbi
generated
@@ -27,11 +27,11 @@ class GoodJob::JobsController
|
||||
include ::GoodJob::ApplicationController::HelperMethods
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::PostGroupsHelper
|
||||
include ::GoodJobHelper
|
||||
|
||||
1451
sorbet/rbi/dsl/ip_address_role.rbi
generated
Normal file
1451
sorbet/rbi/dsl/ip_address_role.rbi
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
sorbet/rbi/dsl/rails/application_controller.rbi
generated
2
sorbet/rbi/dsl/rails/application_controller.rbi
generated
@@ -31,11 +31,11 @@ class Rails::ApplicationController
|
||||
include ::ApplicationHelper
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::DescriptionsHelper
|
||||
include ::Domain::E621::PostsHelper
|
||||
|
||||
@@ -31,11 +31,11 @@ class Rails::Conductor::BaseController
|
||||
include ::ApplicationHelper
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::DescriptionsHelper
|
||||
include ::Domain::E621::PostsHelper
|
||||
|
||||
2
sorbet/rbi/dsl/rails/health_controller.rbi
generated
2
sorbet/rbi/dsl/rails/health_controller.rbi
generated
@@ -31,11 +31,11 @@ class Rails::HealthController
|
||||
include ::ApplicationHelper
|
||||
include ::HelpersInterface
|
||||
include ::LogEntriesHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::UsersHelper
|
||||
include ::PathsHelper
|
||||
include ::Domain::DomainsHelper
|
||||
include ::Domain::DomainModelHelper
|
||||
include ::Pundit::Authorization
|
||||
include ::Domain::PostsHelper
|
||||
include ::Domain::DescriptionsHelper
|
||||
include ::Domain::E621::PostsHelper
|
||||
|
||||
55
spec/controllers/application_controller_spec.rb
Normal file
55
spec/controllers/application_controller_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# typed: false
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe ApplicationController, type: :controller do
|
||||
# Creating a test controller to access protected methods
|
||||
controller do
|
||||
def index
|
||||
render plain: "Test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#current_ip_address_role" do
|
||||
let(:ip_address) { "192.168.1.1" }
|
||||
|
||||
# Execute a request before each test to initialize the controller
|
||||
before { get :index }
|
||||
|
||||
context "when no IP role exists" do
|
||||
before do
|
||||
# Mock the remote_ip method to return our test IP
|
||||
allow(controller.request).to receive(:remote_ip).and_return(ip_address)
|
||||
# Mock the IpAddressRole.for_ip method to return nil for this IP
|
||||
allow(IpAddressRole).to receive(:for_ip).with(ip_address).and_return(
|
||||
nil,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns nil" do
|
||||
expect(controller.current_ip_address_role).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when an IP role exists" do
|
||||
let(:ip_role) { instance_double(IpAddressRole) }
|
||||
|
||||
before do
|
||||
# Mock the remote_ip method to return our test IP
|
||||
allow(controller.request).to receive(:remote_ip).and_return(ip_address)
|
||||
# Mock the IpAddressRole.for_ip method to return the test role for this IP
|
||||
allow(IpAddressRole).to receive(:for_ip).with(ip_address).and_return(
|
||||
ip_role,
|
||||
)
|
||||
end
|
||||
|
||||
it "returns the IP role" do
|
||||
expect(controller.current_ip_address_role).to eq(ip_role)
|
||||
end
|
||||
|
||||
it "memoizes the result" do
|
||||
expect(IpAddressRole).to receive(:for_ip).once.and_return(ip_role)
|
||||
2.times { controller.current_ip_address_role }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
94
spec/models/ip_address_role_spec.rb
Normal file
94
spec/models/ip_address_role_spec.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
# typed: false
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe IpAddressRole, type: :model do
|
||||
describe "validations" do
|
||||
it { is_expected.to validate_presence_of(:ip_address) }
|
||||
|
||||
it "validates uniqueness of ip_address" do
|
||||
# Create a record first to test against
|
||||
IpAddressRole.create!(
|
||||
ip_address: "192.168.1.1",
|
||||
role: "user",
|
||||
active: true,
|
||||
)
|
||||
|
||||
# Now test if creating another with the same IP fails
|
||||
new_role = IpAddressRole.new(ip_address: "192.168.1.1", role: "admin")
|
||||
expect(new_role).not_to be_valid
|
||||
expect(new_role.errors[:ip_address]).to include("has already been taken")
|
||||
end
|
||||
|
||||
context "overlapping IP ranges" do
|
||||
before do
|
||||
# Create a record with a CIDR range
|
||||
@existing_role =
|
||||
IpAddressRole.create!(
|
||||
ip_address: "192.168.0.0/16", # This covers 192.168.0.0 to 192.168.255.255
|
||||
role: "moderator",
|
||||
active: true,
|
||||
)
|
||||
end
|
||||
|
||||
it "should not allow a new record with an overlapping IP range" do
|
||||
# This range overlaps with the existing one (192.168.1.0 to 192.168.1.255)
|
||||
new_role =
|
||||
IpAddressRole.new(
|
||||
ip_address: "192.168.1.0/24",
|
||||
role: "admin",
|
||||
active: true,
|
||||
)
|
||||
|
||||
# If we have a validation for overlapping ranges, this should fail
|
||||
# Note: This test might need to be updated if this validation doesn't exist yet
|
||||
expect(new_role).not_to be_valid
|
||||
expect(new_role.errors[:ip_address]).to include(
|
||||
"overlaps with an existing IP range",
|
||||
)
|
||||
end
|
||||
|
||||
it "should not allow a new record with a single IP within an existing range" do
|
||||
# This single IP is within the existing range
|
||||
new_role =
|
||||
IpAddressRole.new(
|
||||
ip_address: "192.168.1.1",
|
||||
role: "user",
|
||||
active: true,
|
||||
)
|
||||
|
||||
# If we have a validation for overlapping IPs, this should fail
|
||||
# Note: This test might need to be updated if this validation doesn't exist yet
|
||||
expect(new_role).not_to be_valid
|
||||
expect(new_role.errors[:ip_address]).to include(
|
||||
"overlaps with an existing IP range",
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow a new record with a non-overlapping IP range" do
|
||||
# This range doesn't overlap (10.0.0.0 to 10.255.255.255)
|
||||
new_role =
|
||||
IpAddressRole.new(
|
||||
ip_address: "10.0.0.0/8",
|
||||
role: "admin",
|
||||
active: true,
|
||||
)
|
||||
|
||||
# This should be valid since it doesn't overlap
|
||||
expect(new_role).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to validate_presence_of(:role) }
|
||||
end
|
||||
|
||||
describe "role enum" do
|
||||
it "defines expected roles" do
|
||||
expect(IpAddressRole.roles.keys).to match_array(%w[user admin moderator])
|
||||
end
|
||||
|
||||
it "defaults to user role" do
|
||||
ip_role = IpAddressRole.new
|
||||
expect(ip_role.role).to eq("user")
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user