ip address role take 1

This commit is contained in:
Dylan Knutson
2025-03-03 05:47:51 +00:00
parent 111a22ff8a
commit 04661a8505
6 changed files with 170 additions and 70 deletions

View File

@@ -5,6 +5,17 @@ class ApplicationController < ActionController::Base
include Pundit::Authorization
include Devise::Controllers::Helpers::ClassMethods
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
sig { returns(T.nilable(T.any(User, IpAddressRole))) }
def pundit_user
current_user || current_ip_address_role
end
before_action do
if Rails.env.development? || Rails.env.staging?
Rack::MiniProfiler.authorize_request
@@ -16,14 +27,6 @@ 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

@@ -1,5 +1,9 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import {
validateIpAddress,
type ValidationResult,
} from '../utils/ipValidation';
interface IpAddressInputProps {
initialValue?: string;
@@ -10,17 +14,11 @@ interface IpAddressInputProps {
className?: string;
}
type ValidationResult = {
isValid: boolean;
message: string;
type: 'ip' | 'cidr' | 'none';
};
const IpAddressInput: React.FC<IpAddressInputProps> = ({
initialValue = '',
name = 'ip_address_role[ip_address]',
id,
placeholder = 'Example: 192.168.1.1 or 10.0.0.0/24',
placeholder = 'Example: 192.168.1.1, 2001:db8::1, or 10.0.0.0/24',
onChange,
className = '',
}) => {
@@ -32,47 +30,6 @@ const IpAddressInput: React.FC<IpAddressInputProps> = ({
});
const [isFocused, setIsFocused] = useState(false);
// Regular expressions for validation
const ipv4Regex =
/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$/;
const cidrRegex =
/^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}\/(?:[0-9]|[1-2][0-9]|3[0-2])$/;
// Function to validate the input
const validateIpAddress = (input: string): ValidationResult => {
if (!input) {
return {
isValid: false,
message: 'IP address is required',
type: 'none',
};
}
if (input.includes('/')) {
// CIDR notation
if (cidrRegex.test(input)) {
return { isValid: true, message: 'Valid CIDR range', type: 'cidr' };
} else {
return {
isValid: false,
message: 'Invalid CIDR range format',
type: 'cidr',
};
}
} else {
// Single IP address
if (ipv4Regex.test(input)) {
return { isValid: true, message: 'Valid IP address', type: 'ip' };
} else {
return {
isValid: false,
message: 'Invalid IP address format',
type: 'ip',
};
}
}
};
// Update validation when value changes
useEffect(() => {
const result = validateIpAddress(value);
@@ -100,7 +57,7 @@ const IpAddressInput: React.FC<IpAddressInputProps> = ({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className={`block w-full rounded-md shadow-sm focus:ring-sky-500 sm:text-sm ${getBorderColorClass()} ${className}`}
className={`block w-full rounded-md font-mono shadow-sm focus:ring-sky-500 sm:text-sm ${getBorderColorClass()} ${className}`}
id={id || 'ip_address_input'}
/>
@@ -150,18 +107,20 @@ const IpAddressInput: React.FC<IpAddressInputProps> = ({
)}
{/* Additional helpful information */}
<div className="mt-2 text-sm text-slate-500">
{validation.type === 'cidr' ? (
<span>
CIDR notation represents an IP range (e.g., 10.0.0.0/24 includes 256
addresses)
</span>
) : (
<span>
Enter a single IP address (e.g., 192.168.1.1) or an IP range in CIDR
notation (e.g., 10.0.0.0/24)
</span>
)}
<div className="mt-2 min-w-[400px] text-sm text-slate-500">
<div className="flex items-center">
{validation.type === 'cidr-v4' || validation.type === 'cidr-v6' ? (
<span>
CIDR notation represents an IP range (e.g., 10.0.0.0/24 for IPv4
or 2001:db8::/32 for IPv6)
</span>
) : (
<span>
Enter a single IP address (IPv4: 192.168.1.1 or IPv6: 2001:db8::1)
or an IP range in CIDR notation
</span>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,128 @@
export type ValidationResult = {
isValid: boolean;
message: string;
type: 'ipv4' | 'ipv6' | 'cidr-v4' | 'cidr-v6' | 'none';
};
// Utility functions for IP address validation
const isIPv4Segment = (segment: string): boolean => {
const num = Number(segment);
return !isNaN(num) && num >= 0 && num <= 255 && segment === num.toString();
};
const isIPv4Address = (address: string): boolean => {
const segments = address.split('.');
return segments.length === 4 && segments.every(isIPv4Segment);
};
const isIPv6Segment = (segment: string): boolean => {
return segment.length <= 4 && /^[0-9a-fA-F]*$/.test(segment);
};
const isIPv6Address = (address: string): boolean => {
// Handle the :: compression
const parts = address.split('::');
if (parts.length > 2) return false; // More than one :: is invalid
if (parts.length === 2) {
const [left, right] = parts;
const leftSegments = left ? left.split(':') : [];
const rightSegments = right ? right.split(':') : [];
// Total segments should be 8 after decompression
if (leftSegments.length + rightSegments.length > 7) return false;
// Validate each segment
return (
leftSegments.every(isIPv6Segment) && rightSegments.every(isIPv6Segment)
);
}
// No compression, should be exactly 8 segments
const segments = address.split(':');
return segments.length === 8 && segments.every(isIPv6Segment);
};
const isCIDRPrefix = (prefix: string, isIPv6: boolean): boolean => {
const num = Number(prefix);
return !isNaN(num) && num >= 0 && num <= (isIPv6 ? 128 : 32);
};
const validateCIDR = (input: string): ValidationResult | null => {
const [address, prefix] = input.split('/');
if (!prefix) return null;
// Check if it's IPv6 CIDR
if (address.includes(':')) {
if (!isIPv6Address(address) || !isCIDRPrefix(prefix, true)) {
return {
isValid: false,
message: 'Invalid IPv6 CIDR range format',
type: 'none',
};
}
return {
isValid: true,
message: 'Valid IPv6 CIDR range',
type: 'cidr-v6',
};
}
// Check if it's IPv4 CIDR
if (!isIPv4Address(address) || !isCIDRPrefix(prefix, false)) {
return {
isValid: false,
message: 'Invalid IPv4 CIDR range format',
type: 'none',
};
}
return {
isValid: true,
message: 'Valid IPv4 CIDR range',
type: 'cidr-v4',
};
};
export const validateIpAddress = (input: string): ValidationResult => {
if (!input) {
return {
isValid: false,
message: 'IP address is required',
type: 'none',
};
}
if (input.includes('/')) {
const cidrResult = validateCIDR(input);
if (cidrResult) return cidrResult;
return {
isValid: false,
message: 'Invalid CIDR range format',
type: 'none',
};
}
// Single IP address validation
if (isIPv4Address(input)) {
return {
isValid: true,
message: 'Valid IPv4 address',
type: 'ipv4',
};
}
if (isIPv6Address(input)) {
return {
isValid: true,
message: 'Valid IPv6 address',
type: 'ipv6',
};
}
return {
isValid: false,
message: 'Invalid IP address format',
type: 'none',
};
};

View File

@@ -4,7 +4,7 @@
class ApplicationPolicy
extend T::Sig
sig { returns(T.nilable(User)) }
sig { returns(T.nilable(T.any(User, IpAddressRole))) }
attr_reader :user
sig { returns(T.untyped) }

View File

@@ -38,6 +38,12 @@
<%= request.remote_ip %>
</span>
</span>
<span class="font-mono text-slate-500">
<span>IP Address Role</span>
<span class="font-mono text-slate-700">
<%= current_ip_address_role ? current_ip_address_role.ip_address : "None" %>
</span>
</span>
</span>
<div class="flex-grow"></div>
<nav class="flex items-center space-x-4">

View File

@@ -11,4 +11,8 @@ module Pundit::Authorization
end
def policy_scope(scope, policy_scope_class: T.unsafe(nil))
end
sig { returns(Pundit::Context) }
def pundit
end
end