Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions app/controllers/admin/rdap_privilege_grants_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
module Admin
class RdapPrivilegeGrantsController < BaseController
load_and_authorize_resource

def index
@q = RdapPrivilegeGrant.ransack(params[:q])
@rdap_privilege_grants = @q.result.page(params[:page]).order(created_at: :desc)
@count = @q.result.count
@rdap_privilege_grants = @rdap_privilege_grants.per(params[:results_per_page]) if paginate?
end

def new
@rdap_privilege_grant = RdapPrivilegeGrant.new
end

def show; end

def edit; end

def create
@rdap_privilege_grant = RdapPrivilegeGrant.new(rdap_privilege_grant_params)

if @rdap_privilege_grant.save
flash[:notice] = I18n.t('record_created')
redirect_to [:admin, @rdap_privilege_grant]
else
flash.now[:alert] = I18n.t('failed_to_create_record')
render 'new'
end
end

def update
if @rdap_privilege_grant.update(rdap_privilege_grant_params)
flash[:notice] = I18n.t('record_updated')
redirect_to [:admin, @rdap_privilege_grant]
else
flash.now[:alert] = I18n.t('failed_to_update_record')
render 'edit'
end
end

# Suspend and revoke are distinct member actions that change only `status`,
# never a generic edit of unrelated fields (RPD §9 lines 461; AC4/AC5).
def suspend
@rdap_privilege_grant.update!(status: 'suspended')
flash[:notice] = I18n.t('admin.rdap_privilege_grants.grant_suspended')
redirect_to [:admin, @rdap_privilege_grant]
end

def revoke
@rdap_privilege_grant.update!(status: 'revoked')
flash[:notice] = I18n.t('admin.rdap_privilege_grants.grant_revoked')
redirect_to [:admin, @rdap_privilege_grant]
end

private

def rdap_privilege_grant_params
params.require(:rdap_privilege_grant).permit(:eeid_subject,
:full_name,
:legal_basis_ref,
:personal_id_code,
:organization,
:category,
:valid_from,
:valid_until,
:notes)
end
end
end
63 changes: 63 additions & 0 deletions app/controllers/api/v1/internal/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
module Api
module V1
module Internal
# Base controller for the internal, machine-to-machine RDAP data API.
#
# Auth (v1 baseline): pre-shared key + IP-allowlist. Modeled on the
# accreditation_center internal API (IP-allowlist shape) and the
# api/v1/base_controller authenticate_shared_key pattern. mTLS is a later
# production-hardening step, not built here. The endpoint is non-public.
class BaseController < ActionController::API
before_action :check_ip_whitelist, :authenticate_shared_key

rescue_from ActiveRecord::RecordNotFound, with: :show_not_found_error
rescue_from StandardError, with: :show_standard_error

private

def authenticate_shared_key
key = ENV['rdap_internal_api_shared_key'].to_s
# Fail closed when the key is not configured — never let a blank/unset
# secret degrade into an "everyone matches Basic " bypass.
return render_error('Invalid authorization information', :unauthorized) if key.empty?

expected = "Basic #{key}"
provided = request.authorization.to_s

return if ActiveSupport::SecurityUtils.secure_compare(expected, provided)

render_error('Invalid authorization information', :unauthorized)
end

def check_ip_whitelist
return if ip_allowed?(request.ip) || Rails.env.development?

render_error("IP address #{request.ip} is not authorized", :unauthorized)
end

def ip_allowed?(ip)
allowed_ips = ENV['rdap_internal_api_allowed_ips'].to_s.split(',').map(&:strip)
allowed_ips.any? do |entry|
begin
IPAddr.new(entry).include?(ip)
rescue IPAddr::InvalidAddressError
ip == entry
end
end
end

def show_not_found_error
render_error('Not found', :not_found)
end

def show_standard_error(exception)
render_error(exception.message, :internal_server_error)
end

def render_error(message, status)
render json: { message: message }, status: status
end
end
end
end
end
64 changes: 64 additions & 0 deletions app/controllers/api/v1/internal/rdap/access_events_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module Api
module V1
module Internal
module Rdap
# Registry-side store for PRIVILEGED RDAP access events (RDAP spec 11).
# RDAP owns no database; on a real (result_code 200) privileged disclosure
# it POSTs the non-PII facts here and the registry snapshots the resolved
# grant into an insert-only row. The eeID subject / personal id code are
# sensitive PII and MUST NEVER be read or stored here.
class AccessEventsController < BaseController
# POST /api/v1/internal/rdap/access-events
# Body: grant_id, domain_name, requested_at, caller_ip, result_code,
# request_id (optional). Returns 204 on success.
def create
grant = RdapPrivilegeGrant.find_by(uuid: params[:grant_id]) ||
RdapPrivilegeGrant.find_by(id: params[:grant_id])
return render_error('Grant not found', :not_found) unless grant
return render_error('result_code must be 200', :unprocessable_entity) if params[:result_code].to_i != 200

requested_at = parse_time(params[:requested_at])
return render_error('requested_at is invalid', :unprocessable_entity) if requested_at.nil?

event = RdapAccessEvent.new(
requested_at: requested_at,
domain_name: params[:domain_name],
caller_ip: params[:caller_ip],
result_code: params[:result_code],
organization_name: grant.organization,
accessor_name: grant.full_name,
category: grant.category,
grant_ref: grant.grant_id,
request_id: params[:request_id].presence
)

if event.save
head :no_content
else
render_error(event.errors.full_messages.join(', '), :unprocessable_entity)
end
rescue StandardError => e
# Unexpected persistence error ONLY. The explicit rescue is required so
# the technical-log error + failure metric run before returning 500 —
# BaseController's rescue_from would render 500 but skip the telemetry.
# NEVER log eeid_subject / personal_id_code.
Rails.logger.error("[rdap_access_event] record failed domain=#{params[:domain_name]} " \
"grant_ref=#{grant&.grant_id} error=#{e.class}: #{e.message}")
NewRelic::Agent.increment_metric('Custom/Rdap/access_event_record_failure')
render_error('Access event could not be recorded', :internal_server_error)
end

private

def parse_time(value)
return nil if value.blank?

Time.zone.parse(value.to_s)
rescue ArgumentError
nil
end
end
end
end
end
end
26 changes: 26 additions & 0 deletions app/controllers/api/v1/internal/rdap/domains_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require 'serializers/rdap/domain'

module Api
module V1
module Internal
module Rdap
class DomainsController < BaseController
def show
name = params[:name].to_s
domain = Domain
.where(name: name).or(Domain.where(name_puny: name))
.includes(:registrant, :admin_contacts, :tech_contacts,
:registrar, :nameservers, :dnskeys)
.first

if domain
render json: Serializers::Rdap::Domain.new(domain).as_json, status: :ok
else
render_error('Domain not found', :not_found)
end
end
end
end
end
end
end
60 changes: 60 additions & 0 deletions app/controllers/api/v1/internal/rdap/grants_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module Api
module V1
module Internal
module Rdap
class GrantsController < BaseController
# GET /api/v1/internal/rdap/grants/active?subject=:eeid_subject
#
# Resolve the single active privileged-access grant for an eeID
# subject. "Active" is computed server-side (RdapPrivilegeGrant
# .active_for_subject); on multiple active grants the latest valid_from
# wins. Fail-closed: no active grant -> 404 (RDAP never escalates to
# privileged). The subject is sensitive PII (a national id): read it
# from the query string, never from the path, and do not log it.
def active
grant = RdapPrivilegeGrant.active_for_subject(params[:subject]).first

if grant
render json: serialize(grant), status: :ok
else
render_error('No active grant', :not_found)
end
end

# POST /api/v1/internal/rdap/grants/:id/touch
#
# Best-effort last-used marker. Non-blocking, idempotent. 204 on
# success, 404 if the grant is unknown.
def touch
grant = RdapPrivilegeGrant.find_by(uuid: params[:id]) ||
RdapPrivilegeGrant.find_by(id: params[:id])

return render_error('Grant not found', :not_found) unless grant

grant.update_columns(last_used_at: Time.zone.now)
head :no_content
end

private

def serialize(grant)
{
grant_id: grant.grant_id,
eeid_subject: grant.eeid_subject,
privilege_category: grant.category,
organization: grant.organization.presence || grant.category,
privileges: [grant.category],
status: grant.status,
valid_from: iso8601(grant.valid_from),
valid_until: iso8601(grant.valid_until),
}
end

def iso8601(value)
value&.utc&.iso8601
end
end
end
end
end
end
29 changes: 29 additions & 0 deletions app/controllers/api/v1/internal/rdap/nameservers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module Api
module V1
module Internal
module Rdap
class NameserversController < BaseController
# Thin shape: {hostname, hostname_puny} only, DISTINCT-collapsed.
# A host serves many domains (no global unique on hostname) — return
# one result. NO glue (ipv4/ipv6), NO domain list (prevents
# enumeration disclosure).
def show
host = params[:host].to_s
nameserver = Nameserver
.where(hostname: host).or(Nameserver.where(hostname_puny: host))
.first

if nameserver
render json: {
hostname: nameserver.hostname,
hostname_puny: nameserver.hostname_puny,
}, status: :ok
else
render_error('Nameserver not found', :not_found)
end
end
end
end
end
end
end
27 changes: 27 additions & 0 deletions app/controllers/api/v1/internal/rdap/registrars_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Api
module V1
module Internal
module Rdap
class RegistrarsController < BaseController
# Narrow entity shape: {code, name, phone, website} only.
# email and reg_no MUST NOT appear here (they live only inside the
# domain payload, §1.4).
def show
registrar = Registrar.find_by(code: params[:code].to_s.upcase)

if registrar
render json: {
code: registrar.code,
name: registrar.name,
phone: registrar.phone,
website: registrar.website,
}, status: :ok
else
render_error('Registrar not found', :not_found)
end
end
end
end
end
end
end
Loading
Loading