Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
113 changes: 113 additions & 0 deletions app/controllers/api/v1/internal/rdap/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
module Api
module V1
module Internal
module Rdap
# Registry-side implementation of the six pinned RDAP token operations
# (RDAP spec 10-rdap-issued-api-token). RDAP owns no database; it mints an
# opaque token, keyed-HMACs it, and persists ONLY the digest + non-PII
# metadata here through this endpoint. The raw token and the HMAC secret
# never cross this boundary.
#
# `token_hash` everywhere is that keyed HMAC-SHA-256 digest — the store key
# and the request-time lookup key. The subject is sensitive PII (a national
# id): read it from the request body / query string, never from the path,
# and never log it (filtered in filter_parameter_logging.rb).
class TokensController < BaseController
# POST /api/v1/internal/rdap/tokens
# Persist a freshly minted token. Body: token_hash, subject, token_class,
# expires_at (required); label, issued_at (optional). Returns the stored row.
def create
token = RdapApiToken.new(
token_hash: params[:token_hash],
subject: params[:subject],
token_class: params[:token_class],
label: params[:label].presence,
issued_at: parse_time(params[:issued_at]) || Time.zone.now,
expires_at: parse_time(params[:expires_at])
)

if token.save
render json: serialize(token), status: :created
else
render_error(token.errors.full_messages.join(', '), :unprocessable_entity)
end
end

# GET /api/v1/internal/rdap/tokens/active?token_hash=:digest
# Resolve a presented digest to its ACTIVE row (not revoked, not expired),
# or 404. Not-found / revoked / expired are indistinguishable to the caller.
def active
token = RdapApiToken.active_by_hash(params[:token_hash]).first

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

# GET /api/v1/internal/rdap/tokens?subject=:eeid_subject
# The caller's own tokens (metadata only) for the self-service affordance.
def index
tokens = RdapApiToken.for_subject(params[:subject]).order(issued_at: :desc)
render json: tokens.map { |token| serialize(token) }, status: :ok
end

# POST /api/v1/internal/rdap/tokens/revoke { token_hash }
# Revoke one token by digest. Idempotent, 204 even for an unknown digest.
def revoke
token = RdapApiToken.find_by(token_hash: params[:token_hash])
token&.revoke!
head :no_content
end

# POST /api/v1/internal/rdap/tokens/revoke_all { subject }
# Revoke EVERY not-yet-revoked token for the subject (the kill-all path).
# Returns the number revoked (for the operator rake task output).
def revoke_all
count = RdapApiToken.for_subject(params[:subject])
.where(revoked_at: nil)
.update_all(revoked_at: Time.zone.now)
render json: { revoked_count: count }, status: :ok
end

# POST /api/v1/internal/rdap/tokens/touch { token_hash }
# Best-effort last-used marker. Updates last_used_at ONLY, never expires_at.
# Non-blocking, idempotent, 204 even for an unknown digest.
def touch
token = RdapApiToken.find_by(token_hash: params[:token_hash])
token&.touch_last_used!
head :no_content
end

private

def serialize(token)
{
token_hash: token.token_hash,
subject: token.subject,
token_class: token.token_class,
label: token.label,
issued_at: iso8601(token.issued_at),
expires_at: iso8601(token.expires_at),
last_used_at: iso8601(token.last_used_at),
revoked_at: iso8601(token.revoked_at),
}
end

def iso8601(value)
value&.utc&.iso8601
end

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

Time.zone.parse(value.to_s)
rescue ArgumentError
nil
end
end
end
end
end
end
Loading
Loading