first pass on ip address roles

This commit is contained in:
Dylan Knutson
2025-03-02 08:21:14 +00:00
parent 9256d78bf5
commit c6a7b9d49a
13 changed files with 1736 additions and 6 deletions

View File

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

View 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

View 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

View File

@@ -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'),

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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

View 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