From b5d7a2030adb72c32a755d0f71857496976b56f7 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 13 Jul 2026 15:01:09 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Add=20privileged=20RDAP=20grant=20administr?= =?UTF-8?q?ation=20(RPD=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin CRUD, inside the registry app, for managing RdapPrivilegeGrant (spec 09-registry-privileged-admin), sitting next to the data it manages. - Migration: add full_name (NOT NULL), legal_basis_ref (NOT NULL) and optional personal_id_code to rdap_privilege_grants; new per-model log_rdap_privilege_grants audit table + creator_str/updator_str columns. - Model: include the Versions concern (immutable paper_trail audit), presence validations for full_name/legal_basis_ref, and a read-time derived expired?/display_status (STATUSES stays active/revoked/suspended). - Controller/views/routes: top-level admin CRUD mirroring admin_users, with distinct suspend/revoke member actions and no destroy (grants end via suspend/revoke only; audit entries have no edit/delete route). - Ability: can :manage, RdapPrivilegeGrant for the admin role only. - personal_id_code is capture-only: never in the index, never in the frozen internal grants serializer, filtered from logs (config.filter_parameters). - The frozen api/v1/internal/rdap/grants_controller#serialize is untouched; active_for_subject and grant_id are unchanged. - Fresh i18n keys + menu link; all 6 fixtures populated for the new columns. - Tests: model (validations, derived expired, revoke-takes-effect), admin integration (CRUD, suspend/revoke, auth gating, audit history, no-delete route, no PII/no missing-translation), serializer no-leak. --- .../admin/rdap_privilege_grants_controller.rb | 70 +++++++ app/models/ability.rb | 1 + app/models/rdap_privilege_grant.rb | 19 ++ .../version/rdap_privilege_grant_version.rb | 5 + app/views/admin/base/_menu.haml | 2 + .../admin/rdap_privilege_grants/_form.haml | 57 ++++++ .../admin/rdap_privilege_grants/edit.haml | 5 + .../admin/rdap_privilege_grants/index.haml | 40 ++++ .../admin/rdap_privilege_grants/new.haml | 3 + .../admin/rdap_privilege_grants/show.haml | 68 ++++++ .../initializers/filter_parameter_logging.rb | 3 + config/locales/admin/menu.en.yml | 1 + .../admin/rdap_privilege_grants.en.yml | 36 ++++ config/routes.rb | 10 + ...dd_rpd9_fields_to_rdap_privilege_grants.rb | 27 +++ ...3120001_create_rdap_privilege_grant_log.rb | 23 +++ db/structure.sql | 68 +++++- test/fixtures/rdap_privilege_grants.yml | 12 ++ .../admin_area/rdap_privilege_grants_test.rb | 193 ++++++++++++++++++ .../api/v1/internal/rdap/grants_test.rb | 19 ++ test/models/rdap_privilege_grant_test.rb | 46 +++++ 21 files changed, 704 insertions(+), 4 deletions(-) create mode 100644 app/controllers/admin/rdap_privilege_grants_controller.rb create mode 100644 app/models/version/rdap_privilege_grant_version.rb create mode 100644 app/views/admin/rdap_privilege_grants/_form.haml create mode 100644 app/views/admin/rdap_privilege_grants/edit.haml create mode 100644 app/views/admin/rdap_privilege_grants/index.haml create mode 100644 app/views/admin/rdap_privilege_grants/new.haml create mode 100644 app/views/admin/rdap_privilege_grants/show.haml create mode 100644 config/locales/admin/rdap_privilege_grants.en.yml create mode 100644 db/migrate/20260713120000_add_rpd9_fields_to_rdap_privilege_grants.rb create mode 100644 db/migrate/20260713120001_create_rdap_privilege_grant_log.rb create mode 100644 test/integration/admin_area/rdap_privilege_grants_test.rb 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/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_privilege_grant.rb b/app/models/rdap_privilege_grant.rb index e5178a88df..d201f5c129 100644 --- a/app/models/rdap_privilege_grant.rb +++ b/app/models/rdap_privilege_grant.rb @@ -1,4 +1,6 @@ class RdapPrivilegeGrant < ApplicationRecord + include Versions + CATEGORIES = %w[police cert ria eis_internal].freeze STATUSES = %w[active revoked suspended].freeze @@ -8,6 +10,8 @@ class RdapPrivilegeGrant < ApplicationRecord 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): @@ -25,6 +29,21 @@ 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 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/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 584b288bc4..d37d2b7001 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -3,6 +3,9 @@ # 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] 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 @@ -23,6 +27,8 @@ police_active_newer: revoked: eeid_subject: EE10001001000 + full_name: Revoked Grantee + legal_basis_ref: MoU-2026-003 category: police organization: police status: revoked @@ -32,6 +38,8 @@ revoked: suspended: eeid_subject: EE10001001001 + full_name: Suspended Grantee + legal_basis_ref: MoU-2026-004 category: cert organization: cert status: suspended @@ -41,6 +49,8 @@ suspended: expired: eeid_subject: EE10001001002 + full_name: Expired Grantee + legal_basis_ref: MoU-2026-005 category: ria organization: ria status: active @@ -50,6 +60,8 @@ expired: not_yet_valid: eeid_subject: EE10001001003 + full_name: Future Grantee + legal_basis_ref: MoU-2026-006 category: eis_internal organization: eis status: active 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..0f60677ff8 --- /dev/null +++ b/test/integration/admin_area/rdap_privilege_grants_test.rb @@ -0,0 +1,193 @@ +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 + + # --- 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/grants_test.rb b/test/integration/api/v1/internal/rdap/grants_test.rb index 2dae3ca928..de84de3512 100644 --- a/test/integration/api/v1/internal/rdap/grants_test.rb +++ b/test/integration/api/v1/internal/rdap/grants_test.rb @@ -26,6 +26,25 @@ def test_returns_active_grant # 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 diff --git a/test/models/rdap_privilege_grant_test.rb b/test/models/rdap_privilege_grant_test.rb index f32338b51c..2e5a11d851 100644 --- a/test/models/rdap_privilege_grant_test.rb +++ b/test/models/rdap_privilege_grant_test.rb @@ -11,6 +11,50 @@ def test_requires_eeid_subject 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| @@ -63,6 +107,8 @@ def test_active_for_subject_orders_latest_valid_from_first 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, From 5726937970aa55582a53c6b7c532ed5eea4ecdb8 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 13 Jul 2026 15:30:08 +0300 Subject: [PATCH 2/2] test(rdap-admin): assert personal_id_code never renders on show / audit history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the AC23 gap the tester found: the show page's paper_trail audit table is built from object_changes, a potential PII vector. The view already does object_changes.except('personal_id_code'); this test proves it — creates a grant with the code, makes an audit-generating change, asserts the value never appears on the rendered show page. 43 runs / 140 assertions / 0 failures via docker exec docker-images-registry-1. --- .../admin_area/rdap_privilege_grants_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/integration/admin_area/rdap_privilege_grants_test.rb b/test/integration/admin_area/rdap_privilege_grants_test.rb index 0f60677ff8..8e9d0d06d3 100644 --- a/test/integration/admin_area/rdap_privilege_grants_test.rb +++ b/test/integration/admin_area/rdap_privilege_grants_test.rb @@ -156,6 +156,22 @@ def test_personal_id_code_absent_from_index 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,