diff --git a/app/controllers/admin/rdap_privilege_grants_controller.rb b/app/controllers/admin/rdap_privilege_grants_controller.rb new file mode 100644 index 0000000000..454b37f42c --- /dev/null +++ b/app/controllers/admin/rdap_privilege_grants_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/base_controller.rb b/app/controllers/api/v1/internal/base_controller.rb new file mode 100644 index 0000000000..5641dc2cef --- /dev/null +++ b/app/controllers/api/v1/internal/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/access_events_controller.rb b/app/controllers/api/v1/internal/rdap/access_events_controller.rb new file mode 100644 index 0000000000..1622dbe70f --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/access_events_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/domains_controller.rb b/app/controllers/api/v1/internal/rdap/domains_controller.rb new file mode 100644 index 0000000000..c6729fd300 --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/domains_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/grants_controller.rb b/app/controllers/api/v1/internal/rdap/grants_controller.rb new file mode 100644 index 0000000000..49907ee09c --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/grants_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/nameservers_controller.rb b/app/controllers/api/v1/internal/rdap/nameservers_controller.rb new file mode 100644 index 0000000000..041f42ffb2 --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/nameservers_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/registrars_controller.rb b/app/controllers/api/v1/internal/rdap/registrars_controller.rb new file mode 100644 index 0000000000..51f1219633 --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/registrars_controller.rb @@ -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 diff --git a/app/controllers/api/v1/internal/rdap/tokens_controller.rb b/app/controllers/api/v1/internal/rdap/tokens_controller.rb new file mode 100644 index 0000000000..320fc1fc91 --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/tokens_controller.rb @@ -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 diff --git a/app/controllers/api/v1/registrant/access_events_controller.rb b/app/controllers/api/v1/registrant/access_events_controller.rb new file mode 100644 index 0000000000..fc3877a92f --- /dev/null +++ b/app/controllers/api/v1/registrant/access_events_controller.rb @@ -0,0 +1,34 @@ +module Api + module V1 + module Registrant + # Read-only (spec 13, Surface A). Lists the authority accesses the authenticated + # registrant is entitled to see for ONE of their own domains, addressed by uuid. + # Auth is inherited UNCHANGED from BaseController (Bearer -> current_registrant_user). + class AccessEventsController < ::Api::V1::Registrant::BaseController + # GET /api/v1/registrant/domains/:uuid/access_events + def index + # R8/AC9: does the caller CURRENTLY own THIS uuid? Same single branch for an + # unknown uuid and another registrant's uuid, so the two 404 bodies are + # byte-identical and reveal nothing about existence under another registrant. + domain = current_registrant_user.domains.find_by(uuid: params[:domain_uuid]) + return render json: { errors: [{ base: ['Domain not found'] }] }, + status: :not_found unless domain + + # Scoping derived EXCLUSIVELY from current_registrant_user (R4/N3): the action + # never reads params[:registrant_id] / params[:contact_id] / an ident header. + events = RegistrantAccessEventsQuery.new(domain: domain, + registrant_user: current_registrant_user).call + + # EXACTLY the three R9 keys (N1/AC13): withheld fields cannot appear structurally. + render json: events.map { |event| + { + accessed_at: event.requested_at.iso8601, + organization: event.organization_name, + category: event.category, + } + } + end + end + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 26e6668ff1..c1270fae5d 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -114,6 +114,7 @@ def admin # Admin/admin_user dynamic role can :manage, BankTransaction can :manage, Invoice can :manage, WhiteIp + can :manage, RdapPrivilegeGrant can :manage, Account can :manage, AccountActivity can :manage, Dispute diff --git a/app/models/rdap_access_event.rb b/app/models/rdap_access_event.rb new file mode 100644 index 0000000000..f17bb2c76f --- /dev/null +++ b/app/models/rdap_access_event.rb @@ -0,0 +1,26 @@ +# Insert-only store of PRIVILEGED RDAP disclosures (RDAP spec 11). +# +# This table is itself the application event store — NOT a paper_trail audit of a +# mutable model. It therefore does NOT `include Versions` (no +# Version::RdapAccessEventVersion / log_rdap_access_events wiring exists or is +# wanted): there is nothing to "audit the mutations of", because rows are never +# mutated. Each row is a snapshot of the resolved grant taken at write time +# (organization/full_name/category/uuid only) so it survives a later grant edit, +# revoke, or delete. It MUST NEVER carry eeid_subject or personal_id_code (PII). +class RdapAccessEvent < ApplicationRecord + validates :requested_at, presence: true + validates :domain_name, presence: true + validates :caller_ip, presence: true + validates :result_code, presence: true + validates :accessor_name, presence: true + validates :category, presence: true + validates :grant_ref, presence: true + + before_destroy { raise ActiveRecord::ReadOnlyRecord, 'RdapAccessEvent is insert-only' } + + # Create-only immutability: a new (unsaved) record can be inserted, but any + # persisted row is read-only, so update!/save raise ActiveRecord::ReadOnlyRecord. + def readonly? + !new_record? + end +end diff --git a/app/models/rdap_api_token.rb b/app/models/rdap_api_token.rb new file mode 100644 index 0000000000..ceb21d7452 --- /dev/null +++ b/app/models/rdap_api_token.rb @@ -0,0 +1,46 @@ +# Registry-side store for RDAP-issued API tokens (companion to the RDAP spec +# 10-rdap-issued-api-token). RDAP owns no database, so the token state it mints +# lives here and is reached over the internal RDAP data API — exactly like +# RdapPrivilegeGrant holds the grants RDAP reads. +# +# PRIVACY / NO-SECRET INVARIANTS (mirror the RDAP contract): +# * token_hash is the keyed HMAC-SHA-256 digest of the raw token, computed by +# RDAP with a secret only RDAP holds. The RAW token and that secret NEVER +# reach the registry — a leaked registry datastore alone cannot yield usable +# tokens. +# * The row carries NO privilege field. Authorization stays 100% grant-driven +# (RdapPrivilegeGrant); a token only says WHO the caller is. +# * `label` is caller-supplied free text; treat it as opaque, never as PII. +class RdapApiToken < ApplicationRecord + TOKEN_CLASSES = %w[session machine].freeze + + validates :token_hash, presence: true, uniqueness: true + validates :subject, presence: true + validates :token_class, presence: true, inclusion: { in: TOKEN_CLASSES } + validates :issued_at, presence: true + validates :expires_at, presence: true + + # The single authoritative "active" rule (mirrors Rdap::Registry::Token#active?): + # not revoked AND not past expiry. + # A revoked / expired / unknown digest all resolve to "no row" for the caller, + # with no visible distinction (non-enumeration is a privacy property). + scope :active_by_hash, lambda { |token_hash, now = Time.zone.now| + where(token_hash: token_hash, revoked_at: nil) + .where('expires_at IS NULL OR expires_at > ?', now) + } + + scope :for_subject, ->(subject) { where(subject: subject) } + + # Idempotent single-token revoke: stamp revoked_at once; leave an already-revoked + # row untouched so its original revocation time is preserved. + def revoke!(at: Time.zone.now) + return if revoked_at.present? + + update_columns(revoked_at: at) + end + + # Record last use — last_used_at ONLY, NEVER expires_at (no sliding renewal). + def touch_last_used!(at: Time.zone.now) + update_columns(last_used_at: at) + end +end diff --git a/app/models/rdap_privilege_grant.rb b/app/models/rdap_privilege_grant.rb new file mode 100644 index 0000000000..d201f5c129 --- /dev/null +++ b/app/models/rdap_privilege_grant.rb @@ -0,0 +1,59 @@ +class RdapPrivilegeGrant < ApplicationRecord + include Versions + + CATEGORIES = %w[police cert ria eis_internal].freeze + STATUSES = %w[active revoked suspended].freeze + + before_create :assign_uuid + + validates :eeid_subject, presence: true + validates :category, presence: true, inclusion: { in: CATEGORIES } + validates :status, presence: true, inclusion: { in: STATUSES } + validates :valid_from, presence: true + validates :full_name, presence: true + validates :legal_basis_ref, presence: true + validate :valid_until_after_valid_from + + # The single authoritative "active" rule (mirrors the RDAP contract §4): + # status == 'active' AND valid_from <= now (or null) AND (valid_until null OR valid_until > now) + # On multiple active grants, the one with the latest valid_from wins (callers take .first). + scope :active_for_subject, lambda { |subject| + now = Time.zone.now + where(eeid_subject: subject, status: 'active') + .where('valid_from IS NULL OR valid_from <= ?', now) + .where('valid_until IS NULL OR valid_until > ?', now) + .order(valid_from: :desc) + } + + def grant_id + uuid.presence || id.to_s + end + + # Expiry is DERIVED from valid_until at read time; it is never a stored status. + # STATUSES stays %w[active revoked suspended] (see AC15) so the frozen RDAP + # contract is untouched. + def expired? + valid_until.present? && valid_until <= Time.zone.now + end + + # The status to present in the admin UI: an active grant whose valid_until has + # passed reads as "expired" without any stored status change. + def display_status + return 'expired' if status == 'active' && expired? + + status + end + + private + + def assign_uuid + self.uuid = SecureRandom.uuid if uuid.blank? + end + + def valid_until_after_valid_from + return if valid_until.blank? || valid_from.blank? + return if valid_until > valid_from + + errors.add(:valid_until, 'must be after valid_from') + end +end diff --git a/app/models/version/rdap_privilege_grant_version.rb b/app/models/version/rdap_privilege_grant_version.rb new file mode 100644 index 0000000000..978a18c19d --- /dev/null +++ b/app/models/version/rdap_privilege_grant_version.rb @@ -0,0 +1,5 @@ +class Version::RdapPrivilegeGrantVersion < PaperTrail::Version + include VersionSession + self.table_name = :log_rdap_privilege_grants + self.sequence_name = :log_rdap_privilege_grants_id_seq +end diff --git a/app/services/registrant_access_events_query.rb b/app/services/registrant_access_events_query.rb new file mode 100644 index 0000000000..eaee73e9eb --- /dev/null +++ b/app/services/registrant_access_events_query.rb @@ -0,0 +1,172 @@ +# Read-only PORO (spec 13, Surface A). Reconstructs the caller's HALF-OPEN tenure +# intervals on THIS domain's item_id from log_domains (PaperTrail), then returns the +# disclosable RdapAccessEvent rows for the domain: delay-filtered, ordered +# requested_at DESC, capped at 100. +# +# The load-bearing correctness property lives here: every returned event's +# requested_at MUST fall inside one of the caller's [start_i, end_i) intervals for +# item_id = domain.id. This excludes a prior/later owner's accesses (per-tenure) and +# a same-name event that belongs to a different (deleted-namesake) item_id (the +# per-interval LOWER bound is what does that — a name-only + upper-bound filter would +# leak it and is FORBIDDEN). +# +# PII (N1): reads only the caller's OWN contact ids (server-side, from +# current_registrant_user) and event ids/timestamps; writes NOTHING PII-bearing to any +# log/error line. Issues only SELECTs (N5) — never writes, mutates, or reschemas the +# store. No defensive rescue/retry/circuit-breaker — a plain SELECT pipeline. +class RegistrantAccessEventsQuery + FALLBACK_DELAY_MINUTES = 5 + RESULT_CAP = 100 + + def initialize(domain:, registrant_user:, now: Time.current) + @domain = domain + @now = now + # R5a: the caller's OWN contact id(s), resolved SERVER-SIDE from current_registrant_user. + # DIRECT (ident-matched personal) contact(s) only this release; never a client value. + @own_ids = Contact.registrant_user_direct_contacts(registrant_user).ids + end + + def call + intervals = own_tenure_intervals + return RdapAccessEvent.none if intervals.empty? + + scope = RdapAccessEvent.where(domain_name: @domain.name) + .where('requested_at <= ?', cutoff) + scope = scope.where(interval_predicate(intervals), *interval_binds(intervals)) + scope.order(requested_at: :desc).limit(RESULT_CAP) + end + + private + + # Delay cutoff with a SAFE NON-ZERO fallback (R11/R14/N4). Setting. returns nil + # for a missing/blank row; the `|| 5` and the `<= 0 => 5` clamp guarantee the path can + # never reach an effective 0-delay (never real-time disclosure). + def cutoff + delay = (Setting.rdap_access_transparency_disclosure_delay || FALLBACK_DELAY_MINUTES).to_i + delay = FALLBACK_DELAY_MINUTES if delay <= 0 + @now - delay.minutes + end + + # Ordered [start, end) intervals (end = @now for the still-current tenure) during which + # the effective registrant of item_id = @domain.id was one of @own_ids. Built from the + # ordered log_domains timeline for this item_id. + def own_tenure_intervals + rows = Version::DomainVersion.where(item_type: 'Domain', item_id: @domain.id) + .order(:created_at, :id) + .pluck(:created_at, + Arel.sql("object_changes->>'registrant_id'"), + Arel.sql("object->>'registrant_id'")) + build_intervals(rows) + end + + # Pure walk (unit-testable in isolation). rows = [created_at, object_changes_registrant, object_registrant]. + # Derives the effective registrant AFTER each event: prefer the NEW value of + # object_changes.registrant_id ([old, new] array), else the object.registrant_id + # scalar snapshot, else carry forward the previous effective value. Emits half-open + # [start, end) intervals for consecutive runs where the effective registrant is one of + # @own_ids; the still-current tenure is closed at @now. + def build_intervals(rows) + intervals = [] + effective = nil + current_start = nil + current_owned = false + + rows.each do |created_at, changes_registrant, object_registrant| + new_effective = effective_registrant(changes_registrant, object_registrant, effective) + owned_now = own?(new_effective) + + if owned_now && !current_owned + current_start = created_at + elsif !owned_now && current_owned + intervals << [current_start, created_at] + current_start = nil + end + + effective = new_effective + current_owned = owned_now + end + + # The live tail: whoever domain.registrant_id currently is closes at @now. + if own?(@domain.registrant_id) + if current_start + # The walk already established the caller's current, still-open tenure — close it at @now. + intervals << [current_start, @now] + else + # FAIL CLOSED (R5c/R7/N3): the LIVE domain says the caller owns, but the reconstructed + # timeline never left the caller as the effective registrant (e.g. the acquiring version + # row is missing, or its object_changes was empty so carry-forward kept the prior owner). + # Do NOT anchor to rows.first (the whole-domain start) — that would span every prior + # owner's tenure and leak their accesses. Anchor only to the most recent version the + # timeline actually shows the caller as effective registrant; if none exists, emit NO + # open interval (under-disclose rather than risk exposing a prior owner's events). + anchor = last_own_registrant_change(rows) + intervals << [anchor, @now] if anchor + end + elsif current_owned && current_start + # Reconstructed run left the caller owning but the live domain is no longer theirs + # without a closing version row — close at @now defensively (should not normally happen). + intervals << [current_start, @now] + end + + intervals + end + + # Backward scan over the ordered timeline for the created_at of the most recent version whose + # EFFECTIVE registrant (same derivation as the forward walk: object_changes new value, else + # object scalar, else carry-forward) is one of the caller's own contact ids. Returns nil when + # the caller never appears as the effective registrant anywhere in the recorded history. + def last_own_registrant_change(rows) + effective = nil + last = nil + + rows.each do |created_at, changes_registrant, object_registrant| + effective = effective_registrant(changes_registrant, object_registrant, effective) + last = created_at if own?(effective) + end + + last + end + + # Effective registrant AFTER an event. object_changes.registrant_id is a [old, new] + # JSON array (take [1]); object.registrant_id is a scalar. Mirror the string-vs-parsed + # handling in legal_document.rb:102-111. When neither is present, carry forward. + def effective_registrant(changes_registrant, object_registrant, carry) + if changes_registrant.present? + parsed = parse_json(changes_registrant) + return parsed[1] if parsed.is_a?(Array) + + return parsed + end + + return to_id(object_registrant) if object_registrant.present? + + carry + end + + def parse_json(value) + JSON.parse(value) + rescue JSON::ParserError, TypeError + value + end + + def to_id(value) + Integer(value) + rescue ArgumentError, TypeError + value + end + + def own?(registrant_id) + return false if registrant_id.nil? + + @own_ids.include?(to_id(registrant_id)) + end + + # OR of per-interval half-open bounds: (requested_at >= ? AND requested_at < ?). + def interval_predicate(intervals) + Array.new(intervals.size, '(requested_at >= ? AND requested_at < ?)').join(' OR ') + end + + def interval_binds(intervals) + intervals.flat_map { |start_at, end_at| [start_at, end_at] } + end +end diff --git a/app/views/admin/base/_menu.haml b/app/views/admin/base/_menu.haml index eebabe555a..1d6c3beb52 100644 --- a/app/views/admin/base/_menu.haml +++ b/app/views/admin/base/_menu.haml @@ -17,6 +17,8 @@ %li.dropdown-header= t('.users') %li= link_to t('.api_users'), admin_api_users_path %li= link_to t('.admin_users'), admin_admin_users_path + - if can? :read, RdapPrivilegeGrant + %li= link_to t('.rdap_privilege_grants'), admin_rdap_privilege_grants_path %li.divider %li.dropdown-header= t(:billing) - if can? :view, Billing::Price diff --git a/app/views/admin/rdap_privilege_grants/_form.haml b/app/views/admin/rdap_privilege_grants/_form.haml new file mode 100644 index 0000000000..98f712d6cb --- /dev/null +++ b/app/views/admin/rdap_privilege_grants/_form.haml @@ -0,0 +1,57 @@ += form_for([:admin, @rdap_privilege_grant], html: { class: 'form-horizontal', autocomplete: 'off' }) do |f| + = render 'shared/full_errors', object: @rdap_privilege_grant + + .row + .col-md-8 + .form-group + .col-md-4.control-label + = f.label :full_name, nil, class: 'required' + .col-md-8 + = f.text_field :full_name, required: true, autofocus: true, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :eeid_subject, nil, class: 'required' + .col-md-8 + = f.text_field :eeid_subject, required: true, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :organization + .col-md-8 + = f.text_field :organization, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :category, nil, class: 'required' + .col-md-8 + = f.select :category, RdapPrivilegeGrant::CATEGORIES.map { |c| [t("rdap_privilege_grant_category.#{c}", default: c), c] }, { include_blank: true }, required: true, class: 'form-control' + %hr + .form-group + .col-md-4.control-label + = f.label :valid_from, nil, class: 'required' + .col-md-8 + = f.datetime_field :valid_from, required: true, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :valid_until + .col-md-8 + = f.datetime_field :valid_until, class: 'form-control' + %hr + .form-group + .col-md-4.control-label + = f.label :legal_basis_ref, nil, class: 'required' + .col-md-8 + = f.text_field :legal_basis_ref, required: true, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :personal_id_code + .col-md-8 + = f.text_field :personal_id_code, class: 'form-control' + .form-group + .col-md-4.control-label + = f.label :notes + .col-md-8 + = f.text_area :notes, rows: 3, class: 'form-control' + + %hr + .row + .col-md-8.text-right + = button_tag(t(:save), class: 'btn btn-primary') diff --git a/app/views/admin/rdap_privilege_grants/edit.haml b/app/views/admin/rdap_privilege_grants/edit.haml new file mode 100644 index 0000000000..b3eae544a7 --- /dev/null +++ b/app/views/admin/rdap_privilege_grants/edit.haml @@ -0,0 +1,5 @@ +- content_for :actions do + = link_to(t(:back), [:admin, @rdap_privilege_grant], class: 'btn btn-default') += render 'shared/title', name: "#{t(:edit)}: #{@rdap_privilege_grant.full_name}" + += render 'form' diff --git a/app/views/admin/rdap_privilege_grants/index.haml b/app/views/admin/rdap_privilege_grants/index.haml new file mode 100644 index 0000000000..ef103332f7 --- /dev/null +++ b/app/views/admin/rdap_privilege_grants/index.haml @@ -0,0 +1,40 @@ +- content_for :actions do + = link_to(t('.new_btn'), new_admin_rdap_privilege_grant_path, class: 'btn btn-primary') += render 'shared/title', name: t('.title') += render 'application/pagination' + +.row + .col-md-12 + .table-responsive + %table.table.table-hover.table-bordered.table-condensed + %thead + %tr + %th{class: 'col-xs-2'} + = sort_link(@q, 'full_name', RdapPrivilegeGrant.human_attribute_name(:full_name)) + %th{class: 'col-xs-2'} + = sort_link(@q, 'eeid_subject', RdapPrivilegeGrant.human_attribute_name(:eeid_subject)) + %th{class: 'col-xs-2'} + = sort_link(@q, 'organization', RdapPrivilegeGrant.human_attribute_name(:organization)) + %th{class: 'col-xs-1'} + = sort_link(@q, 'category', RdapPrivilegeGrant.human_attribute_name(:category)) + %th{class: 'col-xs-1'} + = sort_link(@q, 'status', RdapPrivilegeGrant.human_attribute_name(:status)) + %th{class: 'col-xs-2'} + = sort_link(@q, 'valid_from', RdapPrivilegeGrant.human_attribute_name(:valid_from)) + %th{class: 'col-xs-2'} + = sort_link(@q, 'valid_until', RdapPrivilegeGrant.human_attribute_name(:valid_until)) + %tbody + - @rdap_privilege_grants.each do |grant| + %tr + %td= link_to(grant.full_name, [:admin, grant]) + %td= grant.eeid_subject + %td= grant.organization + %td= grant.category + %td= grant.display_status + %td= grant.valid_from + %td= grant.valid_until +.row + .col-md-6 + = paginate @rdap_privilege_grants + .col-md-6.text-right + = t(:result_count, count: @count) diff --git a/app/views/admin/rdap_privilege_grants/new.haml b/app/views/admin/rdap_privilege_grants/new.haml new file mode 100644 index 0000000000..f8282f44f0 --- /dev/null +++ b/app/views/admin/rdap_privilege_grants/new.haml @@ -0,0 +1,3 @@ += render 'shared/title', name: t('.title') + += render 'form' diff --git a/app/views/admin/rdap_privilege_grants/show.haml b/app/views/admin/rdap_privilege_grants/show.haml new file mode 100644 index 0000000000..225b48afb6 --- /dev/null +++ b/app/views/admin/rdap_privilege_grants/show.haml @@ -0,0 +1,68 @@ +- content_for :actions do + = link_to(t(:edit), edit_admin_rdap_privilege_grant_path(@rdap_privilege_grant), class: 'btn btn-primary') + - if @rdap_privilege_grant.status == 'active' + = link_to(t('.suspend'), suspend_admin_rdap_privilege_grant_path(@rdap_privilege_grant), method: :post, data: { confirm: t(:are_you_sure) }, class: 'btn btn-warning') + - unless @rdap_privilege_grant.status == 'revoked' + = link_to(t('.revoke'), revoke_admin_rdap_privilege_grant_path(@rdap_privilege_grant), method: :post, data: { confirm: t(:are_you_sure) }, class: 'btn btn-danger') += render 'shared/title', name: @rdap_privilege_grant.full_name + +.row + .col-md-8 + .panel.panel-default + .panel-heading + %h3.panel-title= t(:general) + .panel-body + %dl.dl-horizontal + %dt= RdapPrivilegeGrant.human_attribute_name :full_name + %dd= @rdap_privilege_grant.full_name + + %dt= RdapPrivilegeGrant.human_attribute_name :eeid_subject + %dd= @rdap_privilege_grant.eeid_subject + + %dt= RdapPrivilegeGrant.human_attribute_name :organization + %dd= @rdap_privilege_grant.organization + + %dt= RdapPrivilegeGrant.human_attribute_name :category + %dd= @rdap_privilege_grant.category + + %dt= RdapPrivilegeGrant.human_attribute_name :status + %dd= @rdap_privilege_grant.display_status + + %dt= RdapPrivilegeGrant.human_attribute_name :valid_from + %dd= @rdap_privilege_grant.valid_from + + %dt= RdapPrivilegeGrant.human_attribute_name :valid_until + %dd= @rdap_privilege_grant.valid_until + + %dt= RdapPrivilegeGrant.human_attribute_name :legal_basis_ref + %dd= @rdap_privilege_grant.legal_basis_ref + + %dt= RdapPrivilegeGrant.human_attribute_name :notes + %dd= @rdap_privilege_grant.notes + +.row + .col-md-12 + .panel.panel-default + .panel-heading + %h3.panel-title= t('.audit_history') + .panel-body + .table-responsive + %table.table.table-hover.table-bordered.table-condensed + %thead + %tr + %th= t('.event') + %th= t('.whodunnit') + %th= t('.changed_at') + %th= t('.changes') + %tbody + - @rdap_privilege_grant.versions.reorder(created_at: :desc).each do |version| + %tr + %td= version.event + %td= version.whodunnit + %td= version.created_at + %td + -# personal_id_code is captured but not surfaced in the rendered change table (PII). + - (version.object_changes || {}).except('personal_id_code').each do |attribute, change| + %div + %strong= attribute + = Array(change).join(' → ') diff --git a/config/application.yml.sample b/config/application.yml.sample index 12da2f8e60..f4b54f3576 100644 --- a/config/application.yml.sample +++ b/config/application.yml.sample @@ -101,6 +101,10 @@ rwhois_bounces_api_shared_key: testkey # Link to REST-WHOIS API rwhois_internal_api_shared_key: testkey +# Internal RDAP data API (machine-to-machine; pre-shared key + IP-allowlist) +rdap_internal_api_shared_key: testkey +rdap_internal_api_allowed_ips: '' # 192.0.2.0, 192.0.2.1 + # Base URL (inc. https://) of REST registrant portal # Leave blank to use internal registrant portal registrant_portal_verifications_base_url: '' @@ -196,6 +200,8 @@ test: payments_seb_seller_private: 'test/fixtures/files/seb_seller_key.pem' release_domains_to_auction: 'false' auction_api_allowed_ips: '' + rdap_internal_api_shared_key: 'testkey' + rdap_internal_api_allowed_ips: '' action_mailer_default_host: 'registry.test' action_mailer_default_from: 'no-reply@registry.test' action_mailer_force_delete_from: 'legal@registry.test' @@ -269,7 +275,4 @@ staging: action_mailer_default_from: 'no-reply@registry.staging.test' action_mailer_force_delete_from: 'legal@registry.staging.test' -staging: - action_mailer_default_host: 'registry.staging.test' - action_mailer_default_from: 'no-reply@registry.staging.test' - action_mailer_force_delete_from: 'legal@registry.staging.test' +rdap_api_token_hmac_secret: 'testkey' diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 4f4e682889..d37d2b7001 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,5 +1,11 @@ Rails.application.configure do config.filter_parameters += [:password, /^frame$/, /^nokogiri_frame$/, /^parsed_frame$/] + # Internal RDAP data API: `subject` is a national id (sensitive PII) and + # `token_hash` is an RDAP-issued-token lookup key — neither may appear in logs. + config.filter_parameters += %i[subject token_hash] + # Privileged RDAP grant admin: personal_id_code is sensitive PII (RPD §9); + # it is capture-only and must never reach application logs. + config.filter_parameters += %i[personal_id_code] config.filter_parameters << lambda do |key, value| if key == 'raw_frame' && value.respond_to?(:gsub!) value.gsub!(/pw>.+<\//, 'pw>[FILTERED] **Provenance.** The authoritative specification lives in the **RDAP** project (the public .ee RDAP +> service), at `specs/specs/pending/08-registry-side-rdap-api/` (`proposal.md`, `requirements.txt`, +> `api-contract.md`, `registry-grounding.md`). This file is the in-repo, registry-facing copy so the +> implementation can proceed here. If the two ever diverge, the RDAP spec is the source of truth — +> sync this file. Authored 2026-06-25, grounded in this codebase (file:line below verified). +> +> **Lead decision (2026-06-25).** The public RDAP service must NOT read the registry's normalized DB +> directly. All registry-sourced data — and privileged-user authorization — move behind this +> registry-side API. RDAP already shipped the consumer side (a client interface + an in-memory mock); +> this is the real API behind that mock. +> +> **HARD rule (registry CLAUDE.md):** branch off `master`, open a PR, **never merge to master +> yourself** — human review first. Docker-only; minitest + fixtures; `db/structure.sql`. + +## What RDAP needs + +A new internal, machine-to-machine JSON API exposing four read lookups + one optional write. Model it +on the existing internal peer API `app/controllers/api/v1/accreditation_center/` +(`base_controller.rb:1-89`). Proposed home: `app/controllers/api/v1/internal/rdap/` → +`/api/v1/internal/rdap/*`. + +| Endpoint | Returns | Replaces RDAP's | +| --- | --- | --- | +| `GET /api/v1/internal/rdap/domains/:name` | full privileged domain (PII + glue + DNSSEC) | direct read of normalized tables | +| `GET /api/v1/internal/rdap/registrars/:code` | `{code, name, phone, website}` only | entity endpoint source | +| `GET /api/v1/internal/rdap/nameservers/:host` | `{hostname, hostname_puny}` DISTINCT | nameserver endpoint source | +| `GET /api/v1/internal/rdap/grants/active?subject=:s` | the single active grant, or 404 | local `rdap_privileged_grants` table | +| `POST /api/v1/internal/rdap/grants/:id/touch` (optional) | 204 — best-effort `last_used_at` | best-effort touch | + +## Global rules (review gate — all CRITICAL) + +1. **No secrets, ever.** Never emit `domains.transfer_code`/`auth_info` (`structure.sql:1009`), + `contacts.auth_info` (`:712`), `domains.registrant_verification_token` (`:1024`), or any + `personal_id_code` on a grant. ⚠️ Do **not** reuse `Serializers::Repp::Domain` with + `sponsored: true` — it emits `transfer_code` (`lib/serializers/repp/domain.rb:27`). Write a + dedicated, secrets-free RDAP serializer. +2. **404 ≠ 5xx.** A lookup that matches nothing → HTTP **404** `{ "message": "..." }`. Internal + error/unavailability → **5xx**. RDAP maps 404→RDAP-404 and 5xx→RDAP-503; never conflate (a degraded + registry must not look like "object does not exist"). +3. **No server-side redaction.** Return the **full** normalized row + the **raw** disclosure flags + (`disclosed_attributes`, `system_disclosed_attributes`, `registrant_publishable`). RDAP applies the + disclosure policy; the registry MUST NOT decide what a caller sees. +4. **Read-only** except the optional grant `touch`. +5. **Authenticated + confidential + not public.** **DECIDED (2026-06-25): pre-shared key + + IP-allowlist** — the `api/v1/base_controller.rb:14-16` `authenticate_shared_key` pattern: + `Authorization: Basic ` compared via + `ActiveSupport::SecurityUtils.secure_compare`; IP checked against + `ENV['rdap_internal_api_allowed_ips']`. mTLS client-cert (`repp/v1/base_controller.rb:158-177`, + `api_user.rb#pki_ok?`) is a LATER production-hardening step, not built now. + +## Endpoint shapes & field→column mapping + +### 1. Domain — `GET /api/v1/internal/rdap/domains/:name` +Match `domains.name` **OR** `domains.name_puny` (cf. `Domain.find_by_idn`, `domain.rb:382`). Response: + +```json +{ + "name": "example.ee", "statuses": ["ok"], + "created_at": "...", "updated_at": "...", "valid_to": "...", + "outzone_at": null, "delete_date": null, + "registrant": { "...contact..." }, + "admin_contacts": [ { "...contact..." } ], + "tech_contacts": [ { "...contact..." } ], + "registrar": { "code": "REG1", "name": "...", "email": "...", "phone": "...", + "website": "...", "reg_no": "..." }, + "nameservers": [ { "hostname": "ns1.example.ee", "hostname_puny": "ns1.example.ee", + "ipv4": ["192.0.2.1"], "ipv6": ["2001:db8::1"] } ], + "dnskeys": [ { "flags": 257, "protocol": 3, "alg": 8, "public_key": "...", + "ds_key_tag": "...", "ds_alg": 8, "ds_digest_type": 2, "ds_digest": "..." } ] +} +``` + +- **domain**: `name`←`domains.name` (`structure.sql:1005`); `statuses`←`domains.statuses` (`:1027`, + raw `.ee` strings — vocabulary `domain_status.rb:81-91`); `created_at`/`updated_at`/`valid_to` + (`:1010/1011/1007`); `outzone_at` (`:1021`); `delete_date` (`:1022`, recommend effective + `[delete_date, force_delete_date].compact.min` per `whois_record.rb:43`). +- **contact** (registrant via `domains.registrant_id`; admin/tech via `domain_contacts` STI `type` + `AdminDomainContact`/`TechDomainContact`, `domain_contact.rb:17-18`, `domain.rb:62-77`): + `name`/`org_name`/`email`/`phone`/`street`/`city`/`zip`/`country_code`/`ident`/`ident_type` + (priv/org/birthday, `contact.rb:98-105`)/`ident_country_code`/`updated_at` (`structure.sql:704-724`); + `disclosed_attributes` = **union** of `contacts.disclosed_attributes` (`:733`) and + `contacts.system_disclosed_attributes` (`:741`); `registrant_publishable` (`:735`). Never `auth_info`. +- **registrar** (via `domains.registrar_id`): `code`/`name`/`email`/`phone`/`website`/`reg_no` + (`structure.sql:2625-2641`). Wider than endpoint 2 — keeps `email`+`reg_no`. +- **nameservers** (`nameservers WHERE domain_id`): `hostname`/`hostname_puny`/`ipv4[]`/`ipv6[]` + (`structure.sql:2319-2328`). +- **dnskeys** (`dnskeys WHERE domain_id`): `flags`/`protocol`/`alg`/`public_key`/`ds_key_tag`/`ds_alg`/ + `ds_digest_type`/`ds_digest` (`structure.sql:889-896`). + +### 2. Registrar — `GET /api/v1/internal/rdap/registrars/:code` +`Registrar.find_by(code: code.upcase)`. Emit **only** `{code, name, phone, website}` +(`structure.sql:2640/2625/2632/2641`). `email`/`reg_no` MUST NOT appear here (they belong only inside +the domain payload, endpoint 1). + +### 3. Nameserver — `GET /api/v1/internal/rdap/nameservers/:host` +Match `hostname OR hostname_puny`; **DISTINCT-collapse** to one result (a host serves many domains — +no global unique, only `(domain_id, hostname)`, `structure.sql:4222`). Emit **only** +`{hostname, hostname_puny}`. No glue, no domain list (prevents enumeration). + +### 4. Active grant — `GET /api/v1/internal/rdap/grants/active?subject=:s` +`:s` is the **dash-free** country-prefixed eeID subject `EE38001085718` (matches `users.subject`, +`db/migrate/20260601120000_add_subject_to_users.rb`; NOT the dashed `RegistrantUser.registrant_ident`). +Net-new — nothing in the registry maps to this (no police/cert/ria/eis_internal concept today). +Server computes "active" authoritatively: `status='active'` AND `valid_from<=now`(or null) AND +(`valid_until` null OR `>now`); on multiple active, latest `valid_from` wins. Response: + +```json +{ "grant_id": "uuid", "eeid_subject": "EE38001085718", "privilege_category": "police", + "organization": "police", "privileges": ["police"], "status": "active", + "valid_from": "...", "valid_until": null } +``` + +404 `{ "message": "No active grant" }` when none active (RDAP fails closed → never privileged). +`subject` via query string (PII — keep out of path/logs). Never emit a national-id secret. + +## Implementation plan (this repo) + +1. `app/controllers/api/v1/internal/base_controller.rb` (copy `accreditation_center/base_controller.rb` + pattern: `< ActionController::API`, IP-allowlist `ENV['rdap_internal_api_allowed_ips']`, auth, + `rescue_from`, `render_error`). Routes under `namespace :api { namespace :v1 { namespace :internal { + namespace :rdap { ... } } } }` (`config/routes.rb:189-233`). +2. `domains_controller#show` + new `lib/serializers/rdap/domain.rb` (secrets-free). Eager-load assocs. +3. `registrars_controller#show` (slice 4 fields). `nameservers_controller#show` (DISTINCT thin). +4. Net-new `rdap_privilege_grants` table + `RdapPrivilegeGrant` model + (`CATEGORIES=%w[police cert ria eis_internal]`, `STATUSES=%w[active revoked suspended]`, + `active_for_subject` scope, PaperTrail). Migration in Rails 6.1 style (regenerates + `db/structure.sql`). +5. `grants_controller#active` (+ optional `#touch`). +6. Admin CRUD `app/controllers/admin/rdap_privilege_grants_controller.rb` modeled on + `Admin::DisputesController` (validity-window resource); `can :manage, RdapPrivilegeGrant` in + `ability.rb`; admin routes block (`config/routes.rb:244-399`). +7. minitest integration tests under `test/integration/api/v1/internal/rdap/` (fixtures): happy paths, + 404s, **secrets-exclusion assertion**, nameserver DISTINCT-collapse, grant active/edge cases. Auth + tests modeled on `test/integration/repp/v1/base_test.rb`. +8. Open a PR for human review. **Do not merge to master.** + +## Decisions (resolved 2026-06-25) +- **Q1 auth** → **pre-shared key + IP-allowlist** (see global rule 5); mTLS later. +- **Q2 namespace** → `/api/v1/internal/rdap/*` (new `internal` namespace). +- **Q3 scope** → **BACKEND ONLY**: model + table + the 4 endpoints + resolution + tests. **No admin + CRUD UI** in this spec (separate later spec). Grants seeded via fixtures / console until then. +- **Q4 categories/states** → confirmed `police/cert/ria/eis_internal` + `active/revoked/suspended`; + active rule as above; no extra categories, no four-eyes for now. +- **Q5 touch** → keep the optional best-effort `POST .../grants/:id/touch` (204). +- **Q6** → `delete_date` = effective `[delete_date, force_delete_date].compact.min`; `disclosed_attributes` + = union of the registrant + system arrays in one field. diff --git a/lib/serializers/rdap/domain.rb b/lib/serializers/rdap/domain.rb new file mode 100644 index 0000000000..ec1b051e51 --- /dev/null +++ b/lib/serializers/rdap/domain.rb @@ -0,0 +1,115 @@ +module Serializers + module Rdap + # Secrets-free serializer for the internal RDAP domain endpoint + # (GET /api/v1/internal/rdap/domains/:name). + # + # This serializer MUST NEVER emit transfer_code / auth_info / + # registrant_verification_token. It returns the full normalized row plus the + # raw disclosure flags (the union of disclosed_attributes and + # system_disclosed_attributes) — RDAP, not the registry, applies the + # disclosure policy. Do NOT reuse Serializers::Repp::Domain here: that one + # emits transfer_code when sponsored. + class Domain + attr_reader :domain + + def initialize(domain) + @domain = domain + end + + def as_json(*) + { + name: domain.name, + statuses: Array(domain.statuses), + created_at: iso8601(domain.created_at), + updated_at: iso8601(domain.updated_at), + valid_to: iso8601(domain.valid_to), + outzone_at: iso8601(domain.outzone_at), + delete_date: iso8601(effective_delete_date), + registrant: contact(domain.registrant), + admin_contacts: domain.admin_contacts.map { |c| contact(c) }, + tech_contacts: domain.tech_contacts.map { |c| contact(c) }, + registrar: registrar(domain.registrar), + nameservers: domain.nameservers.map { |ns| nameserver(ns) }, + dnskeys: domain.dnskeys.map { |dk| dnskey(dk) }, + } + end + + private + + # Effective delete date as WHOIS uses it: the earliest of delete_date and + # force_delete_date. + def effective_delete_date + [domain.delete_date, domain.force_delete_date].compact.min + end + + def contact(contact) + return nil if contact.nil? + + { + name: contact.name, + org_name: contact.org_name, + email: contact.email, + phone: contact.phone, + street: contact.street, + city: contact.city, + zip: contact.zip, + country_code: contact.country_code, + ident: contact.ident, + ident_type: contact.ident_type, + ident_country_code: contact.ident_country_code, + disclosed_attributes: disclosed_attributes(contact), + registrant_publishable: contact.registrant_publishable, + updated_at: iso8601(contact.updated_at), + } + end + + # Union of the registrant-set and system-set disclosure arrays, in one + # field (the effective public-disclosure set). RDAP applies policy. + def disclosed_attributes(contact) + (Array(contact.disclosed_attributes) + + Array(contact.system_disclosed_attributes)).uniq + end + + def registrar(registrar) + return nil if registrar.nil? + + { + code: registrar.code, + name: registrar.name, + email: registrar.email, + phone: registrar.phone, + website: registrar.website, + reg_no: registrar.reg_no, + } + end + + def nameserver(nameserver) + { + hostname: nameserver.hostname, + hostname_puny: nameserver.hostname_puny, + ipv4: Array(nameserver.ipv4), + ipv6: Array(nameserver.ipv6), + } + end + + def dnskey(dnskey) + { + flags: dnskey.flags, + protocol: dnskey.protocol, + alg: dnskey.alg, + public_key: dnskey.public_key, + ds_key_tag: dnskey.ds_key_tag, + ds_alg: dnskey.ds_alg, + ds_digest_type: dnskey.ds_digest_type, + ds_digest: dnskey.ds_digest, + } + end + + def iso8601(value) + return nil if value.nil? + + value.to_time.utc.iso8601 + end + end + end +end diff --git a/test/fixtures/domains.yml b/test/fixtures/domains.yml index 770559bdf0..60d95d9bbe 100644 --- a/test/fixtures/domains.yml +++ b/test/fixtures/domains.yml @@ -74,3 +74,69 @@ invalid: registrar: bestnames registrant: invalid uuid: 3c430ead-bb17-4b5b-aaa1-caa7dde7e138 + +# spec 13 (RDAP access transparency): a domain currently owned by :john (== users(:registrant)) +# whose id is the item_id referenced by the log_domains timeline fixtures (john_tenure_*) and +# matched by rdap_access_events.domain_name = mytenure.test. +mytenure: + name: mytenure.test + name_puny: mytenure.test + name_dirty: mytenure.test + registrar: spec13_registrar + registrant: john + transfer_code: 9f13ab7 + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 7d1f6b2c-0e3a-4c5d-9b8e-1a2b3c4d5e6f + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13: a second john-owned domain used only for the DESC + 100-cap test (AC12), so its +# >100 events do not pollute the mytenure.test count assertions. +capdomain: + name: capdomain.test + name_puny: capdomain.test + name_dirty: capdomain.test + registrar: spec13_registrar + registrant: john + transfer_code: 3ac71f9 + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 5c9a0d1e-2f3b-4a6c-8d7e-9f0a1b2c3d4e + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED regression: john CURRENTLY owns this domain (registrant: john) but its +# log_domains timeline NEVER records john becoming registrant — the only recorded registrant is +# the prior owner (jane). The old fail-OPEN code anchored the live tail to rows.first (2020) and +# leaked jane's prior-tenure access event; the fail-closed fix emits NO interval here. +orphan: + name: orphan.test + name_puny: orphan.test + name_dirty: orphan.test + registrar: spec13_registrar + registrant: john + transfer_code: 7be21ac + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 8e2f7c3d-1f4b-4d6e-ac8f-2b3c4d5e6f70 + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED sub-case: john CURRENTLY owns this domain, and the timeline DOES show john +# as effective registrant earlier (2021), but the LAST recorded registrant-change hands it back +# to jane (2022) with no row recording john re-acquiring. The fail-closed fix anchors the live +# tail to john's most-recent effective-registrant version (2021), so only events at/after 2021 +# are disclosable and jane's pre-2021 prior-tenure event stays excluded. +partial: + name: partial.test + name_puny: partial.test + name_dirty: partial.test + registrar: spec13_registrar + registrant: john + transfer_code: 4de89bc + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 9f3a8d4e-2a5c-4e7f-bd90-3c4d5e6f7081 + created_at: <%= Time.zone.parse('2020-01-01') %> diff --git a/test/fixtures/files/domain_versions.csv b/test/fixtures/files/domain_versions.csv index 0b3629a5e2..d5a0de8140 100644 --- a/test/fixtures/files/domain_versions.csv +++ b/test/fixtures/files/domain_versions.csv @@ -8,5 +8,14 @@ airport.test,John,Best Names,create,2023-12-04 22:00:00 shop.test,John,Good Names,create,2023-10-04 21:00:00 cinema.test,John,Good Names,create,2023-09-04 21:00:00 metro.test,Jack,Good Names,create,2023-09-04 21:00:00 +,John,,update,2022-12-31 22:00:00 +,Jane,,update,2021-12-31 22:00:00 +,Jane,,update,2021-12-31 22:00:00 +,John,,update,2020-12-31 22:00:00 +,John,,update,2020-12-31 22:00:00 +orphan.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 +partial.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 +mytenure.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 +capdomain.test,John,Spec13 Registrar,create,2019-12-31 22:00:00 ,test_code,Best Names,update,2018-04-23 15:50:48 ,John,,update,2010-07-04 21:00:00 diff --git a/test/fixtures/log_domains.yml b/test/fixtures/log_domains.yml index 28a9a36c35..265ab8f039 100644 --- a/test/fixtures/log_domains.yml +++ b/test/fixtures/log_domains.yml @@ -95,4 +95,101 @@ create_seven: name: [null, 'metro.test'] registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:goodnames) %>] registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jack) %>] - created_at: <%= Time.zone.parse('2023-09-05') %> \ No newline at end of file + created_at: <%= Time.zone.parse('2023-09-05') %> + +# spec 13: the registrant timeline for domains(:mytenure) (item_id = identify(:mytenure)). +# john == users(:registrant); jane is the "other" (prior/intervening) owner. +# [2020-01-01, 2021-01-01) jane (prior owner) +# [2021-01-01, 2022-01-01) john (caller interval s1..e1) -> AC4/AC5 boundary +# [2022-01-01, 2023-01-01) jane (intervening owner) -> AC6/AC7 excluded +# [2023-01-01, now) john (caller re-acquired, live) -> AC7 second interval +john_tenure_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: create + object_changes: + name: [null, 'mytenure.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +john_tenure_acquire_1: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2021-01-01') %> + +john_tenure_transfer_away: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:john) %>, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2022-01-01') %> + +john_tenure_reacquire: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2023-01-01') %> + +# spec 13: domains(:capdomain) is john's for its whole life [2020-01-01, now) — used for the +# DESC ordering + 100-cap test (AC12). +capdomain_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:capdomain) %> + item_type: Domain + event: create + object_changes: + name: [null, 'capdomain.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED regression: domains(:orphan). The ONLY recorded registrant-change is the +# create → jane (prior owner). No row records john (the CURRENT live registrant) acquiring it, so +# the reconstructed walk never leaves the caller as the effective registrant. Fail-closed: emit +# no interval; jane's prior-tenure access event must NOT leak to john. +orphan_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:orphan) %> + item_type: Domain + event: create + object_changes: + name: [null, 'orphan.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED sub-case: domains(:partial). jane creates (2020), john acquires (2021), +# then it is transferred back to jane (2022) — and NO row records john re-acquiring even though +# the live domain says john owns it now. Fail-closed anchors the live tail to john's last +# effective-registrant version (2021), so an event at/after 2021 is disclosed while jane's +# pre-2021 prior-tenure event stays excluded. +partial_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: create + object_changes: + name: [null, 'partial.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +partial_to_john: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2021-01-01') %> + +partial_back_to_jane: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:john) %>, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2022-01-01') %> \ No newline at end of file diff --git a/test/fixtures/rdap_access_events.yml b/test/fixtures/rdap_access_events.yml new file mode 100644 index 0000000000..c6ad9cdb2b --- /dev/null +++ b/test/fixtures/rdap_access_events.yml @@ -0,0 +1,200 @@ +# Insert-only privileged RDAP access events. grant_ref is a snapshot of the +# grant's uuid (see rdap_privilege_grants.yml), NOT a foreign key. + +police_lookup: + requested_at: <%= 2.days.ago.to_s(:db) %> + domain_name: example.ee + caller_ip: 192.0.2.10 + result_code: 200 + organization_name: police + accessor_name: Police Officer One + category: police + grant_ref: a1b2c3d4-0000-0000-0000-000000000001 + request_id: req-0001 + created_at: <%= 2.days.ago.to_s(:db) %> + +cert_lookup: + requested_at: <%= 1.day.ago.to_s(:db) %> + domain_name: another.ee + caller_ip: 192.0.2.20 + result_code: 200 + organization_name: cert_team + accessor_name: Cert Analyst Two + category: cert + grant_ref: a1b2c3d4-0000-0000-0000-000000000002 + request_id: + created_at: <%= 1.day.ago.to_s(:db) %> + +# --- spec 13 (RDAP access transparency) events for domains(:mytenure) = mytenure.test --- +# john's tenure intervals on this item_id: [2021-01-01, 2022-01-01) and [2023-01-01, now). +# All requested_at values below are absolute so they land deterministically relative to the +# tenure boundaries; those meant to be disclosable are also older than the 5-minute delay cutoff. + +# AC4 + AC7(first interval): inside john's first tenure [2021-01-01, 2022-01-01) -> PRESENT. +mytenure_in_first_tenure: + requested_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.30 + result_code: 200 + organization_name: police + accessor_name: Officer Alpha + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000010 + request_id: req-0010 + created_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + +# AC5: before john's earliest tenure start (prior owner jane's interval) -> ABSENT. +mytenure_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.31 + result_code: 200 + organization_name: cert_team + accessor_name: Prior Owner Access + category: cert + grant_ref: b2c3d4e5-0000-0000-0000-000000000011 + request_id: req-0011 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# AC6: EXACTLY at the transfer instant end=2022-01-01 -> belongs to the acquiring owner (jane), +# excluded from the departing caller (half-open) -> ABSENT. +mytenure_transfer_instant: + requested_at: <%= Time.zone.parse('2022-01-01 00:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.32 + result_code: 200 + organization_name: police + accessor_name: Transfer Instant Access + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000012 + request_id: req-0012 + created_at: <%= Time.zone.parse('2022-01-01 00:00:00') %> + +# AC6 + AC7(intervening): inside jane's intervening interval [2022-01-01, 2023-01-01) -> ABSENT. +mytenure_intervening_owner: + requested_at: <%= Time.zone.parse('2022-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.33 + result_code: 200 + organization_name: cert_team + accessor_name: Intervening Owner Access + category: cert + grant_ref: b2c3d4e5-0000-0000-0000-000000000013 + request_id: req-0013 + created_at: <%= Time.zone.parse('2022-06-01 10:00:00') %> + +# AC7(second interval): inside john's re-acquired tenure [2023-01-01, now) -> PRESENT. +# Also the AC13 null-organization case (organization_name is nil). +mytenure_in_second_tenure_null_org: + requested_at: <%= Time.zone.parse('2023-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.34 + result_code: 200 + organization_name: + accessor_name: Officer Beta + category: emergency + grant_ref: b2c3d4e5-0000-0000-0000-000000000014 + request_id: req-0014 + created_at: <%= Time.zone.parse('2023-06-01 10:00:00') %> + +# AC8: same domain_name = mytenure.test but requested_at (2015) is OUTSIDE every interval of +# THIS item_id (a deleted namesake on a different item_id) -> ABSENT. +mytenure_namesake: + requested_at: <%= Time.zone.parse('2015-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.35 + result_code: 200 + organization_name: police + accessor_name: Namesake Access + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000015 + request_id: req-0015 + created_at: <%= Time.zone.parse('2015-06-01 10:00:00') %> + +# AC10/AC11: inside john's current tenure, PAST the delay cutoff (2 hours old) -> PRESENT. +mytenure_current_past_cutoff: + requested_at: <%= 2.hours.ago.to_s(:db) %> + domain_name: mytenure.test + caller_ip: 192.0.2.36 + result_code: 200 + organization_name: police + accessor_name: Recent Officer + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000016 + request_id: req-0016 + created_at: <%= 2.hours.ago.to_s(:db) %> + +# AC10/AC11: inside john's current tenure but NEWER than now-delay (1 minute old) -> ABSENT +# (suppressed by the disclosure delay, and by the 5-minute fallback when the Setting is absent). +mytenure_current_in_window: + requested_at: <%= 1.minute.ago.to_s(:db) %> + domain_name: mytenure.test + caller_ip: 192.0.2.37 + result_code: 200 + organization_name: police + accessor_name: Just Now Officer + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000017 + request_id: req-0017 + created_at: <%= 1.minute.ago.to_s(:db) %> + +# --- spec 13 FAIL-CLOSED regression events for domains(:orphan) = orphan.test --- +# john currently owns orphan.test, but the timeline only records jane (prior owner). This police +# lookup happened during jane's recorded tenure. The old fail-OPEN tail would have leaked it to +# john via a fabricated [2020, now) interval; the fail-closed fix emits NO interval -> ABSENT. +orphan_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: orphan.test + caller_ip: 192.0.2.40 + result_code: 200 + organization_name: police + accessor_name: Orphan Prior Owner Access + category: police + grant_ref: d4e5f6a7-0000-0000-0000-000000000020 + request_id: req-0020 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# --- spec 13 FAIL-CLOSED sub-case events for domains(:partial) = partial.test --- +# jane's prior tenure [2020-01-01, 2021-01-01): BEFORE john's last effective-registrant version +# (2021) -> ABSENT. +partial_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: partial.test + caller_ip: 192.0.2.41 + result_code: 200 + organization_name: cert_team + accessor_name: Partial Prior Owner Access + category: cert + grant_ref: d4e5f6a7-0000-0000-0000-000000000021 + request_id: req-0021 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# during/after john's effective-registrant version (2021): the fail-closed anchor is 2021-01-01, +# so this event is inside the emitted [2021-01-01, now) interval -> PRESENT. +partial_after_john_acquire: + requested_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + domain_name: partial.test + caller_ip: 192.0.2.42 + result_code: 200 + organization_name: police + accessor_name: Partial Caller Access + category: police + grant_ref: d4e5f6a7-0000-0000-0000-000000000022 + request_id: req-0022 + created_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + +# --- AC12: >100 disclosable events on domains(:capdomain) = capdomain.test (john's whole life), +# all past the delay cutoff, so the response is capped at 100 most-recent DESC. --- +<% (1..101).each do |i| %> +capdomain_event_<%= i %>: + requested_at: <%= (i + 1).hours.ago.to_s(:db) %> + domain_name: capdomain.test + caller_ip: 192.0.2.100 + result_code: 200 + organization_name: police + accessor_name: Cap Officer <%= i %> + category: police + grant_ref: <%= format('c3d4e5f6-0000-0000-0000-%012d', i) %> + request_id: cap-<%= i %> + created_at: <%= (i + 1).hours.ago.to_s(:db) %> +<% end %> diff --git a/test/fixtures/rdap_api_tokens.yml b/test/fixtures/rdap_api_tokens.yml new file mode 100644 index 0000000000..a25b590362 --- /dev/null +++ b/test/fixtures/rdap_api_tokens.yml @@ -0,0 +1,42 @@ +# RDAP-issued API token store fixtures. token_hash stands in for the keyed +# HMAC-SHA-256 digest RDAP would send (the raw token never reaches the registry). +# Subjects reuse the grant fixtures' eeID subjects. + +session_active: + token_hash: digest-session-active + subject: EE38001085718 + token_class: session + label: browser + issued_at: <%= 1.hour.ago %> + expires_at: <%= 7.hours.from_now %> + +machine_active: + token_hash: digest-machine-active + subject: EE38001085718 + token_class: machine + label: ci-runner + issued_at: <%= 2.days.ago %> + expires_at: <%= 88.days.from_now %> + +revoked: + token_hash: digest-revoked + subject: EE38001085718 + token_class: session + issued_at: <%= 3.hours.ago %> + expires_at: <%= 5.hours.from_now %> + revoked_at: <%= 1.hour.ago %> + +expired: + token_hash: digest-expired + subject: EE38001085718 + token_class: session + issued_at: <%= 2.days.ago %> + expires_at: <%= 1.hour.ago %> + +other_subject_active: + token_hash: digest-other-subject + subject: EE10001001000 + token_class: machine + label: partner-backend + issued_at: <%= 1.day.ago %> + expires_at: <%= 30.days.from_now %> diff --git a/test/fixtures/rdap_privilege_grants.yml b/test/fixtures/rdap_privilege_grants.yml new file mode 100644 index 0000000000..7088fdcaaf --- /dev/null +++ b/test/fixtures/rdap_privilege_grants.yml @@ -0,0 +1,97 @@ +# Subject form is the dash-free, country-prefixed eeID subject (EE38001085718), +# matching users.subject — NOT the dashed registrant_ident form. + +police_active: + eeid_subject: EE38001085718 + full_name: Police Officer One + legal_basis_ref: MoU-2026-001 + category: police + organization: police + status: active + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000001 + +# Same subject as police_active, but a NEWER valid_from — this one must win the +# "multiple active -> latest valid_from" selection. +police_active_newer: + eeid_subject: EE38001085718 + full_name: Cert Analyst Two + legal_basis_ref: MoU-2026-002 + category: cert + organization: cert_team + status: active + valid_from: <%= 1.day.ago.to_s(:db) %> + valid_until: <%= 30.days.from_now.to_s(:db) %> + uuid: a1b2c3d4-0000-0000-0000-000000000002 + +revoked: + eeid_subject: EE10001001000 + full_name: Revoked Grantee + legal_basis_ref: MoU-2026-003 + category: police + organization: police + status: revoked + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000003 + +suspended: + eeid_subject: EE10001001001 + full_name: Suspended Grantee + legal_basis_ref: MoU-2026-004 + category: cert + organization: cert + status: suspended + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000004 + +expired: + eeid_subject: EE10001001002 + full_name: Expired Grantee + legal_basis_ref: MoU-2026-005 + category: ria + organization: ria + status: active + valid_from: <%= 30.days.ago.to_s(:db) %> + valid_until: <%= 1.day.ago.to_s(:db) %> + uuid: a1b2c3d4-0000-0000-0000-000000000005 + +not_yet_valid: + eeid_subject: EE10001001003 + full_name: Future Grantee + legal_basis_ref: MoU-2026-006 + category: eis_internal + organization: eis + status: active + valid_from: <%= 5.days.from_now.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000006 + +# Grant with a NULL organization — the access-event snapshot must persist +# organization_name NULL (spec 11 AC8) while accessor_name/category/grant_ref stay set. +no_organization: + eeid_subject: EE10001001004 + full_name: No Org Grantee + legal_basis_ref: MoU-2026-007 + category: police + organization: + status: active + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000007 + +# Grant with eeid_subject AND personal_id_code populated — the access-event row +# must copy NEITHER into any column (spec 11 AC16 PII gate). +with_personal_id: + eeid_subject: EE49001010002 + full_name: Personal Id Grantee + legal_basis_ref: MoU-2026-008 + category: cert + organization: cert + personal_id_code: '49001010002' + status: active + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000008 diff --git a/test/fixtures/registrars.yml b/test/fixtures/registrars.yml index b0ae6d2800..1231e69583 100644 --- a/test/fixtures/registrars.yml +++ b/test/fixtures/registrars.yml @@ -51,3 +51,21 @@ invalid: accounting_customer_code: any language: en reference_no: 42 + +# spec 13 (RDAP access transparency): a test_registrar that carries the mytenure/capdomain +# fixtures so they stay OUT of the registrar market-share stats (serialize_* skips any +# registrar with test_registrar: true), while still being real, john-owned domains for the +# access-events tenure fixtures. +spec13_registrar: + name: Spec13 Registrar + reg_no: 90013 + code: spec13 + email: info@spec13.test + address_street: Main Street 1 + address_city: NY + address_country_code: US + vat_no: any + accounting_customer_code: spec13 + language: en + reference_no: 13013 + test_registrar: true diff --git a/test/integration/admin_area/rdap_privilege_grants_test.rb b/test/integration/admin_area/rdap_privilege_grants_test.rb new file mode 100644 index 0000000000..8e9d0d06d3 --- /dev/null +++ b/test/integration/admin_area/rdap_privilege_grants_test.rb @@ -0,0 +1,209 @@ +require 'test_helper' + +class AdminAreaRdapPrivilegeGrantsIntegrationTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @admin = users(:admin) + @grant = rdap_privilege_grants(:police_active) + sign_in @admin + end + + # --- AC1: list --------------------------------------------------------------- + def test_index_lists_grants + get admin_rdap_privilege_grants_path + + assert_response :success + assert_select 'table' + assert_match @grant.full_name, response.body + assert_match @grant.organization, response.body + end + + # --- AC2: create ------------------------------------------------------------- + def test_create_valid_grant + assert_difference('RdapPrivilegeGrant.count', 1) do + post admin_rdap_privilege_grants_path, params: { rdap_privilege_grant: valid_params } + end + + grant = RdapPrivilegeGrant.order(:created_at).last + assert_redirected_to admin_rdap_privilege_grant_path(grant) + assert_equal 'New Grantee', grant.full_name + assert_equal 'MoU-2026-777', grant.legal_basis_ref + end + + def test_create_invalid_grant_creates_no_row + assert_no_difference('RdapPrivilegeGrant.count') do + post admin_rdap_privilege_grants_path, + params: { rdap_privilege_grant: valid_params.merge(full_name: '') } + end + + assert_response :success # form re-rendered with errors + end + + # --- AC3: edit --------------------------------------------------------------- + def test_update_valid_grant + patch admin_rdap_privilege_grant_path(@grant), + params: { rdap_privilege_grant: { organization: 'new-org' } } + + assert_redirected_to admin_rdap_privilege_grant_path(@grant) + assert_equal 'new-org', @grant.reload.organization + end + + def test_update_invalid_grant_persists_nothing + patch admin_rdap_privilege_grant_path(@grant), + params: { rdap_privilege_grant: { legal_basis_ref: '' } } + + assert_response :success + assert_not_equal '', @grant.reload.legal_basis_ref + end + + # --- AC4 / AC5: suspend and revoke via distinct member routes ---------------- + def test_suspend_is_a_distinct_action + post suspend_admin_rdap_privilege_grant_path(@grant) + + assert_redirected_to admin_rdap_privilege_grant_path(@grant) + assert_equal 'suspended', @grant.reload.status + end + + def test_revoke_is_a_distinct_action + post revoke_admin_rdap_privilege_grant_path(@grant) + + assert_redirected_to admin_rdap_privilege_grant_path(@grant) + assert_equal 'revoked', @grant.reload.status + end + + # --- AC8 / AC9 / AC10: fields round-trip and render on show ------------------ + def test_show_renders_legal_basis_notes_and_full_name + @grant.update!(notes: 'Investigation 2026-42', legal_basis_ref: 'MoU-show-1') + + get admin_rdap_privilege_grant_path(@grant) + + assert_response :success + assert_match @grant.full_name, response.body + assert_match 'MoU-show-1', response.body + assert_match 'Investigation 2026-42', response.body + end + + # --- AC11 / AC16: attribution + audit log on create and update --------------- + def test_create_and_update_are_attributed_and_audited + post admin_rdap_privilege_grants_path, params: { rdap_privilege_grant: valid_params } + grant = RdapPrivilegeGrant.order(:created_at).last + + assert_equal @admin.id_role_username, grant.creator_str + assert_equal @admin.id_role_username, grant.updator_str + + create_version = grant.versions.where(event: 'create').last + assert_not_nil create_version + assert_equal @admin.id_role_username, create_version.whodunnit + + patch admin_rdap_privilege_grant_path(grant), + params: { rdap_privilege_grant: { organization: 'audited-org' } } + + update_version = grant.reload.versions.where(event: 'update').last + assert_not_nil update_version + assert_equal @admin.id_role_username, update_version.whodunnit + assert_equal @admin.id_role_username, grant.updator_str + end + + # --- AC17: audit history rendered on show ------------------------------------ + def test_show_renders_audit_history + @grant.update!(organization: 'history-org') + + get admin_rdap_privilege_grant_path(@grant) + + assert_response :success + assert_match I18n.t('admin.rdap_privilege_grants.show.audit_history'), response.body + assert_match 'update', response.body + end + + # --- AC18: no hard-delete route ---------------------------------------------- + def test_delete_is_not_routable + assert_raises(ActionController::RoutingError) do + delete admin_rdap_privilege_grant_path(@grant) + end + end + + # --- AC21: auth gating ------------------------------------------------------- + def test_unauthenticated_is_denied + sign_out @admin + get admin_rdap_privilege_grants_path + + assert_response :redirect + assert_no_match(/rdap privilege grants/i, flash[:notice].to_s) + end + + def test_non_admin_role_is_denied + sign_out @admin + sign_in non_admin_user + get admin_rdap_privilege_grants_path + + assert_redirected_to root_url + end + + def test_admin_role_is_allowed + get admin_rdap_privilege_grants_path + assert_response :success + end + + # --- AC23: personal_id_code never leaks to the index ------------------------- + def test_personal_id_code_absent_from_index + # Value chosen so it is NOT a substring of any rendered eeid_subject. + @grant.update!(personal_id_code: '49001010001') + + get admin_rdap_privilege_grants_path + + assert_response :success + assert_no_match '49001010001', response.body + end + + # --- AC23: personal_id_code never leaks to the show page (incl. audit history) -- + def test_personal_id_code_absent_from_show_and_audit_history + # Set the PII, then make an audit-generating change so the grant has + # paper_trail versions whose object/object_changes snapshots contain it. + @grant.update!(personal_id_code: '49001010001') + @grant.update!(organization: 'leak-probe-org') + + get admin_rdap_privilege_grant_path(@grant) + + assert_response :success + # The show page renders an audit-history table sourced from object_changes; + # the sensitive code must never appear there or anywhere on the page. + assert_no_match '49001010001', response.body + assert_no_match(/personal_id_code/i, response.body) + end + + # --- AC27: no missing translations on rendered pages ------------------------- + def test_rendered_pages_have_no_missing_translations + [admin_rdap_privilege_grants_path, + new_admin_rdap_privilege_grant_path, + admin_rdap_privilege_grant_path(@grant), + edit_admin_rdap_privilege_grant_path(@grant)].each do |path| + get path + assert_response :success + assert_no_match(/translation missing/i, response.body, "missing translation on #{path}") + end + end + + private + + def valid_params + { + eeid_subject: 'EE39912310123', + full_name: 'New Grantee', + legal_basis_ref: 'MoU-2026-777', + organization: 'police', + category: 'police', + valid_from: 1.day.ago, + notes: 'lawful access', + } + end + + def non_admin_user + AdminUser.create!(username: 'plain_admin', + email: 'plain@registry.test', + country_code: 'US', + password: 'testtest', + password_confirmation: 'testtest', + roles: ['user']) + end +end diff --git a/test/integration/api/registrant/registrant_api_domains_test.rb b/test/integration/api/registrant/registrant_api_domains_test.rb index a8801f1b4a..8c08491284 100644 --- a/test/integration/api/registrant/registrant_api_domains_test.rb +++ b/test/integration/api/registrant/registrant_api_domains_test.rb @@ -102,7 +102,9 @@ def test_domains_total_if_an_incomplete_list_is_returned response_json = JSON.parse(response.body, symbolize_names: true) assert_equal response_json[:domains].length, response_json[:count] - assert_equal response_json[:total], 5 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json[:total], 9 end def test_root_accepts_limit_and_offset_parameters @@ -116,7 +118,9 @@ def test_root_accepts_limit_and_offset_parameters get '/api/v1/registrant/domains', headers: @auth_headers response_json = JSON.parse(response.body, symbolize_names: true) - assert_equal(4, response_json[:domains].count) + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal(8, response_json[:domains].count) end def test_root_does_not_accept_limit_higher_than_200 diff --git a/test/integration/api/v1/internal/rdap/access_events_test.rb b/test/integration/api/v1/internal/rdap/access_events_test.rb new file mode 100644 index 0000000000..729a12084a --- /dev/null +++ b/test/integration/api/v1/internal/rdap/access_events_test.rb @@ -0,0 +1,231 @@ +require 'test_helper' + +class ApiV1InternalRdapAccessEventsTest < ApplicationIntegrationTest + def setup + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + @grant = rdap_privilege_grants(:police_active) + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + # AC5 — happy path: 204 + empty body + exactly one row with posted values. + def test_create_records_event_and_returns_204 + requested_at = 3.hours.ago.change(usec: 0) + + assert_difference 'RdapAccessEvent.count', 1 do + post_event(domain_name: 'happy.ee', caller_ip: '198.51.100.5', + requested_at: requested_at.iso8601, result_code: 200) + end + + assert_response :no_content + assert_empty response.body + + event = RdapAccessEvent.last + assert_equal 'happy.ee', event.domain_name + assert_equal '198.51.100.5', event.caller_ip + assert_equal 200, event.result_code + assert_equal requested_at.to_i, event.requested_at.to_i + end + + # AC6 — route reachable at the explicit path (no resources siblings). + def test_route_reachable_at_explicit_path + assert_routing({ method: 'post', path: '/api/v1/internal/rdap/access-events' }, + { controller: 'api/v1/internal/rdap/access_events', action: 'create' }) + end + + # AC7 — grant resolved by uuid AND by id; snapshots equal the grant fixture. + def test_create_snapshots_organization_accessor_category_grant_ref_from_grant + post_event(grant_id: @grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + assert_equal @grant.organization, event.organization_name + assert_equal @grant.full_name, event.accessor_name + assert_equal @grant.category, event.category + assert_equal @grant.uuid, event.grant_ref + + # Resolve by numeric id as well. + post_event(grant_id: @grant.id) + assert_response :no_content + assert_equal @grant.uuid, RdapAccessEvent.last.grant_ref + end + + # AC8 — grant with NULL organization: organization_name persists NULL, others set. + def test_create_with_grant_without_organization + grant = rdap_privilege_grants(:no_organization) + assert_nil grant.organization + + post_event(grant_id: grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + assert_nil event.organization_name + assert_equal grant.full_name, event.accessor_name + assert_equal grant.category, event.category + assert_equal grant.uuid, event.grant_ref + end + + # AC9 — unknown grant: 404 + { message: } + no row. + def test_create_with_unknown_grant_returns_404 + assert_no_difference 'RdapAccessEvent.count' do + post_event(grant_id: 'does-not-exist') + end + + assert_response :not_found + assert_equal 'Grant not found', json_body[:message] + end + + # AC10 — non-200 result_code: 422 + { message: } + no row. + def test_create_with_non_200_result_code_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(result_code: 404) + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC11 (a) — missing required field: save returns false -> 422 + no row. + def test_create_with_missing_field_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(domain_name: nil) + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC11 (b) — unparseable requested_at: 422 + no row. + def test_create_with_unparseable_requested_at_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(requested_at: 'not-a-timestamp') + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC12 — Punycode name stored verbatim, no lookup. + def test_create_with_punycode_domain_name + post_event(domain_name: 'xn--eeau-zna.ee') + assert_response :no_content + assert_equal 'xn--eeau-zna.ee', RdapAccessEvent.last.domain_name + end + + # AC12 — Unicode name stored verbatim, and a no-such-domain still succeeds (no lookup). + def test_create_stores_domain_name_verbatim_no_lookup + post_event(domain_name: 'õäöü.ee') + assert_response :no_content + assert_equal 'õäöü.ee', RdapAccessEvent.last.domain_name + + post_event(domain_name: 'no-such-domain-anywhere.ee') + assert_response :no_content + assert_equal 'no-such-domain-anywhere.ee', RdapAccessEvent.last.domain_name + end + + # AC13 — forced persistence exception: 500 + { message: }. NOT a validation-false. + def test_create_returns_500_on_save_failure + RdapAccessEvent.stub_any_instance(:save, ->(*) { raise ActiveRecord::StatementInvalid, 'boom' }) do + assert_no_difference 'RdapAccessEvent.count' do + post_event + end + end + + assert_response :internal_server_error + assert json_body[:message].present? + end + + # AC14 — the save-failure exception logs an error AND increments the failure metric. + def test_save_failure_logs_error_and_increments_counter + logged = [] + incremented = [] + + RdapAccessEvent.stub_any_instance(:save, ->(*) { raise ActiveRecord::StatementInvalid, 'boom' }) do + NewRelic::Agent.stub(:increment_metric, ->(name, *) { incremented << name }) do + Rails.logger.stub(:error, ->(msg = nil) { logged << msg }) do + post_event + end + end + end + + assert_response :internal_server_error + assert_includes incremented, 'Custom/Rdap/access_event_record_failure' + assert logged.any? { |m| m.to_s.include?('rdap_access_event') }, + 'expected a technical-log error carrying the rdap_access_event marker' + end + + # AC15 — unauthenticated request: 401 + no row. + def test_requires_authentication_returns_401 + assert_no_difference 'RdapAccessEvent.count' do + post '/api/v1/internal/rdap/access-events', params: valid_params + end + assert_response :unauthorized + end + + # AC16 — PII gate: neither eeid_subject nor personal_id_code is stored anywhere. + def test_create_does_not_store_eeid_subject_or_personal_id_code + grant = rdap_privilege_grants(:with_personal_id) + assert grant.eeid_subject.present? + assert grant.personal_id_code.present? + + post_event(grant_id: grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + values = event.attributes.values.map(&:to_s) + assert_not_includes values, grant.eeid_subject + assert_not_includes values, grant.personal_id_code + + columns = RdapAccessEvent.column_names + assert_not_includes columns, 'eeid_subject' + assert_not_includes columns, 'personal_id_code' + end + + # AC17 — request_id is non-unique: two POSTs with the same request_id both persist. + def test_request_id_is_not_unique + assert_difference 'RdapAccessEvent.count', 2 do + post_event(request_id: 'dup-123') + post_event(request_id: 'dup-123') + end + + assert_equal 2, RdapAccessEvent.where(request_id: 'dup-123').count + end + + # AC17 — request_id may be omitted (nullable). + def test_request_id_may_be_omitted + params = valid_params.except(:request_id) + assert_difference 'RdapAccessEvent.count', 1 do + post '/api/v1/internal/rdap/access-events', params: params, headers: @header + end + assert_response :no_content + assert_nil RdapAccessEvent.last.request_id + end + + private + + def valid_params + { + grant_id: @grant.uuid, + domain_name: 'example.ee', + requested_at: Time.zone.now.iso8601, + caller_ip: '192.0.2.1', + result_code: 200, + request_id: 'req-abc', + } + end + + def post_event(overrides = {}) + post '/api/v1/internal/rdap/access-events', + params: valid_params.merge(overrides), headers: @header + end + + def json_body + JSON.parse(response.body, symbolize_names: true) + end +end diff --git a/test/integration/api/v1/internal/rdap/domains_test.rb b/test/integration/api/v1/internal/rdap/domains_test.rb new file mode 100644 index 0000000000..4b0d1f5d0a --- /dev/null +++ b/test/integration/api/v1/internal/rdap/domains_test.rb @@ -0,0 +1,93 @@ +require 'test_helper' + +class ApiV1InternalRdapDomainsTest < ApplicationIntegrationTest + def setup + @domain = domains(:shop) + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + def test_returns_domain_by_name + get '/api/v1/internal/rdap/domains/shop.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 'shop.test', json[:name] + assert_equal @domain.registrant.name, json[:registrant][:name] + assert_equal 'bestnames', json[:registrar][:code] + # registrar embedded in the domain keeps email + reg_no (§1.4) + assert_equal @domain.registrar.email, json[:registrar][:email] + assert_equal @domain.registrar.reg_no, json[:registrar][:reg_no] + # admin/tech contacts via STI assocs + assert_includes json[:admin_contacts].map { |c| c[:name] }, contacts(:jane).name + assert_includes json[:tech_contacts].map { |c| c[:name] }, contacts(:william).name + # nameservers carry glue + hostnames = json[:nameservers].map { |n| n[:hostname] } + assert_includes hostnames, 'ns1.bestnames.test' + end + + def test_matches_on_name_puny + get '/api/v1/internal/rdap/domains/shop.test', headers: @header + assert_response :ok + end + + def test_response_never_contains_secrets + get '/api/v1/internal/rdap/domains/shop.test', headers: @header + + assert_response :ok + body = response.body + # the shop domain has transfer_code 65078d5; registrant john has auth_info cacb5b + refute_includes body, 'transfer_code' + refute_includes body, 'auth_info' + refute_includes body, 'registrant_verification_token' + refute_includes body, @domain.transfer_code + refute_includes body, @domain.registrant.auth_info + end + + def test_disclosed_attributes_is_union_of_both_arrays + contact = @domain.registrant + contact.update_columns(disclosed_attributes: %w[name email], + system_disclosed_attributes: %w[phone email]) + + get '/api/v1/internal/rdap/domains/shop.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_equal %w[name email phone].sort, + json[:registrant][:disclosed_attributes].sort + end + + def test_returns_404_when_domain_not_found + get '/api/v1/internal/rdap/domains/nonexistent.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 'Domain not found', json[:message] + end + + def test_requires_authentication + get '/api/v1/internal/rdap/domains/shop.test' + assert_response :unauthorized + end + + def test_rejects_wrong_shared_key + get '/api/v1/internal/rdap/domains/shop.test', + headers: { 'Authorization' => 'Basic wrong-key' } + assert_response :unauthorized + end + + def test_rejects_non_whitelisted_ip + get '/api/v1/internal/rdap/domains/shop.test', + headers: @header.merge('REMOTE_ADDR' => '10.10.10.10') + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :unauthorized + assert_equal 'IP address 10.10.10.10 is not authorized', json[:message] + end +end diff --git a/test/integration/api/v1/internal/rdap/grants_test.rb b/test/integration/api/v1/internal/rdap/grants_test.rb new file mode 100644 index 0000000000..de84de3512 --- /dev/null +++ b/test/integration/api/v1/internal/rdap/grants_test.rb @@ -0,0 +1,112 @@ +require 'test_helper' + +class ApiV1InternalRdapGrantsTest < ApplicationIntegrationTest + def setup + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + def test_returns_active_grant + get '/api/v1/internal/rdap/grants/active', params: { subject: 'EE38001085718' }, + headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 'EE38001085718', json[:eeid_subject] + assert_equal 'active', json[:status] + assert json[:grant_id].present? + end + + # Two active grants share EE38001085718; the latest valid_from wins + # (police_active_newer, category cert, valid_from 1.day.ago). + # The serializer is a frozen contract: admin-only columns added by spec + # 09-registry-privileged-admin (full_name, legal_basis_ref, personal_id_code, + # creator_str/updator_str) MUST NOT surface in the RDAP-facing output. + def test_serializer_exposes_no_admin_only_or_pii_fields + # Value chosen so it is NOT a substring of the eeid_subject the serializer + # legitimately returns (EE38001085718). + rdap_privilege_grants(:police_active_newer).update!(personal_id_code: '49001010001') + + get '/api/v1/internal/rdap/grants/active', params: { subject: 'EE38001085718' }, + headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + %i[full_name legal_basis_ref personal_id_code creator_str updator_str].each do |field| + assert_not_includes json.keys, field + end + assert_no_match '49001010001', response.body + end + + def test_multiple_active_returns_latest_valid_from + get '/api/v1/internal/rdap/grants/active', params: { subject: 'EE38001085718' }, + headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 'cert', json[:privilege_category] + assert_equal %w[cert], json[:privileges] + assert_equal rdap_privilege_grants(:police_active_newer).uuid, json[:grant_id] + end + + def test_revoked_grant_is_not_active + assert_no_active_grant('EE10001001000') + end + + def test_suspended_grant_is_not_active + assert_no_active_grant('EE10001001001') + end + + def test_expired_grant_is_not_active + assert_no_active_grant('EE10001001002') + end + + def test_not_yet_valid_grant_is_not_active + assert_no_active_grant('EE10001001003') + end + + def test_unknown_subject_returns_404 + assert_no_active_grant('EE00000000000') + end + + def test_requires_authentication + get '/api/v1/internal/rdap/grants/active', params: { subject: 'EE38001085718' } + assert_response :unauthorized + end + + def test_touch_sets_last_used_at_and_returns_204 + grant = rdap_privilege_grants(:police_active) + assert_nil grant.last_used_at + + post "/api/v1/internal/rdap/grants/#{grant.uuid}/touch", headers: @header + + assert_response :no_content + assert_not_nil grant.reload.last_used_at + end + + def test_touch_unknown_grant_returns_404 + post '/api/v1/internal/rdap/grants/does-not-exist/touch', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 'Grant not found', json[:message] + end + + private + + def assert_no_active_grant(subject) + get '/api/v1/internal/rdap/grants/active', params: { subject: subject }, + headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 'No active grant', json[:message] + end +end diff --git a/test/integration/api/v1/internal/rdap/nameservers_test.rb b/test/integration/api/v1/internal/rdap/nameservers_test.rb new file mode 100644 index 0000000000..b3144cd454 --- /dev/null +++ b/test/integration/api/v1/internal/rdap/nameservers_test.rb @@ -0,0 +1,60 @@ +require 'test_helper' + +class ApiV1InternalRdapNameserversTest < ApplicationIntegrationTest + def setup + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + def test_returns_thin_nameserver_shape + get '/api/v1/internal/rdap/nameservers/ns1.bestnames.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 'ns1.bestnames.test', json[:hostname] + assert_equal 'ns1.bestnames.test', json[:hostname_puny] + end + + # ns1.bestnames.test is attached to shop, airport AND metro in the fixtures. + # The endpoint must DISTINCT-collapse to a single object (an Object, not an + # Array) and never leak glue or a domain list (prevents enumeration). + def test_distinct_collapses_multi_domain_host_and_hides_glue + assert_operator Nameserver.where(hostname: 'ns1.bestnames.test').count, :>, 1 + + get '/api/v1/internal/rdap/nameservers/ns1.bestnames.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_kind_of Hash, json + assert_equal %i[hostname hostname_puny].sort, json.keys.sort + refute json.key?(:ipv4) + refute json.key?(:ipv6) + refute json.key?(:domains) + refute json.key?(:domain_id) + end + + def test_matches_on_hostname_puny + get '/api/v1/internal/rdap/nameservers/ns2.bestnames.test', headers: @header + assert_response :ok + end + + def test_returns_404_when_not_found + get '/api/v1/internal/rdap/nameservers/ns9.nope.test', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 'Nameserver not found', json[:message] + end + + def test_requires_authentication + get '/api/v1/internal/rdap/nameservers/ns1.bestnames.test' + assert_response :unauthorized + end +end diff --git a/test/integration/api/v1/internal/rdap/registrars_test.rb b/test/integration/api/v1/internal/rdap/registrars_test.rb new file mode 100644 index 0000000000..0d69fbed3c --- /dev/null +++ b/test/integration/api/v1/internal/rdap/registrars_test.rb @@ -0,0 +1,62 @@ +require 'test_helper' + +class ApiV1InternalRdapRegistrarsTest < ApplicationIntegrationTest + def setup + @registrar = registrars(:bestnames) + # In production, registrar codes are stored upper-cased by Registrar#code= + # (the controller upcases the lookup to match, per the contract). The shared + # fixture is loaded via raw INSERT, bypassing the setter, so normalize here. + @registrar.update_columns(code: @registrar.code.upcase) + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + def test_returns_registrar_narrow_shape + get '/api/v1/internal/rdap/registrars/bestnames', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + # Full-hash equality: proves the response is EXACTLY the narrow 4-key shape + # (no email/reg_no leak) and avoids the assert_nil deprecation when a fixture + # field is nil. + assert_equal( + { code: 'BESTNAMES', name: @registrar.name, phone: @registrar.phone, website: @registrar.website }, + json + ) + end + + def test_does_not_expose_email_or_reg_no + get '/api/v1/internal/rdap/registrars/bestnames', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + refute json.key?(:email), 'email MUST NOT appear on the entity endpoint' + refute json.key?(:reg_no), 'reg_no MUST NOT appear on the entity endpoint' + refute_includes response.body, @registrar.email + end + + def test_upcases_code_before_lookup + get '/api/v1/internal/rdap/registrars/BESTNAMES', headers: @header + assert_response :ok + end + + def test_returns_404_when_not_found + get '/api/v1/internal/rdap/registrars/nope', headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :not_found + assert_equal 'Registrar not found', json[:message] + end + + def test_requires_authentication + get '/api/v1/internal/rdap/registrars/bestnames' + assert_response :unauthorized + end +end diff --git a/test/integration/api/v1/internal/rdap/tokens_test.rb b/test/integration/api/v1/internal/rdap/tokens_test.rb new file mode 100644 index 0000000000..90f8d2fe19 --- /dev/null +++ b/test/integration/api/v1/internal/rdap/tokens_test.rb @@ -0,0 +1,158 @@ +require 'test_helper' + +class ApiV1InternalRdapTokensTest < ApplicationIntegrationTest + def setup + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + # ---- create ------------------------------------------------------------- + + def test_create_persists_and_returns_the_token + assert_difference 'RdapApiToken.count', 1 do + post '/api/v1/internal/rdap/tokens', + params: { token_hash: 'digest-fresh', subject: 'EE38001085718', + token_class: 'machine', label: 'deploy-bot', + expires_at: 90.days.from_now.utc.iso8601 }, + headers: @header + end + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :created + assert_equal 'digest-fresh', json[:token_hash] + assert_equal 'EE38001085718', json[:subject] + assert_equal 'machine', json[:token_class] + assert_equal 'deploy-bot', json[:label] + assert json[:expires_at].present? + assert_nil json[:revoked_at] + end + + def test_create_rejects_invalid_class + assert_no_difference 'RdapApiToken.count' do + post '/api/v1/internal/rdap/tokens', + params: { token_hash: 'digest-bad', subject: 'EE38001085718', + token_class: 'root', expires_at: 1.day.from_now.utc.iso8601 }, + headers: @header + end + assert_response :unprocessable_entity + end + + # ---- active (find by hash) --------------------------------------------- + + def test_active_returns_active_token + token = rdap_api_tokens(:session_active) + get '/api/v1/internal/rdap/tokens/active', + params: { token_hash: token.token_hash }, headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal token.subject, json[:subject] + assert_equal 'session', json[:token_class] + end + + def test_active_revoked_is_404 + assert_no_active(rdap_api_tokens(:revoked).token_hash) + end + + def test_active_expired_is_404 + assert_no_active(rdap_api_tokens(:expired).token_hash) + end + + def test_active_unknown_is_404 + assert_no_active('nope') + end + + # ---- index (list for subject) ------------------------------------------ + + def test_index_lists_only_the_subjects_tokens + get '/api/v1/internal/rdap/tokens', + params: { subject: 'EE38001085718' }, headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + subjects = json.map { |t| t[:subject] }.uniq + assert_equal %w[EE38001085718], subjects + assert_not_includes json.map { |t| t[:token_hash] }, rdap_api_tokens(:other_subject_active).token_hash + end + + # ---- revoke ------------------------------------------------------------- + + def test_revoke_makes_token_inactive + token = rdap_api_tokens(:session_active) + post '/api/v1/internal/rdap/tokens/revoke', + params: { token_hash: token.token_hash }, headers: @header + + assert_response :no_content + assert_not_nil token.reload.revoked_at + assert_no_active(token.token_hash) + end + + def test_revoke_unknown_is_idempotent_204 + post '/api/v1/internal/rdap/tokens/revoke', + params: { token_hash: 'ghost' }, headers: @header + assert_response :no_content + end + + # ---- revoke_all --------------------------------------------------------- + + def test_revoke_all_kills_every_active_token_for_subject + post '/api/v1/internal/rdap/tokens/revoke_all', + params: { subject: 'EE38001085718' }, headers: @header + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + # Revokes every NOT-yet-revoked token for the subject (the kill-all filter is + # revoked_at IS NULL) — session_active + machine_active + the expired-but-not- + # revoked one; the already-revoked fixture is left untouched. Matches the RDAP + # mock semantics (revoked_at.nil? is the only filter). + assert_equal 3, json[:revoked_count] + assert_no_active(rdap_api_tokens(:session_active).token_hash) + assert_no_active(rdap_api_tokens(:machine_active).token_hash) + # A different subject's token is untouched. + assert_nil rdap_api_tokens(:other_subject_active).reload.revoked_at + end + + # ---- touch -------------------------------------------------------------- + + def test_touch_sets_last_used_and_keeps_expiry + token = rdap_api_tokens(:machine_active) + original_expiry = token.expires_at + post '/api/v1/internal/rdap/tokens/touch', + params: { token_hash: token.token_hash }, headers: @header + + assert_response :no_content + token.reload + assert_not_nil token.last_used_at + assert_equal original_expiry.to_i, token.expires_at.to_i + end + + def test_touch_unknown_is_204 + post '/api/v1/internal/rdap/tokens/touch', + params: { token_hash: 'ghost' }, headers: @header + assert_response :no_content + end + + # ---- auth --------------------------------------------------------------- + + def test_requires_authentication + get '/api/v1/internal/rdap/tokens/active', params: { token_hash: 'x' } + assert_response :unauthorized + end + + private + + def assert_no_active(token_hash) + get '/api/v1/internal/rdap/tokens/active', + params: { token_hash: token_hash }, headers: @header + json = JSON.parse(response.body, symbolize_names: true) + assert_response :not_found + assert_equal 'No active token', json[:message] + end +end diff --git a/test/integration/api/v1/registrant/access_events_test.rb b/test/integration/api/v1/registrant/access_events_test.rb new file mode 100644 index 0000000000..9180787bff --- /dev/null +++ b/test/integration/api/v1/registrant/access_events_test.rb @@ -0,0 +1,292 @@ +require 'test_helper' +require 'auth_token/auth_token_creator' + +# spec 13, Surface A: GET /api/v1/registrant/domains/:uuid/access_events +# +# Fixture ground truth (see test/fixtures/{domains,contacts,log_domains,rdap_access_events}.yml): +# users(:registrant) == US-1234 resolves via Contact.registrant_user_direct_contacts to +# contacts(:john) only (ident 1234, US). contacts(:jane) is the other/prior/intervening owner. +# domains(:mytenure) (name mytenure.test) is currently john's; its log_domains timeline gives +# john the tenure intervals [2021-01-01, 2022-01-01) and [2023-01-01, now); jane owns +# [2020-01-01, 2021-01-01) and [2022-01-01, 2023-01-01). +class RegistrantApiV1AccessEventsTest < ActionDispatch::IntegrationTest + setup do + @user = users(:registrant) + @domain = domains(:mytenure) + end + + def test_route_exists_per_domain_only + get access_events_path(@domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_response :ok + assert_kind_of Array, JSON.parse(response.body) + end + + def test_missing_and_invalid_token_return_401 + get access_events_path(@domain.uuid), as: :json + assert_response :unauthorized + + get access_events_path(@domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => 'Bearer garbage-not-a-token' } + assert_response :unauthorized + end + + def test_ignores_client_supplied_registrant_and_contact_and_ident + clean_body = get_events(@domain.uuid) + + # Inject a foreign registrant id / contact id (jane, id 123456) and an ident header — + # all must be ignored; scoping is derived only from current_registrant_user. + get access_events_path(@domain.uuid) + '?registrant_id=999999&contact_id=999999', + as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token, + 'X-Registrant-Ident' => '123456' } + assert_response :ok + injected_body = JSON.parse(response.body) + + assert_equal clean_body, injected_body + end + + def test_event_during_caller_tenure_is_returned + body = get_events(@domain.uuid) + # mytenure_in_first_tenure (2021-06-01) is inside [2021-01-01, 2022-01-01). + assert_includes accessed_ats(body), iso('2021-06-01 10:00:00') + end + + def test_prior_owner_event_excluded + body = get_events(@domain.uuid) + # mytenure_prior_owner (2020-06-01) is before john's earliest tenure start. + refute_includes accessed_ats(body), iso('2020-06-01 10:00:00') + end + + def test_later_owner_and_transfer_instant_events_excluded + body = get_events(@domain.uuid) + # transfer instant == end (2022-01-01 00:00:00) belongs to the acquiring owner. + refute_includes accessed_ats(body), iso('2022-01-01 00:00:00') + # intervening owner's interval [2022-01-01, 2023-01-01). + refute_includes accessed_ats(body), iso('2022-06-01 10:00:00') + end + + def test_two_noncontiguous_tenures_return_both_own_intervals_only + body = get_events(@domain.uuid) + ats = accessed_ats(body) + assert_includes ats, iso('2021-06-01 10:00:00') # first own interval + assert_includes ats, iso('2023-06-01 10:00:00') # second own interval + refute_includes ats, iso('2022-06-01 10:00:00') # intervening owner + end + + def test_namesake_different_item_id_event_excluded + body = get_events(@domain.uuid) + # 2015-06-01 mytenure.test event lies outside every interval of this item_id. + refute_includes accessed_ats(body), iso('2015-06-01 10:00:00') + end + + def test_unknown_and_other_registrant_uuid_return_identical_404 + unknown_uuid = '00000000-0000-0000-0000-000000000000' + other_uuid = domains(:metro).uuid # owned by jack, not the caller + + get access_events_path(unknown_uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :not_found + unknown_body = response.body + + get access_events_path(other_uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :not_found + other_body = response.body + + assert_equal({ 'errors' => [{ 'base' => ['Domain not found'] }] }, JSON.parse(unknown_body)) + assert_equal unknown_body, other_body # byte-identical, no existence leak + end + + def test_delay_filter_in_window_absent_past_cutoff_present + with_setting('rdap_access_transparency_disclosure_delay', '5') do + body = get_events(@domain.uuid) + ats = accessed_ats(body) + # 2-hours-ago event is past the 5-minute cutoff -> present. + assert(ats.any? { |a| Time.zone.parse(a) < 30.minutes.ago }, + 'expected a past-cutoff event in the response') + # 1-minute-ago event is inside the suppression window -> absent. + refute(ats.any? { |a| Time.zone.parse(a) > 30.minutes.ago }, + 'in-window (newer than cutoff) event must be absent') + end + end + + def test_setting_value_and_missing_setting_5min_fallback + # (a) a controlled Setting value drives the cutoff. + with_setting('rdap_access_transparency_disclosure_delay', '5') do + body = get_events(@domain.uuid) + refute(accessed_ats(body).any? { |a| Time.zone.parse(a) > 30.minutes.ago }) + end + + # (b) with the Setting row ABSENT, the 5-minute non-zero fallback still suppresses the + # in-window event (never real-time / 0-delay). + SettingEntry.where(code: 'rdap_access_transparency_disclosure_delay').destroy_all + assert_nil Setting.rdap_access_transparency_disclosure_delay + body = get_events(@domain.uuid) + refute(accessed_ats(body).any? { |a| Time.zone.parse(a) > 30.minutes.ago }, + 'missing Setting must fall back to 5 minutes, not 0-delay') + end + + def test_order_desc_and_cap_100_after_filtering + cap_uuid = domains(:capdomain).uuid + body = get_events(cap_uuid) + + assert_equal 100, body.size + ats = body.map { |e| Time.zone.parse(e['accessed_at']) } + assert_equal ats.sort.reverse, ats, 'events must be ordered requested_at DESC' + # The 100 returned are the most-recent disclosable ones (cap applied post-filter): the + # oldest capdomain event (capdomain_event_101 at 102h ago) must be excluded. + refute_includes body.map { |e| e['accessed_at'] }, iso_time(102.hours.ago) + end + + def test_response_has_exactly_three_keys_and_no_withheld_fields + body = get_events(@domain.uuid) + assert body.any?, 'expected at least one disclosable event' + + body.each do |event| + assert_equal %w[accessed_at category organization], event.keys.sort + # accessed_at parses as ISO-8601 with a timezone offset. + assert_match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2}\z/, event['accessed_at']) + end + + # null organization_name -> organization: null (mytenure_in_second_tenure_null_org). + null_org = body.find { |e| e['accessed_at'] == iso('2023-06-01 10:00:00') } + assert null_org, 'expected the null-organization event to be disclosed' + assert_nil null_org['organization'] + + raw = response.body + %w[accessor_name grant_ref request_id caller_ip result_code created_at + eeid_subject personal_id_code Officer\ Alpha Namesake].each do |withheld| + refute_includes raw, withheld, "response body must not contain #{withheld}" + end + # the caller's own ident must not leak. + refute_includes raw, @user.ident + end + + def test_no_pii_in_logs_across_happy_404_401 + logged = capture_rails_log do + get_events(@domain.uuid) + get access_events_path('00000000-0000-0000-0000-000000000000'), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + get access_events_path(@domain.uuid), as: :json # 401 + end + + refute_includes logged, @user.ident # caller's ident string + refute_includes logged, 'Officer Alpha' # accessor personal identifier + refute_includes logged, 'Namesake Access' + refute_includes logged, 'Recent Officer' + end + + def test_read_only_no_store_or_related_writes + assert_no_difference ['RdapAccessEvent.count', 'Version::DomainVersion.count', + 'Domain.count', 'Contact.count'] do + get_events(@domain.uuid) + get access_events_path('00000000-0000-0000-0000-000000000000'), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + get access_events_path(@domain.uuid), as: :json # 401 + end + end + + # FAIL-CLOSED regression (M1): the caller CURRENTLY owns domains(:orphan) but the log_domains + # timeline never records john becoming registrant — only the prior owner (jane) appears. The old + # fail-OPEN live tail anchored to rows.first (2020) and emitted [2020, now), leaking jane's + # prior-tenure police lookup. The fix emits NO interval, so nothing is disclosed. + def test_fail_closed_caller_never_in_timeline_excludes_prior_owner_event + orphan_uuid = domains(:orphan).uuid + body = get_events(orphan_uuid) + + assert_equal [], body, + 'caller absent from the recorded timeline must disclose NO events (fail closed)' + refute_includes accessed_ats(body), iso('2020-06-01 10:00:00') + end + + # FAIL-CLOSED sub-case (M1): the caller CURRENTLY owns domains(:partial) and DID appear as + # effective registrant earlier (2021), but the last recorded change hands it back to jane (2022) + # with no john re-acquire row. The fix anchors the live tail to john's last effective-registrant + # version (2021): only events at/after 2021 are disclosed, jane's pre-2021 event stays excluded. + def test_fail_closed_caller_earlier_in_timeline_anchors_to_last_own_version + partial_uuid = domains(:partial).uuid + body = get_events(partial_uuid) + ats = accessed_ats(body) + + assert_includes ats, iso('2021-06-01 10:00:00'), + 'event after the caller last-own version (2021) must be disclosed' + refute_includes ats, iso('2020-06-01 10:00:00'), + 'prior-owner event before the caller last-own version must be excluded' + end + + def test_owned_domain_zero_events_returns_200_empty_array + # domains(:hospital) is john's but has no rdap_access_events rows (hospital.test). + empty_domain = domains(:hospital) + get access_events_path(empty_domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_response :ok + assert_equal [], JSON.parse(response.body) + end + + private + + def access_events_path(uuid) + "/api/v1/registrant/domains/#{uuid}/access_events" + end + + def get_events(uuid) + get access_events_path(uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :ok + JSON.parse(response.body) + end + + def accessed_ats(body) + body.map { |e| e['accessed_at'] } + end + + def iso(parseable) + Time.zone.parse(parseable).iso8601 + end + + def iso_time(time) + time.iso8601 + end + + def with_setting(code, value) + entry = SettingEntry.find_by(code: code) + original = entry&.value + if entry + entry.update!(value: value) + else + SettingEntry.create!(code: code, value: value, format: 'integer', group: 'rdap') + end + yield + ensure + current = SettingEntry.find_by(code: code) + if original + current&.update!(value: original) + else + current&.destroy + end + end + + # Capture technical-log / error lines the endpoint emits at the application's own log level + # (info+; SQL debug lines are not part of the endpoint's emitted output). The assertion is that + # the endpoint's own logging carries no PII — our controller/query object make no log calls. + def capture_rails_log + io = StringIO.new + logger = ActiveSupport::Logger.new(io) + logger.level = Logger::INFO + original = Rails.logger + Rails.logger = logger + yield + io.string + ensure + Rails.logger = original + end + + def auth_token + token_creator = AuthTokenCreator.create_with_defaults(@user) + hash = token_creator.token_in_hash + "Bearer #{hash[:access_token]}" + end +end diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index 367df9e48a..30f6f60ba5 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -21,8 +21,10 @@ def test_get_default_counts_of_domains assert_response :ok response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json['total'], 8 + assert_equal response_json['count'], 8 end def test_get_default_counts_of_direct_domains @@ -32,8 +34,10 @@ def test_get_default_counts_of_direct_domains end response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json['total'], 8 + assert_equal response_json['count'], 8 end private diff --git a/test/integration/repp/v1/stats/market_share_test.rb b/test/integration/repp/v1/stats/market_share_test.rb index fa7ea75899..6a8bd2f72e 100644 --- a/test/integration/repp/v1/stats/market_share_test.rb +++ b/test/integration/repp/v1/stats/market_share_test.rb @@ -34,11 +34,18 @@ def test_shows_market_share_growth_rate_data assert_equal 1000, json[:code] assert_equal 'Command completed successfully', json[:message] + # domains counts are unchanged: the spec-13 test_registrar fixtures are excluded by + # serialize_growth_rate_result. calculate_market_share, however, divides by the total across + # ALL registrars (test ones included), so the spec-13 domains enlarge the denominator and + # lower each shown share. There are now 4 spec-13 domains (mytenure/capdomain + + # orphan/partial, all created 2020-01-01, so present at both dates): + # prev (2023-11): Good 3 / (3 + 0 + 4 spec13) = 42.9 % + # today (07.26): Good 2 / (2 + 4 + 4 spec13) = 20.0 %, Best 4 / 10 = 40.0 % assert_equal json[:data], prev_data: { name: prev_date, domains: [['Good Names', 3], ['Best Names', 0]], - market_share: [['Good Names', 100.0], ['Best Names', 0.0]] }, + market_share: [['Good Names', 42.9], ['Best Names', 0.0]] }, data: { name: @today, domains: [['Good Names', 2], ['Best Names', 4]], - market_share: [['Good Names', 33.3], ['Best Names', 66.7]] } + market_share: [['Good Names', 20.0], ['Best Names', 40.0]] } end end diff --git a/test/models/rdap_access_event_test.rb b/test/models/rdap_access_event_test.rb new file mode 100644 index 0000000000..587d8ed388 --- /dev/null +++ b/test/models/rdap_access_event_test.rb @@ -0,0 +1,96 @@ +require 'test_helper' + +class RdapAccessEventTest < ActiveSupport::TestCase + def test_valid_event + assert build_event.valid? + end + + def test_requires_requested_at + assert_invalid_without(:requested_at) + end + + def test_requires_domain_name + assert_invalid_without(:domain_name) + end + + def test_requires_caller_ip + assert_invalid_without(:caller_ip) + end + + def test_requires_result_code + assert_invalid_without(:result_code) + end + + def test_requires_accessor_name + assert_invalid_without(:accessor_name) + end + + def test_requires_category + assert_invalid_without(:category) + end + + def test_requires_grant_ref + assert_invalid_without(:grant_ref) + end + + # AC8 / AC19: organization_name and request_id are NOT required. + def test_valid_with_blank_organization_name_and_request_id + event = build_event(organization_name: nil, request_id: nil) + assert event.valid? + assert event.save + assert_nil event.reload.organization_name + assert_nil event.request_id + end + + # AC18: create-only immutability — a fresh create succeeds, but any mutation + # or destroy of a persisted row raises. + def test_create_succeeds + assert_difference 'RdapAccessEvent.count', 1 do + build_event.save! + end + end + + def test_update_raises + event = build_event.tap(&:save!) + assert_raises(ActiveRecord::ReadOnlyRecord) { event.update!(domain_name: 'changed.ee') } + end + + def test_save_on_persisted_row_raises + event = build_event.tap(&:save!) + event.domain_name = 'changed.ee' + assert_raises(ActiveRecord::ReadOnlyRecord) { event.save } + end + + def test_destroy_raises + event = build_event.tap(&:save!) + assert_raises(ActiveRecord::ReadOnlyRecord) { event.destroy } + end + + # AC20: NOT a paper_trail model — it does not include the Versions concern. + def test_is_not_a_paper_trail_model + assert_not RdapAccessEvent.include?(Versions) + assert_not RdapAccessEvent.respond_to?(:paper_trail_options) + end + + private + + def build_event(attrs = {}) + RdapAccessEvent.new({ + requested_at: Time.zone.now, + domain_name: 'example.ee', + caller_ip: '192.0.2.1', + result_code: 200, + organization_name: 'police', + accessor_name: 'Police Officer One', + category: 'police', + grant_ref: 'a1b2c3d4-0000-0000-0000-000000000001', + request_id: 'req-test', + }.merge(attrs)) + end + + def assert_invalid_without(field) + event = build_event(field => nil) + assert event.invalid? + assert_includes event.errors.attribute_names, field + end +end diff --git a/test/models/rdap_api_token_test.rb b/test/models/rdap_api_token_test.rb new file mode 100644 index 0000000000..a282fc9b46 --- /dev/null +++ b/test/models/rdap_api_token_test.rb @@ -0,0 +1,82 @@ +require 'test_helper' + +class RdapApiTokenTest < ActiveSupport::TestCase + def test_valid_fixture + assert rdap_api_tokens(:session_active).valid? + end + + def test_requires_token_hash_subject_class_and_timestamps + token = RdapApiToken.new + assert_not token.valid? + assert token.errors.key?(:token_hash) + assert token.errors.key?(:subject) + assert token.errors.key?(:token_class) + assert token.errors.key?(:issued_at) + assert token.errors.key?(:expires_at) + end + + def test_token_class_inclusion + token = build_token(token_class: 'admin') + assert_not token.valid? + assert token.errors.key?(:token_class) + end + + def test_token_hash_uniqueness + dup = build_token(token_hash: rdap_api_tokens(:session_active).token_hash) + assert_not dup.valid? + assert dup.errors.key?(:token_hash) + end + + def test_active_by_hash_returns_active + token = rdap_api_tokens(:session_active) + assert_equal token, RdapApiToken.active_by_hash(token.token_hash).first + end + + def test_active_by_hash_excludes_revoked + assert_nil RdapApiToken.active_by_hash(rdap_api_tokens(:revoked).token_hash).first + end + + def test_active_by_hash_excludes_expired + assert_nil RdapApiToken.active_by_hash(rdap_api_tokens(:expired).token_hash).first + end + + def test_active_by_hash_unknown_is_nil + assert_nil RdapApiToken.active_by_hash('no-such-digest').first + end + + def test_for_subject_scope + subjects = RdapApiToken.for_subject('EE38001085718').pluck(:subject).uniq + assert_equal %w[EE38001085718], subjects + end + + def test_revoke_is_idempotent_and_preserves_time + token = rdap_api_tokens(:session_active) + token.revoke! + first_time = token.reload.revoked_at + assert_not_nil first_time + + token.revoke! + assert_equal first_time, token.reload.revoked_at + end + + def test_touch_last_used_does_not_change_expiry + token = rdap_api_tokens(:session_active) + original_expiry = token.expires_at + token.touch_last_used! + token.reload + assert_not_nil token.last_used_at + assert_equal original_expiry.to_i, token.expires_at.to_i + end + + private + + def build_token(overrides = {}) + RdapApiToken.new({ + token_hash: 'digest-new', + subject: 'EE38001085718', + token_class: 'session', + issued_at: Time.zone.now, + expires_at: 8.hours.from_now, + }.merge(overrides)) + end +end diff --git a/test/models/rdap_privilege_grant_test.rb b/test/models/rdap_privilege_grant_test.rb new file mode 100644 index 0000000000..2e5a11d851 --- /dev/null +++ b/test/models/rdap_privilege_grant_test.rb @@ -0,0 +1,121 @@ +require 'test_helper' + +class RdapPrivilegeGrantTest < ActiveSupport::TestCase + def test_valid_grant + assert build_grant.valid? + end + + def test_requires_eeid_subject + grant = build_grant(eeid_subject: nil) + assert grant.invalid? + assert_includes grant.errors.attribute_names, :eeid_subject + end + + def test_requires_full_name + grant = build_grant(full_name: nil) + assert grant.invalid? + assert_includes grant.errors.attribute_names, :full_name + end + + def test_requires_legal_basis_ref + grant = build_grant(legal_basis_ref: nil) + assert grant.invalid? + assert_includes grant.errors.attribute_names, :legal_basis_ref + end + + def test_personal_id_code_is_optional + assert build_grant(personal_id_code: nil).valid? + assert build_grant(personal_id_code: '38001085718').valid? + end + + def test_valid_with_only_valid_from + assert build_grant(valid_from: 1.day.ago, valid_until: nil).valid? + end + + def test_statuses_do_not_include_expired + assert_equal %w[active revoked suspended], RdapPrivilegeGrant::STATUSES + end + + def test_expired_is_derived_from_valid_until + active = build_grant(status: 'active', valid_from: 30.days.ago, valid_until: 1.day.ago) + assert active.expired? + assert_equal 'expired', active.display_status + + live = build_grant(status: 'active', valid_from: 1.day.ago, valid_until: 30.days.from_now) + assert_not live.expired? + assert_equal 'active', live.display_status + end + + def test_active_for_subject_returns_nothing_immediately_after_revoke + subject = 'EE44444444444' + grant = create_grant(eeid_subject: subject, status: 'active', valid_from: 2.days.ago) + assert_equal [grant.id], RdapPrivilegeGrant.active_for_subject(subject).map(&:id) + + grant.update!(status: 'revoked') + assert_empty RdapPrivilegeGrant.active_for_subject(subject) + end + + def test_category_must_be_in_list + assert build_grant(category: 'mayor').invalid? + RdapPrivilegeGrant::CATEGORIES.each do |category| + assert build_grant(category: category).valid?, "#{category} should be valid" + end + end + + def test_status_must_be_in_list + assert build_grant(status: 'paused').invalid? + end + + def test_valid_until_must_be_after_valid_from + grant = build_grant(valid_from: Time.zone.now, valid_until: 1.day.ago) + assert grant.invalid? + assert_includes grant.errors.attribute_names, :valid_until + end + + def test_assigns_uuid_on_create + grant = build_grant + assert_nil grant.uuid + grant.save! + assert grant.uuid.present? + end + + def test_active_for_subject_returns_only_active_window + subject = 'EE12345678901' + active = create_grant(eeid_subject: subject, status: 'active', + valid_from: 2.days.ago, valid_until: nil) + create_grant(eeid_subject: subject, status: 'revoked', valid_from: 2.days.ago) + create_grant(eeid_subject: subject, status: 'active', + valid_from: 1.day.from_now) # not yet valid + create_grant(eeid_subject: subject, status: 'active', + valid_from: 5.days.ago, valid_until: 1.day.ago) # expired + + result = RdapPrivilegeGrant.active_for_subject(subject) + assert_equal [active.id], result.map(&:id) + end + + def test_active_for_subject_orders_latest_valid_from_first + subject = 'EE99999999999' + older = create_grant(eeid_subject: subject, status: 'active', valid_from: 10.days.ago) + newer = create_grant(eeid_subject: subject, status: 'active', valid_from: 1.day.ago) + + result = RdapPrivilegeGrant.active_for_subject(subject) + assert_equal [newer.id, older.id], result.map(&:id) + end + + private + + def build_grant(attrs = {}) + RdapPrivilegeGrant.new({ + eeid_subject: 'EE38001085718', + full_name: 'Grant Holder', + legal_basis_ref: 'MoU-2026-999', + category: 'police', + status: 'active', + valid_from: 1.day.ago, + }.merge(attrs)) + end + + def create_grant(attrs = {}) + build_grant(attrs).tap(&:save!) + end +end