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/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/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_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/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..edd5dfe914 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' \ No newline at end of file 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/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..89767cb977 --- /dev/null +++ b/test/fixtures/rdap_privilege_grants.yml @@ -0,0 +1,70 @@ +# 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 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/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/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