From eda8c958ef08ea08d135d7e7581cd27d8b112e23 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 25 Jun 2026 13:59:06 +0300 Subject: [PATCH 01/15] doc(api): add internal RDAP data API contract (registry-side spec) The .ee RDAP service must stop reading the registry DB directly and instead consume a registry-side API (lead decision 2026-06-25). This adds the contract + implementation guide for that internal API: - GET /api/v1/internal/rdap/domains/:name (full privileged domain) - GET /api/v1/internal/rdap/registrars/:code (code,name,phone,website) - GET /api/v1/internal/rdap/nameservers/:host (hostname,hostname_puny) - GET /api/v1/internal/rdap/grants/active?subject= (net-new grant store) Authoritative spec lives in the RDAP repo (specs/specs/pending/08-registry-side-rdap-api). No code yet; doc only. Branch off master for review; do not merge to master. --- doc/api/v1/rdap.md | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 doc/api/v1/rdap.md diff --git a/doc/api/v1/rdap.md b/doc/api/v1/rdap.md new file mode 100644 index 0000000000..ac84228335 --- /dev/null +++ b/doc/api/v1/rdap.md @@ -0,0 +1,144 @@ +# Internal RDAP data API — registry-side contract & implementation guide + +> **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.** Choose an existing pattern: HTTP Basic + `ApiUser` + + IP-allowlist (`accreditation_center/base_controller.rb:41-49,69-85`), pre-shared key + (`api/v1/base_controller.rb:14-16`), and/or mTLS client-cert (`repp/v1/base_controller.rb:158-177`, + `api_user.rb#pki_ok?`). mTLS is the recommended production hardening. + +## 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.** + +## Open questions (confirm with the RDAP team / lead — see RDAP requirements.txt §5) +- **Q1** auth baseline (Basic+ApiUser / shared-key / mTLS; is mTLS required in prod?). +- **Q2** namespace/URL (`/api/v1/internal/rdap/*` ok?). +- **Q3** admin CRUD UI in this spec, or model+API now + UI fast-follow? +- **Q4** confirm categories (police/cert/ria/eis_internal), states (active/revoked/suspended), and the + exact "active" rule; any four-eyes/approval on grant creation? +- **Q5** keep or drop the optional grant `touch` write? +- **Q6** send `delete_date` raw or effective-min; return the disclosure union or both arrays separately? From e1a20f21e96d6bd984f803f40b88513e6e2618cb Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 25 Jun 2026 14:58:05 +0300 Subject: [PATCH 02/15] doc(api): sync RDAP API contract with resolved decisions Auth = pre-shared key + IP-allowlist; backend-only (no admin UI this spec); categories/states confirmed; defaults for namespace/touch/delete-date/disclosure. --- doc/api/v1/rdap.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/doc/api/v1/rdap.md b/doc/api/v1/rdap.md index ac84228335..c9eb98fe17 100644 --- a/doc/api/v1/rdap.md +++ b/doc/api/v1/rdap.md @@ -43,10 +43,12 @@ on the existing internal peer API `app/controllers/api/v1/accreditation_center/` (`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.** Choose an existing pattern: HTTP Basic + `ApiUser` + - IP-allowlist (`accreditation_center/base_controller.rb:41-49,69-85`), pre-shared key - (`api/v1/base_controller.rb:14-16`), and/or mTLS client-cert (`repp/v1/base_controller.rb:158-177`, - `api_user.rb#pki_ok?`). mTLS is the recommended production hardening. +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 @@ -134,11 +136,13 @@ Server computes "active" authoritatively: `status='active'` AND `valid_from<=now tests modeled on `test/integration/repp/v1/base_test.rb`. 8. Open a PR for human review. **Do not merge to master.** -## Open questions (confirm with the RDAP team / lead — see RDAP requirements.txt §5) -- **Q1** auth baseline (Basic+ApiUser / shared-key / mTLS; is mTLS required in prod?). -- **Q2** namespace/URL (`/api/v1/internal/rdap/*` ok?). -- **Q3** admin CRUD UI in this spec, or model+API now + UI fast-follow? -- **Q4** confirm categories (police/cert/ria/eis_internal), states (active/revoked/suspended), and the - exact "active" rule; any four-eyes/approval on grant creation? -- **Q5** keep or drop the optional grant `touch` write? -- **Q6** send `delete_date` raw or effective-min; return the disclosure union or both arrays separately? +## 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. From 5dffa0ad1dec339fd0f16c320df181ee3f87e2c2 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 25 Jun 2026 15:15:09 +0300 Subject: [PATCH 03/15] feat(api): add internal RDAP data API (backend only) New machine-to-machine JSON API consumed by the public RDAP service, so RDAP no longer reads the registry's normalized DB directly. Namespace /api/v1/internal/rdap/*, auth = pre-shared key + IP-allowlist. Endpoints: - GET domains/:name full privileged domain (PII + glue + DNSSEC) - GET registrars/:code narrow entity {code,name,phone,website} - GET nameservers/:host thin {hostname,hostname_puny}, DISTINCT-collapsed - GET grants/active the single authoritative-active privilege grant - POST grants/:id/touch best-effort last_used_at (204) Net-new rdap_privilege_grants table + model (active_for_subject resolution, fail-closed). Secrets-free Serializers::Rdap::Domain (never emits transfer_code/ auth_info/registrant_verification_token). minitest integration + model tests. Backend only: no admin UI (grants seeded via fixtures/console per the spec). --- .../api/v1/internal/base_controller.rb | 58 +++++++++ .../v1/internal/rdap/domains_controller.rb | 26 ++++ .../api/v1/internal/rdap/grants_controller.rb | 60 +++++++++ .../internal/rdap/nameservers_controller.rb | 29 +++++ .../v1/internal/rdap/registrars_controller.rb | 27 ++++ app/models/rdap_privilege_grant.rb | 40 ++++++ config/application.yml.sample | 6 + config/routes.rb | 10 ++ ...0625120000_create_rdap_privilege_grants.rb | 23 ++++ lib/serializers/rdap/domain.rb | 115 ++++++++++++++++++ test/fixtures/rdap_privilege_grants.yml | 58 +++++++++ .../api/v1/internal/rdap/domains_test.rb | 93 ++++++++++++++ .../api/v1/internal/rdap/grants_test.rb | 93 ++++++++++++++ .../api/v1/internal/rdap/nameservers_test.rb | 60 +++++++++ .../api/v1/internal/rdap/registrars_test.rb | 59 +++++++++ test/models/rdap_privilege_grant_test.rb | 75 ++++++++++++ 16 files changed, 832 insertions(+) create mode 100644 app/controllers/api/v1/internal/base_controller.rb create mode 100644 app/controllers/api/v1/internal/rdap/domains_controller.rb create mode 100644 app/controllers/api/v1/internal/rdap/grants_controller.rb create mode 100644 app/controllers/api/v1/internal/rdap/nameservers_controller.rb create mode 100644 app/controllers/api/v1/internal/rdap/registrars_controller.rb create mode 100644 app/models/rdap_privilege_grant.rb create mode 100644 db/migrate/20260625120000_create_rdap_privilege_grants.rb create mode 100644 lib/serializers/rdap/domain.rb create mode 100644 test/fixtures/rdap_privilege_grants.yml create mode 100644 test/integration/api/v1/internal/rdap/domains_test.rb create mode 100644 test/integration/api/v1/internal/rdap/grants_test.rb create mode 100644 test/integration/api/v1/internal/rdap/nameservers_test.rb create mode 100644 test/integration/api/v1/internal/rdap/registrars_test.rb create mode 100644 test/models/rdap_privilege_grant_test.rb 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..bde8ee4cce --- /dev/null +++ b/app/controllers/api/v1/internal/base_controller.rb @@ -0,0 +1,58 @@ +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 + expected = "Basic #{ENV['rdap_internal_api_shared_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/models/rdap_privilege_grant.rb b/app/models/rdap_privilege_grant.rb new file mode 100644 index 0000000000..e5178a88df --- /dev/null +++ b/app/models/rdap_privilege_grant.rb @@ -0,0 +1,40 @@ +class RdapPrivilegeGrant < ApplicationRecord + 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 + 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 + + 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/config/application.yml.sample b/config/application.yml.sample index 12da2f8e60..75d24edcba 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' diff --git a/config/routes.rb b/config/routes.rb index 05af94cda3..8d7f8e24ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -223,6 +223,16 @@ get 'auth', to: 'auth#index' end + namespace :internal do + namespace :rdap do + get 'domains/:name', to: 'domains#show', constraints: { name: /[^\/]+/ } + get 'registrars/:code', to: 'registrars#show' + get 'nameservers/:host', to: 'nameservers#show', constraints: { host: /[^\/]+/ } + get 'grants/active', to: 'grants#active' + post 'grants/:id/touch', to: 'grants#touch' + end + end + resources :auctions, only: %i[index show update], param: :uuid resources :contact_requests, only: %i[create update], param: :id resources :bounces, only: %i[create] diff --git a/db/migrate/20260625120000_create_rdap_privilege_grants.rb b/db/migrate/20260625120000_create_rdap_privilege_grants.rb new file mode 100644 index 0000000000..8ff34f008d --- /dev/null +++ b/db/migrate/20260625120000_create_rdap_privilege_grants.rb @@ -0,0 +1,23 @@ +class CreateRdapPrivilegeGrants < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + create_table :rdap_privilege_grants do |t| + t.string :eeid_subject, null: false + t.string :category, null: false + t.string :organization + t.string :status, null: false, default: 'active' + t.datetime :valid_from, null: false + t.datetime :valid_until + t.datetime :last_used_at + t.string :uuid + t.string :notes + + t.timestamps + end + + add_index :rdap_privilege_grants, %i[eeid_subject status], algorithm: :concurrently + add_index :rdap_privilege_grants, :valid_until, algorithm: :concurrently + add_index :rdap_privilege_grants, :uuid, unique: true, algorithm: :concurrently + end +end 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_privilege_grants.yml b/test/fixtures/rdap_privilege_grants.yml new file mode 100644 index 0000000000..382a7d0e87 --- /dev/null +++ b/test/fixtures/rdap_privilege_grants.yml @@ -0,0 +1,58 @@ +# 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 + 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 + 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 + 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 + 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 + 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 + 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/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..2dae3ca928 --- /dev/null +++ b/test/integration/api/v1/internal/rdap/grants_test.rb @@ -0,0 +1,93 @@ +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). + 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..a1917af9b0 --- /dev/null +++ b/test/integration/api/v1/internal/rdap/registrars_test.rb @@ -0,0 +1,59 @@ +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 + assert_equal 'BESTNAMES', json[:code] + assert_equal @registrar.name, json[:name] + assert_equal @registrar.phone, json[:phone] + assert_equal @registrar.website, json[:website] + 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/models/rdap_privilege_grant_test.rb b/test/models/rdap_privilege_grant_test.rb new file mode 100644 index 0000000000..f32338b51c --- /dev/null +++ b/test/models/rdap_privilege_grant_test.rb @@ -0,0 +1,75 @@ +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_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', + category: 'police', + status: 'active', + valid_from: 1.day.ago, + }.merge(attrs)) + end + + def create_grant(attrs = {}) + build_grant(attrs).tap(&:save!) + end +end From 1f6723947ae99a19675493e9d4f4b9ac6a313865 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 25 Jun 2026 15:17:00 +0300 Subject: [PATCH 04/15] fix(api): fail closed when rdap_internal_api_shared_key is unset Without this, a blank/unset shared key makes expected = 'Basic ' and any caller sending an empty Basic credential would authenticate. Reject when the key is not configured. --- app/controllers/api/v1/internal/base_controller.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/internal/base_controller.rb b/app/controllers/api/v1/internal/base_controller.rb index bde8ee4cce..5641dc2cef 100644 --- a/app/controllers/api/v1/internal/base_controller.rb +++ b/app/controllers/api/v1/internal/base_controller.rb @@ -16,7 +16,12 @@ class BaseController < ActionController::API private def authenticate_shared_key - expected = "Basic #{ENV['rdap_internal_api_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) From 2550f301d91f96f1d29c71a9efe68129589c2413 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 26 Jun 2026 13:02:56 +0300 Subject: [PATCH 05/15] feat(api): add rdap_privilege_grants to structure.sql; tighten registrar test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit structure.sql: only the new rdap_privilege_grants table (CREATE TABLE + sequence + pkey + 3 indexes) and its schema_migrations version — no unrelated churn (the noisy full pg_dump regen was discarded). Validated: db:test:prepare reloads it from scratch and the suite is green (36 runs, 101 assertions, 0F/0E). registrar happy-path test now asserts full-hash equality (proves the exact narrow 4-key shape, drops the assert_nil deprecation). --- db/structure.sql | 78 ++++++++++++++++++- .../api/v1/internal/rdap/registrars_test.rb | 11 ++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/db/structure.sql b/db/structure.sql index f9fcd64f8c..29f4d51a63 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -5351,6 +5351,81 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT user_registrar_id_fk FOREIGN KEY (registrar_id) REFERENCES public.registrars(id); +-- +-- Name: rdap_privilege_grants; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rdap_privilege_grants ( + id bigint NOT NULL, + eeid_subject character varying NOT NULL, + category character varying NOT NULL, + organization character varying, + status character varying DEFAULT 'active'::character varying NOT NULL, + valid_from timestamp without time zone NOT NULL, + valid_until timestamp without time zone, + last_used_at timestamp without time zone, + uuid character varying, + notes character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rdap_privilege_grants_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rdap_privilege_grants_id_seq OWNED BY public.rdap_privilege_grants.id; + + +-- +-- Name: rdap_privilege_grants id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_privilege_grants ALTER COLUMN id SET DEFAULT nextval('public.rdap_privilege_grants_id_seq'::regclass); + + +-- +-- Name: rdap_privilege_grants rdap_privilege_grants_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_privilege_grants + ADD CONSTRAINT rdap_privilege_grants_pkey PRIMARY KEY (id); + + +-- +-- Name: index_rdap_privilege_grants_on_eeid_subject_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_privilege_grants_on_eeid_subject_and_status ON public.rdap_privilege_grants USING btree (eeid_subject, status); + + +-- +-- Name: index_rdap_privilege_grants_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_rdap_privilege_grants_on_uuid ON public.rdap_privilege_grants USING btree (uuid); + + +-- +-- Name: index_rdap_privilege_grants_on_valid_until; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_privilege_grants_on_valid_until ON public.rdap_privilege_grants USING btree (valid_until); + + -- -- PostgreSQL database dump complete -- @@ -5863,6 +5938,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260406125446'), ('20260529120000'), ('20260601120000'), -('20260608120000'); +('20260608120000'), +('20260625120000'); diff --git a/test/integration/api/v1/internal/rdap/registrars_test.rb b/test/integration/api/v1/internal/rdap/registrars_test.rb index a1917af9b0..0d69fbed3c 100644 --- a/test/integration/api/v1/internal/rdap/registrars_test.rb +++ b/test/integration/api/v1/internal/rdap/registrars_test.rb @@ -23,10 +23,13 @@ def test_returns_registrar_narrow_shape json = JSON.parse(response.body, symbolize_names: true) assert_response :ok - assert_equal 'BESTNAMES', json[:code] - assert_equal @registrar.name, json[:name] - assert_equal @registrar.phone, json[:phone] - assert_equal @registrar.website, json[:website] + # 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 From 425070258c6442d8fa926d28daf669ec2674cf70 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Tue, 7 Jul 2026 11:14:25 +0300 Subject: [PATCH 06/15] feat(rdap-internal-api): registry-side store for RDAP-issued API tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the RDAP spec 10-rdap-issued-api-token. RDAP owns no database, so the opaque API tokens it mints persist here and are reached over the internal RDAP data API — the same seam that already serves domains, registrars, nameservers and privilege grants. Implements the six pinned token operations RDAP's registry client calls, all keyed by the token_hash (RDAP's keyed HMAC-SHA-256 digest of the raw token; the raw token and the HMAC secret never reach the registry): POST /api/v1/internal/rdap/tokens create (store hash + metadata) GET /api/v1/internal/rdap/tokens/active find active by hash (404 if revoked/expired/unknown — no caller-visible distinction) GET /api/v1/internal/rdap/tokens?subject= list a subject's tokens POST /api/v1/internal/rdap/tokens/revoke revoke one (idempotent, 204) POST /api/v1/internal/rdap/tokens/revoke_all kill-all-by-subject (-> count) POST /api/v1/internal/rdap/tokens/touch best-effort last_used_at only - rdap_api_tokens table (token_hash unique, subject+revoked_at index); no privilege field — authorization stays 100% grant-driven. Migration + hand-spliced structure.sql (schema_format :sql). - Model RdapApiToken: active_by_hash / for_subject scopes, idempotent revoke!, touch_last_used! that never moves expires_at (no sliding renewal). - Fix (existing): add subject + token_hash to filter_parameters — the grants controller already said 'do not log the subject' (a national id) but nothing enforced it; now both are filtered out of logs. - Tests: model + integration (24 new; full internal/rdap suite 60 runs / 158 assertions / 0 failures via docker-images-registry-1). Contract mirrors the RDAP-side interface (Rdap::Registry::Client TOKEN_OPERATIONS + Token value object) so the RDAP RealClient token methods drop straight onto it. --- .../api/v1/internal/rdap/tokens_controller.rb | 113 +++++++++++++ app/models/rdap_api_token.rb | 46 +++++ .../initializers/filter_parameter_logging.rb | 3 + config/routes.rb | 6 + .../20260707120000_create_rdap_api_tokens.rb | 25 +++ db/structure.sql | 70 +++++++- test/fixtures/rdap_api_tokens.yml | 42 +++++ .../api/v1/internal/rdap/tokens_test.rb | 158 ++++++++++++++++++ test/models/rdap_api_token_test.rb | 82 +++++++++ 9 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 app/controllers/api/v1/internal/rdap/tokens_controller.rb create mode 100644 app/models/rdap_api_token.rb create mode 100644 db/migrate/20260707120000_create_rdap_api_tokens.rb create mode 100644 test/fixtures/rdap_api_tokens.yml create mode 100644 test/integration/api/v1/internal/rdap/tokens_test.rb create mode 100644 test/models/rdap_api_token_test.rb 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/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/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index 4f4e682889..584b288bc4 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,5 +1,8 @@ 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] config.filter_parameters << lambda do |key, value| if key == 'raw_frame' && value.respond_to?(:gsub!) value.gsub!(/pw>.+<\//, 'pw>[FILTERED] + 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/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 From 9dae4a3aa0c7adfde4fa9234e63b1100ae8840ec Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 9 Jul 2026 14:48:19 +0300 Subject: [PATCH 07/15] added api token generator --- config/application.yml.sample | 5 +- db/structure.sql | 334 +++++++++++++++++----------------- 2 files changed, 164 insertions(+), 175 deletions(-) diff --git a/config/application.yml.sample b/config/application.yml.sample index 75d24edcba..edd5dfe914 100644 --- a/config/application.yml.sample +++ b/config/application.yml.sample @@ -275,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/db/structure.sql b/db/structure.sql index 4f6b326623..451844b93b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,7 +1,7 @@ -\restrict WmlyozFAnc1c6zHWXudb7s2jRC1uKwHPlCMDikRGHsbNPX1TGBaq3KQ01YXVO8T +\restrict Kw0FlFjOo7AUyExjfwpZoGQ0hCDF4IfnRBjP2hurV0W6vKi33me7sPJaQhQwWkF -- Dumped from database version 13.4 (Debian 13.4-4.pgdg110+1) --- Dumped by pg_dump version 13.23 (Debian 13.23-1.pgdg11+1) +-- Dumped by pg_dump version 13.23 (Debian 13.23-0+deb11u4) SET statement_timeout = 0; SET lock_timeout = 0; @@ -733,11 +733,11 @@ CREATE TABLE public.contacts ( disclosed_attributes character varying[] DEFAULT '{}'::character varying[] NOT NULL, email_history character varying, registrant_publishable boolean DEFAULT false, + checked_company_at timestamp without time zone, + company_register_status character varying, ident_request_sent_at timestamp without time zone, verified_at timestamp without time zone, verification_id character varying, - checked_company_at timestamp without time zone, - company_register_status character varying, system_disclosed_attributes character varying[] DEFAULT '{}'::character varying[], verification_pending_at timestamp without time zone, verification_snapshot jsonb DEFAULT '{}'::jsonb @@ -2574,6 +2574,83 @@ CREATE SEQUENCE public.que_jobs_job_id_seq ALTER SEQUENCE public.que_jobs_job_id_seq OWNED BY public.que_jobs.job_id; +-- +-- Name: rdap_api_tokens; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rdap_api_tokens ( + id bigint NOT NULL, + token_hash character varying NOT NULL, + subject character varying NOT NULL, + token_class character varying NOT NULL, + label character varying, + issued_at timestamp without time zone NOT NULL, + expires_at timestamp without time zone NOT NULL, + last_used_at timestamp without time zone, + revoked_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: rdap_api_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rdap_api_tokens_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rdap_api_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rdap_api_tokens_id_seq OWNED BY public.rdap_api_tokens.id; + + +-- +-- Name: rdap_privilege_grants; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rdap_privilege_grants ( + id bigint NOT NULL, + eeid_subject character varying NOT NULL, + category character varying NOT NULL, + organization character varying, + status character varying DEFAULT 'active'::character varying NOT NULL, + valid_from timestamp without time zone NOT NULL, + valid_until timestamp without time zone, + last_used_at timestamp without time zone, + uuid character varying, + notes character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rdap_privilege_grants_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rdap_privilege_grants_id_seq OWNED BY public.rdap_privilege_grants.id; + + -- -- Name: registrant_verifications; Type: TABLE; Schema: public; Owner: - -- @@ -2650,9 +2727,9 @@ CREATE TABLE public.registrars ( legaldoc_optout boolean DEFAULT false NOT NULL, legaldoc_optout_comment text, email_history character varying, + accept_pdf_invoices boolean DEFAULT true, accreditation_date timestamp without time zone, - accreditation_expire_date timestamp without time zone, - accept_pdf_invoices boolean DEFAULT true + accreditation_expire_date timestamp without time zone ); @@ -2675,25 +2752,6 @@ CREATE SEQUENCE public.registrars_id_seq ALTER SEQUENCE public.registrars_id_seq OWNED BY public.registrars.id; --- --- Name: reports_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.reports_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: reports_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.reports_id_seq OWNED BY public.admin_reports.id; - - -- -- Name: repp_logs; Type: TABLE; Schema: public; Owner: - -- @@ -2780,7 +2838,9 @@ CREATE TABLE public.reserved_domains ( legacy_id integer, name character varying NOT NULL, password character varying NOT NULL, - expire_at timestamp without time zone + expire_at timestamp without time zone, + access_token character varying, + token_created_at timestamp without time zone ); @@ -3153,7 +3213,7 @@ ALTER TABLE ONLY public.actions ALTER COLUMN id SET DEFAULT nextval('public.acti -- Name: admin_reports id; Type: DEFAULT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.admin_reports ALTER COLUMN id SET DEFAULT nextval('public.reports_id_seq'::regclass); +ALTER TABLE ONLY public.admin_reports ALTER COLUMN id SET DEFAULT nextval('public.admin_reports_id_seq'::regclass); -- @@ -3548,6 +3608,20 @@ ALTER TABLE ONLY public.prices ALTER COLUMN id SET DEFAULT nextval('public.price ALTER TABLE ONLY public.que_jobs ALTER COLUMN job_id SET DEFAULT nextval('public.que_jobs_job_id_seq'::regclass); +-- +-- Name: rdap_api_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_api_tokens ALTER COLUMN id SET DEFAULT nextval('public.rdap_api_tokens_id_seq'::regclass); + + +-- +-- Name: rdap_privilege_grants id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_privilege_grants ALTER COLUMN id SET DEFAULT nextval('public.rdap_privilege_grants_id_seq'::regclass); + + -- -- Name: registrant_verifications id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4127,6 +4201,22 @@ ALTER TABLE ONLY public.que_jobs ADD CONSTRAINT que_jobs_pkey PRIMARY KEY (queue, priority, run_at, job_id); +-- +-- Name: rdap_api_tokens rdap_api_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_api_tokens + ADD CONSTRAINT rdap_api_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: rdap_privilege_grants rdap_privilege_grants_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_privilege_grants + ADD CONSTRAINT rdap_privilege_grants_pkey PRIMARY KEY (id); + + -- -- Name: registrant_verifications registrant_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4960,6 +5050,41 @@ CREATE INDEX index_pghero_space_stats_on_database_and_captured_at ON public.pghe CREATE INDEX index_prices_on_zone_id ON public.prices USING btree (zone_id); +-- +-- Name: index_rdap_api_tokens_on_subject_and_revoked_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_api_tokens_on_subject_and_revoked_at ON public.rdap_api_tokens USING btree (subject, revoked_at); + + +-- +-- Name: index_rdap_api_tokens_on_token_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_rdap_api_tokens_on_token_hash ON public.rdap_api_tokens USING btree (token_hash); + + +-- +-- Name: index_rdap_privilege_grants_on_eeid_subject_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_privilege_grants_on_eeid_subject_and_status ON public.rdap_privilege_grants USING btree (eeid_subject, status); + + +-- +-- Name: index_rdap_privilege_grants_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_rdap_privilege_grants_on_uuid ON public.rdap_privilege_grants USING btree (uuid); + + +-- +-- Name: index_rdap_privilege_grants_on_valid_until; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_privilege_grants_on_valid_until ON public.rdap_privilege_grants USING btree (valid_until); + + -- -- Name: index_registrant_verifications_on_created_at; Type: INDEX; Schema: public; Owner: - -- @@ -5351,157 +5476,16 @@ ALTER TABLE ONLY public.users ADD CONSTRAINT user_registrar_id_fk FOREIGN KEY (registrar_id) REFERENCES public.registrars(id); --- --- Name: rdap_privilege_grants; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.rdap_privilege_grants ( - id bigint NOT NULL, - eeid_subject character varying NOT NULL, - category character varying NOT NULL, - organization character varying, - status character varying DEFAULT 'active'::character varying NOT NULL, - valid_from timestamp without time zone NOT NULL, - valid_until timestamp without time zone, - last_used_at timestamp without time zone, - uuid character varying, - notes character varying, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL -); - - --- --- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.rdap_privilege_grants_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: rdap_privilege_grants_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.rdap_privilege_grants_id_seq OWNED BY public.rdap_privilege_grants.id; - - --- --- Name: rdap_privilege_grants id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.rdap_privilege_grants ALTER COLUMN id SET DEFAULT nextval('public.rdap_privilege_grants_id_seq'::regclass); - - --- --- Name: rdap_privilege_grants rdap_privilege_grants_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.rdap_privilege_grants - ADD CONSTRAINT rdap_privilege_grants_pkey PRIMARY KEY (id); - - --- --- Name: index_rdap_privilege_grants_on_eeid_subject_and_status; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_rdap_privilege_grants_on_eeid_subject_and_status ON public.rdap_privilege_grants USING btree (eeid_subject, status); - - --- --- Name: index_rdap_privilege_grants_on_uuid; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_rdap_privilege_grants_on_uuid ON public.rdap_privilege_grants USING btree (uuid); - - --- --- Name: index_rdap_privilege_grants_on_valid_until; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_rdap_privilege_grants_on_valid_until ON public.rdap_privilege_grants USING btree (valid_until); - - --- --- Name: rdap_api_tokens; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.rdap_api_tokens ( - id bigint NOT NULL, - token_hash character varying NOT NULL, - subject character varying NOT NULL, - token_class character varying NOT NULL, - label character varying, - issued_at timestamp without time zone NOT NULL, - expires_at timestamp without time zone NOT NULL, - last_used_at timestamp without time zone, - revoked_at timestamp without time zone, - created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL -); - - --- --- Name: rdap_api_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.rdap_api_tokens_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: rdap_api_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.rdap_api_tokens_id_seq OWNED BY public.rdap_api_tokens.id; - - --- --- Name: rdap_api_tokens id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.rdap_api_tokens ALTER COLUMN id SET DEFAULT nextval('public.rdap_api_tokens_id_seq'::regclass); - - --- --- Name: rdap_api_tokens rdap_api_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.rdap_api_tokens - ADD CONSTRAINT rdap_api_tokens_pkey PRIMARY KEY (id); - - --- --- Name: index_rdap_api_tokens_on_subject_and_revoked_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_rdap_api_tokens_on_subject_and_revoked_at ON public.rdap_api_tokens USING btree (subject, revoked_at); - - --- --- Name: index_rdap_api_tokens_on_token_hash; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_rdap_api_tokens_on_token_hash ON public.rdap_api_tokens USING btree (token_hash); - - -- -- PostgreSQL database dump complete -- -\unrestrict WmlyozFAnc1c6zHWXudb7s2jRC1uKwHPlCMDikRGHsbNPX1TGBaq3KQ01YXVO8T +\unrestrict Kw0FlFjOo7AUyExjfwpZoGQ0hCDF4IfnRBjP2hurV0W6vKi33me7sPJaQhQwWkF SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('0'), ('20140616073945'), ('20140620130107'), ('20140627082711'), @@ -5980,14 +5964,19 @@ INSERT INTO "schema_migrations" (version) VALUES ('20221214073933'), ('20221214074252'), ('20230531111154'), +('20230612094319'), +('20230612094326'), +('20230612094335'), ('20230707084741'), ('20230710120154'), ('20230711083811'), +('20240722085530'), +('20240723110208'), ('20240816091049'), ('20240816092636'), -('20240903131540'), ('20240924103554'), ('20241015071505'), +('20241022121525'), ('20241030095636'), ('20241104104620'), ('20241112093540'), @@ -5998,11 +5987,14 @@ INSERT INTO "schema_migrations" (version) VALUES ('20250219102811'), ('20250310133151'), ('20250313122119'), +('20250314133357'), ('20250319104749'), ('20250627084536'), ('20251230104312'), ('20260220111500'), ('20260406125446'), +('20260410130124'), +('20260413125925'), ('20260529120000'), ('20260601120000'), ('20260608120000'), From b5d7a2030adb72c32a755d0f71857496976b56f7 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 13 Jul 2026 15:01:09 +0300 Subject: [PATCH 08/15] =?UTF-8?q?Add=20privileged=20RDAP=20grant=20adminis?= =?UTF-8?q?tration=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 09/15] 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, From 3111704bf7cec6e513d02fbe861b8293be08a313 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 14:31:43 +0300 Subject: [PATCH 10/15] feat(rdap): registry-side privileged access-event store + write endpoint (spec 11) Add an insert-only rdap_access_events table plus the internal write endpoint POST /api/v1/internal/rdap/access-events for recording privileged (result_code 200) RDAP disclosures. - Migration: bigint PK, timestamptz requested_at, snapshot columns (organization_name/accessor_name/category/grant_ref), nullable request_id, explicit created_at (no updated_at); indexes on [domain_name, requested_at], [grant_ref, requested_at], [requested_at]; no FK, no request_id index. - Model RdapAccessEvent: presence validations, create-only immutability (readonly? => !new_record? + before_destroy raise); NOT a paper_trail model. - Controller: three-way outcome (404 unresolved grant / 422 non-200 result_code or unparseable requested_at or validation-false / 204 success) plus an explicit in-action rescue for the 500 path that logs + increments a New Relic failure metric. Grant resolved server-side by uuid-or-id; snapshots taken at write time; eeid_subject/personal_id_code never read or stored. - Route: single explicit post line inside namespace :internal / :rdap. - Tests: model (presence, immutability, no Versions, nullable organization) and integration (AC5-AC17) following grants_test.rb; new fixtures. --- .../internal/rdap/access_events_controller.rb | 64 +++++ app/models/rdap_access_event.rb | 26 ++ config/routes.rb | 1 + ...0260716120000_create_rdap_access_events.rb | 22 ++ db/structure.sql | 81 +++++- test/fixtures/rdap_access_events.yml | 26 ++ test/fixtures/rdap_privilege_grants.yml | 27 ++ .../v1/internal/rdap/access_events_test.rb | 231 ++++++++++++++++++ test/models/rdap_access_event_test.rb | 96 ++++++++ 9 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 app/controllers/api/v1/internal/rdap/access_events_controller.rb create mode 100644 app/models/rdap_access_event.rb create mode 100644 db/migrate/20260716120000_create_rdap_access_events.rb create mode 100644 test/fixtures/rdap_access_events.yml create mode 100644 test/integration/api/v1/internal/rdap/access_events_test.rb create mode 100644 test/models/rdap_access_event_test.rb diff --git a/app/controllers/api/v1/internal/rdap/access_events_controller.rb b/app/controllers/api/v1/internal/rdap/access_events_controller.rb new file mode 100644 index 0000000000..1622dbe70f --- /dev/null +++ b/app/controllers/api/v1/internal/rdap/access_events_controller.rb @@ -0,0 +1,64 @@ +module Api + module V1 + module Internal + module Rdap + # Registry-side store for PRIVILEGED RDAP access events (RDAP spec 11). + # RDAP owns no database; on a real (result_code 200) privileged disclosure + # it POSTs the non-PII facts here and the registry snapshots the resolved + # grant into an insert-only row. The eeID subject / personal id code are + # sensitive PII and MUST NEVER be read or stored here. + class AccessEventsController < BaseController + # POST /api/v1/internal/rdap/access-events + # Body: grant_id, domain_name, requested_at, caller_ip, result_code, + # request_id (optional). Returns 204 on success. + def create + grant = RdapPrivilegeGrant.find_by(uuid: params[:grant_id]) || + RdapPrivilegeGrant.find_by(id: params[:grant_id]) + return render_error('Grant not found', :not_found) unless grant + return render_error('result_code must be 200', :unprocessable_entity) if params[:result_code].to_i != 200 + + requested_at = parse_time(params[:requested_at]) + return render_error('requested_at is invalid', :unprocessable_entity) if requested_at.nil? + + event = RdapAccessEvent.new( + requested_at: requested_at, + domain_name: params[:domain_name], + caller_ip: params[:caller_ip], + result_code: params[:result_code], + organization_name: grant.organization, + accessor_name: grant.full_name, + category: grant.category, + grant_ref: grant.grant_id, + request_id: params[:request_id].presence + ) + + if event.save + head :no_content + else + render_error(event.errors.full_messages.join(', '), :unprocessable_entity) + end + rescue StandardError => e + # Unexpected persistence error ONLY. The explicit rescue is required so + # the technical-log error + failure metric run before returning 500 — + # BaseController's rescue_from would render 500 but skip the telemetry. + # NEVER log eeid_subject / personal_id_code. + Rails.logger.error("[rdap_access_event] record failed domain=#{params[:domain_name]} " \ + "grant_ref=#{grant&.grant_id} error=#{e.class}: #{e.message}") + NewRelic::Agent.increment_metric('Custom/Rdap/access_event_record_failure') + render_error('Access event could not be recorded', :internal_server_error) + end + + private + + def parse_time(value) + return nil if value.blank? + + Time.zone.parse(value.to_s) + rescue ArgumentError + nil + end + end + end + end + end +end diff --git a/app/models/rdap_access_event.rb b/app/models/rdap_access_event.rb new file mode 100644 index 0000000000..f17bb2c76f --- /dev/null +++ b/app/models/rdap_access_event.rb @@ -0,0 +1,26 @@ +# Insert-only store of PRIVILEGED RDAP disclosures (RDAP spec 11). +# +# This table is itself the application event store — NOT a paper_trail audit of a +# mutable model. It therefore does NOT `include Versions` (no +# Version::RdapAccessEventVersion / log_rdap_access_events wiring exists or is +# wanted): there is nothing to "audit the mutations of", because rows are never +# mutated. Each row is a snapshot of the resolved grant taken at write time +# (organization/full_name/category/uuid only) so it survives a later grant edit, +# revoke, or delete. It MUST NEVER carry eeid_subject or personal_id_code (PII). +class RdapAccessEvent < ApplicationRecord + validates :requested_at, presence: true + validates :domain_name, presence: true + validates :caller_ip, presence: true + validates :result_code, presence: true + validates :accessor_name, presence: true + validates :category, presence: true + validates :grant_ref, presence: true + + before_destroy { raise ActiveRecord::ReadOnlyRecord, 'RdapAccessEvent is insert-only' } + + # Create-only immutability: a new (unsaved) record can be inserted, but any + # persisted row is read-only, so update!/save raise ActiveRecord::ReadOnlyRecord. + def readonly? + !new_record? + end +end diff --git a/config/routes.rb b/config/routes.rb index 0c96d9132e..d58d6245c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -236,6 +236,7 @@ post 'tokens/revoke_all', to: 'tokens#revoke_all' post 'tokens/revoke', to: 'tokens#revoke' post 'tokens/touch', to: 'tokens#touch' + post 'access-events', to: 'access_events#create' end end diff --git a/db/migrate/20260716120000_create_rdap_access_events.rb b/db/migrate/20260716120000_create_rdap_access_events.rb new file mode 100644 index 0000000000..e1101ff33a --- /dev/null +++ b/db/migrate/20260716120000_create_rdap_access_events.rb @@ -0,0 +1,22 @@ +class CreateRdapAccessEvents < ActiveRecord::Migration[6.1] + def change + create_table :rdap_access_events do |t| + t.column :requested_at, :timestamptz, null: false + t.string :domain_name, null: false + t.string :caller_ip, null: false + t.integer :result_code, null: false + t.string :organization_name + t.string :accessor_name, null: false + t.string :category, null: false + t.string :grant_ref, null: false + t.string :request_id + + # Insert-only event store: explicit created_at only, no updated_at. + t.datetime :created_at, null: false + end + + add_index :rdap_access_events, %i[domain_name requested_at] + add_index :rdap_access_events, %i[grant_ref requested_at] + add_index :rdap_access_events, :requested_at + end +end diff --git a/db/structure.sql b/db/structure.sql index faef4620c5..ed558d6af3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1,4 +1,4 @@ -\restrict 3yuhOd8UVvUalnyP7ubMzaEoYQt7050auMhPKG9bKziR1D2CEgysZBGz4HrmcuY +\restrict 1fCvfWjQ1zhTOaPmInGUCh1mebjUY2V6m45AIX5B6cYbXOlD6RkTGCWhgcWyQMA -- Dumped from database version 13.4 (Debian 13.4-4.pgdg110+1) -- Dumped by pg_dump version 13.23 (Debian 13.23-0+deb11u4) @@ -2612,6 +2612,44 @@ CREATE SEQUENCE public.que_jobs_job_id_seq ALTER SEQUENCE public.que_jobs_job_id_seq OWNED BY public.que_jobs.job_id; +-- +-- Name: rdap_access_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.rdap_access_events ( + id bigint NOT NULL, + requested_at timestamp with time zone NOT NULL, + domain_name character varying NOT NULL, + caller_ip character varying NOT NULL, + result_code integer NOT NULL, + organization_name character varying, + accessor_name character varying NOT NULL, + category character varying NOT NULL, + grant_ref character varying NOT NULL, + request_id character varying, + created_at timestamp without time zone NOT NULL +); + + +-- +-- Name: rdap_access_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.rdap_access_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: rdap_access_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.rdap_access_events_id_seq OWNED BY public.rdap_access_events.id; + + -- -- Name: rdap_api_tokens; Type: TABLE; Schema: public; Owner: - -- @@ -3658,6 +3696,13 @@ ALTER TABLE ONLY public.prices ALTER COLUMN id SET DEFAULT nextval('public.price ALTER TABLE ONLY public.que_jobs ALTER COLUMN job_id SET DEFAULT nextval('public.que_jobs_job_id_seq'::regclass); +-- +-- Name: rdap_access_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_access_events ALTER COLUMN id SET DEFAULT nextval('public.rdap_access_events_id_seq'::regclass); + + -- -- Name: rdap_api_tokens id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4259,6 +4304,14 @@ ALTER TABLE ONLY public.que_jobs ADD CONSTRAINT que_jobs_pkey PRIMARY KEY (queue, priority, run_at, job_id); +-- +-- Name: rdap_access_events rdap_access_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.rdap_access_events + ADD CONSTRAINT rdap_access_events_pkey PRIMARY KEY (id); + + -- -- Name: rdap_api_tokens rdap_api_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -5108,6 +5161,27 @@ CREATE INDEX index_pghero_space_stats_on_database_and_captured_at ON public.pghe CREATE INDEX index_prices_on_zone_id ON public.prices USING btree (zone_id); +-- +-- Name: index_rdap_access_events_on_domain_name_and_requested_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_access_events_on_domain_name_and_requested_at ON public.rdap_access_events USING btree (domain_name, requested_at); + + +-- +-- Name: index_rdap_access_events_on_grant_ref_and_requested_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_access_events_on_grant_ref_and_requested_at ON public.rdap_access_events USING btree (grant_ref, requested_at); + + +-- +-- Name: index_rdap_access_events_on_requested_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_rdap_access_events_on_requested_at ON public.rdap_access_events USING btree (requested_at); + + -- -- Name: index_rdap_api_tokens_on_subject_and_revoked_at; Type: INDEX; Schema: public; Owner: - -- @@ -5538,7 +5612,7 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict 3yuhOd8UVvUalnyP7ubMzaEoYQt7050auMhPKG9bKziR1D2CEgysZBGz4HrmcuY +\unrestrict 1fCvfWjQ1zhTOaPmInGUCh1mebjUY2V6m45AIX5B6cYbXOlD6RkTGCWhgcWyQMA SET search_path TO "$user", public; @@ -6059,6 +6133,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260625120000'), ('20260707120000'), ('20260713120000'), -('20260713120001'); +('20260713120001'), +('20260716120000'); diff --git a/test/fixtures/rdap_access_events.yml b/test/fixtures/rdap_access_events.yml new file mode 100644 index 0000000000..28725a80da --- /dev/null +++ b/test/fixtures/rdap_access_events.yml @@ -0,0 +1,26 @@ +# Insert-only privileged RDAP access events. grant_ref is a snapshot of the +# grant's uuid (see rdap_privilege_grants.yml), NOT a foreign key. + +police_lookup: + requested_at: <%= 2.days.ago.to_s(:db) %> + domain_name: example.ee + caller_ip: 192.0.2.10 + result_code: 200 + organization_name: police + accessor_name: Police Officer One + category: police + grant_ref: a1b2c3d4-0000-0000-0000-000000000001 + request_id: req-0001 + created_at: <%= 2.days.ago.to_s(:db) %> + +cert_lookup: + requested_at: <%= 1.day.ago.to_s(:db) %> + domain_name: another.ee + caller_ip: 192.0.2.20 + result_code: 200 + organization_name: cert_team + accessor_name: Cert Analyst Two + category: cert + grant_ref: a1b2c3d4-0000-0000-0000-000000000002 + request_id: + created_at: <%= 1.day.ago.to_s(:db) %> diff --git a/test/fixtures/rdap_privilege_grants.yml b/test/fixtures/rdap_privilege_grants.yml index 89767cb977..7088fdcaaf 100644 --- a/test/fixtures/rdap_privilege_grants.yml +++ b/test/fixtures/rdap_privilege_grants.yml @@ -68,3 +68,30 @@ not_yet_valid: valid_from: <%= 5.days.from_now.to_s(:db) %> valid_until: uuid: a1b2c3d4-0000-0000-0000-000000000006 + +# Grant with a NULL organization — the access-event snapshot must persist +# organization_name NULL (spec 11 AC8) while accessor_name/category/grant_ref stay set. +no_organization: + eeid_subject: EE10001001004 + full_name: No Org Grantee + legal_basis_ref: MoU-2026-007 + category: police + organization: + status: active + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000007 + +# Grant with eeid_subject AND personal_id_code populated — the access-event row +# must copy NEITHER into any column (spec 11 AC16 PII gate). +with_personal_id: + eeid_subject: EE49001010002 + full_name: Personal Id Grantee + legal_basis_ref: MoU-2026-008 + category: cert + organization: cert + personal_id_code: '49001010002' + status: active + valid_from: <%= 10.days.ago.to_s(:db) %> + valid_until: + uuid: a1b2c3d4-0000-0000-0000-000000000008 diff --git a/test/integration/api/v1/internal/rdap/access_events_test.rb b/test/integration/api/v1/internal/rdap/access_events_test.rb new file mode 100644 index 0000000000..729a12084a --- /dev/null +++ b/test/integration/api/v1/internal/rdap/access_events_test.rb @@ -0,0 +1,231 @@ +require 'test_helper' + +class ApiV1InternalRdapAccessEventsTest < ApplicationIntegrationTest + def setup + ENV['rdap_internal_api_shared_key'] = 'test-rdap-key' + ENV['rdap_internal_api_allowed_ips'] = '127.0.0.1,::1' + @header = { 'Authorization' => 'Basic test-rdap-key' } + @grant = rdap_privilege_grants(:police_active) + end + + def teardown + ENV.delete('rdap_internal_api_shared_key') + ENV.delete('rdap_internal_api_allowed_ips') + super + end + + # AC5 — happy path: 204 + empty body + exactly one row with posted values. + def test_create_records_event_and_returns_204 + requested_at = 3.hours.ago.change(usec: 0) + + assert_difference 'RdapAccessEvent.count', 1 do + post_event(domain_name: 'happy.ee', caller_ip: '198.51.100.5', + requested_at: requested_at.iso8601, result_code: 200) + end + + assert_response :no_content + assert_empty response.body + + event = RdapAccessEvent.last + assert_equal 'happy.ee', event.domain_name + assert_equal '198.51.100.5', event.caller_ip + assert_equal 200, event.result_code + assert_equal requested_at.to_i, event.requested_at.to_i + end + + # AC6 — route reachable at the explicit path (no resources siblings). + def test_route_reachable_at_explicit_path + assert_routing({ method: 'post', path: '/api/v1/internal/rdap/access-events' }, + { controller: 'api/v1/internal/rdap/access_events', action: 'create' }) + end + + # AC7 — grant resolved by uuid AND by id; snapshots equal the grant fixture. + def test_create_snapshots_organization_accessor_category_grant_ref_from_grant + post_event(grant_id: @grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + assert_equal @grant.organization, event.organization_name + assert_equal @grant.full_name, event.accessor_name + assert_equal @grant.category, event.category + assert_equal @grant.uuid, event.grant_ref + + # Resolve by numeric id as well. + post_event(grant_id: @grant.id) + assert_response :no_content + assert_equal @grant.uuid, RdapAccessEvent.last.grant_ref + end + + # AC8 — grant with NULL organization: organization_name persists NULL, others set. + def test_create_with_grant_without_organization + grant = rdap_privilege_grants(:no_organization) + assert_nil grant.organization + + post_event(grant_id: grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + assert_nil event.organization_name + assert_equal grant.full_name, event.accessor_name + assert_equal grant.category, event.category + assert_equal grant.uuid, event.grant_ref + end + + # AC9 — unknown grant: 404 + { message: } + no row. + def test_create_with_unknown_grant_returns_404 + assert_no_difference 'RdapAccessEvent.count' do + post_event(grant_id: 'does-not-exist') + end + + assert_response :not_found + assert_equal 'Grant not found', json_body[:message] + end + + # AC10 — non-200 result_code: 422 + { message: } + no row. + def test_create_with_non_200_result_code_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(result_code: 404) + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC11 (a) — missing required field: save returns false -> 422 + no row. + def test_create_with_missing_field_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(domain_name: nil) + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC11 (b) — unparseable requested_at: 422 + no row. + def test_create_with_unparseable_requested_at_returns_422 + assert_no_difference 'RdapAccessEvent.count' do + post_event(requested_at: 'not-a-timestamp') + end + + assert_response :unprocessable_entity + assert json_body[:message].present? + end + + # AC12 — Punycode name stored verbatim, no lookup. + def test_create_with_punycode_domain_name + post_event(domain_name: 'xn--eeau-zna.ee') + assert_response :no_content + assert_equal 'xn--eeau-zna.ee', RdapAccessEvent.last.domain_name + end + + # AC12 — Unicode name stored verbatim, and a no-such-domain still succeeds (no lookup). + def test_create_stores_domain_name_verbatim_no_lookup + post_event(domain_name: 'õäöü.ee') + assert_response :no_content + assert_equal 'õäöü.ee', RdapAccessEvent.last.domain_name + + post_event(domain_name: 'no-such-domain-anywhere.ee') + assert_response :no_content + assert_equal 'no-such-domain-anywhere.ee', RdapAccessEvent.last.domain_name + end + + # AC13 — forced persistence exception: 500 + { message: }. NOT a validation-false. + def test_create_returns_500_on_save_failure + RdapAccessEvent.stub_any_instance(:save, ->(*) { raise ActiveRecord::StatementInvalid, 'boom' }) do + assert_no_difference 'RdapAccessEvent.count' do + post_event + end + end + + assert_response :internal_server_error + assert json_body[:message].present? + end + + # AC14 — the save-failure exception logs an error AND increments the failure metric. + def test_save_failure_logs_error_and_increments_counter + logged = [] + incremented = [] + + RdapAccessEvent.stub_any_instance(:save, ->(*) { raise ActiveRecord::StatementInvalid, 'boom' }) do + NewRelic::Agent.stub(:increment_metric, ->(name, *) { incremented << name }) do + Rails.logger.stub(:error, ->(msg = nil) { logged << msg }) do + post_event + end + end + end + + assert_response :internal_server_error + assert_includes incremented, 'Custom/Rdap/access_event_record_failure' + assert logged.any? { |m| m.to_s.include?('rdap_access_event') }, + 'expected a technical-log error carrying the rdap_access_event marker' + end + + # AC15 — unauthenticated request: 401 + no row. + def test_requires_authentication_returns_401 + assert_no_difference 'RdapAccessEvent.count' do + post '/api/v1/internal/rdap/access-events', params: valid_params + end + assert_response :unauthorized + end + + # AC16 — PII gate: neither eeid_subject nor personal_id_code is stored anywhere. + def test_create_does_not_store_eeid_subject_or_personal_id_code + grant = rdap_privilege_grants(:with_personal_id) + assert grant.eeid_subject.present? + assert grant.personal_id_code.present? + + post_event(grant_id: grant.uuid) + assert_response :no_content + + event = RdapAccessEvent.last + values = event.attributes.values.map(&:to_s) + assert_not_includes values, grant.eeid_subject + assert_not_includes values, grant.personal_id_code + + columns = RdapAccessEvent.column_names + assert_not_includes columns, 'eeid_subject' + assert_not_includes columns, 'personal_id_code' + end + + # AC17 — request_id is non-unique: two POSTs with the same request_id both persist. + def test_request_id_is_not_unique + assert_difference 'RdapAccessEvent.count', 2 do + post_event(request_id: 'dup-123') + post_event(request_id: 'dup-123') + end + + assert_equal 2, RdapAccessEvent.where(request_id: 'dup-123').count + end + + # AC17 — request_id may be omitted (nullable). + def test_request_id_may_be_omitted + params = valid_params.except(:request_id) + assert_difference 'RdapAccessEvent.count', 1 do + post '/api/v1/internal/rdap/access-events', params: params, headers: @header + end + assert_response :no_content + assert_nil RdapAccessEvent.last.request_id + end + + private + + def valid_params + { + grant_id: @grant.uuid, + domain_name: 'example.ee', + requested_at: Time.zone.now.iso8601, + caller_ip: '192.0.2.1', + result_code: 200, + request_id: 'req-abc', + } + end + + def post_event(overrides = {}) + post '/api/v1/internal/rdap/access-events', + params: valid_params.merge(overrides), headers: @header + end + + def json_body + JSON.parse(response.body, symbolize_names: true) + end +end diff --git a/test/models/rdap_access_event_test.rb b/test/models/rdap_access_event_test.rb new file mode 100644 index 0000000000..587d8ed388 --- /dev/null +++ b/test/models/rdap_access_event_test.rb @@ -0,0 +1,96 @@ +require 'test_helper' + +class RdapAccessEventTest < ActiveSupport::TestCase + def test_valid_event + assert build_event.valid? + end + + def test_requires_requested_at + assert_invalid_without(:requested_at) + end + + def test_requires_domain_name + assert_invalid_without(:domain_name) + end + + def test_requires_caller_ip + assert_invalid_without(:caller_ip) + end + + def test_requires_result_code + assert_invalid_without(:result_code) + end + + def test_requires_accessor_name + assert_invalid_without(:accessor_name) + end + + def test_requires_category + assert_invalid_without(:category) + end + + def test_requires_grant_ref + assert_invalid_without(:grant_ref) + end + + # AC8 / AC19: organization_name and request_id are NOT required. + def test_valid_with_blank_organization_name_and_request_id + event = build_event(organization_name: nil, request_id: nil) + assert event.valid? + assert event.save + assert_nil event.reload.organization_name + assert_nil event.request_id + end + + # AC18: create-only immutability — a fresh create succeeds, but any mutation + # or destroy of a persisted row raises. + def test_create_succeeds + assert_difference 'RdapAccessEvent.count', 1 do + build_event.save! + end + end + + def test_update_raises + event = build_event.tap(&:save!) + assert_raises(ActiveRecord::ReadOnlyRecord) { event.update!(domain_name: 'changed.ee') } + end + + def test_save_on_persisted_row_raises + event = build_event.tap(&:save!) + event.domain_name = 'changed.ee' + assert_raises(ActiveRecord::ReadOnlyRecord) { event.save } + end + + def test_destroy_raises + event = build_event.tap(&:save!) + assert_raises(ActiveRecord::ReadOnlyRecord) { event.destroy } + end + + # AC20: NOT a paper_trail model — it does not include the Versions concern. + def test_is_not_a_paper_trail_model + assert_not RdapAccessEvent.include?(Versions) + assert_not RdapAccessEvent.respond_to?(:paper_trail_options) + end + + private + + def build_event(attrs = {}) + RdapAccessEvent.new({ + requested_at: Time.zone.now, + domain_name: 'example.ee', + caller_ip: '192.0.2.1', + result_code: 200, + organization_name: 'police', + accessor_name: 'Police Officer One', + category: 'police', + grant_ref: 'a1b2c3d4-0000-0000-0000-000000000001', + request_id: 'req-test', + }.merge(attrs)) + end + + def assert_invalid_without(field) + event = build_event(field => nil) + assert event.invalid? + assert_includes event.errors.attribute_names, field + end +end From a3ceaa811db06ab26352e4033c2aac279d41a847 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:02:09 +0300 Subject: [PATCH 11/15] feat(rdap): registrant access-events read API (spec 13 Surface A) Add GET /api/v1/registrant/domains/:uuid/access_events, the registrant-facing "who accessed my domain data" read endpoint. - Thin AccessEventsController < Api::V1::Registrant::BaseController: inherits the Bearer auth (401 on missing/invalid), resolves the addressed domain via current_registrant_user.domains.find_by(uuid: params[:domain_uuid]) (404 in the registrant-API error shape, byte-identical for unknown vs other-registrant), and renders EXACTLY {accessed_at, organization, category} per event. - RegistrantAccessEventsQuery PORO (app/services): reconstructs the caller's HALF-OPEN tenure intervals on THIS item_id from the ordered log_domains timeline (object_changes/object registrant_id), keeps only intervals whose effective registrant is one of the caller's own direct contact ids, and selects RdapAccessEvent rows for the domain name whose requested_at falls inside a tenure interval AND is older than the disclosure cutoff; DESC, capped 100. The per-interval lower bound excludes prior/later owners and same-name deleted-namesake events. - Read-time disclosure delay from Setting.rdap_access_transparency_disclosure_delay with a safe non-zero 5-minute fallback (never 0/real-time); seed + idempotent data migration create the SettingEntry (integer, group rdap, value 5). - Route nested under the authenticated registrant domains resource. - Integration + fixture coverage for historical per-tenure ownership (in-tenure, prior-owner, later-owner/transfer-instant, two non-contiguous intervals, namesake), delay filter incl. missing-Setting fallback, DESC+100 cap, PII value/log gates, read-only, 401, 404 no-leak, empty->200 []. --- .../v1/registrant/access_events_controller.rb | 34 +++ .../registrant_access_events_query.rb | 144 ++++++++++ config/routes.rb | 1 + ...s_transparency_disclosure_delay_setting.rb | 18 ++ db/seeds.rb | 1 + db/structure.sql | 3 +- test/fixtures/domains.yml | 31 ++ test/fixtures/log_domains.yml | 54 +++- test/fixtures/rdap_access_events.yml | 129 +++++++++ .../api/v1/registrant/access_events_test.rb | 264 ++++++++++++++++++ 10 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 app/controllers/api/v1/registrant/access_events_controller.rb create mode 100644 app/services/registrant_access_events_query.rb create mode 100644 db/migrate/20260716123000_add_rdap_access_transparency_disclosure_delay_setting.rb create mode 100644 test/integration/api/v1/registrant/access_events_test.rb diff --git a/app/controllers/api/v1/registrant/access_events_controller.rb b/app/controllers/api/v1/registrant/access_events_controller.rb new file mode 100644 index 0000000000..fc3877a92f --- /dev/null +++ b/app/controllers/api/v1/registrant/access_events_controller.rb @@ -0,0 +1,34 @@ +module Api + module V1 + module Registrant + # Read-only (spec 13, Surface A). Lists the authority accesses the authenticated + # registrant is entitled to see for ONE of their own domains, addressed by uuid. + # Auth is inherited UNCHANGED from BaseController (Bearer -> current_registrant_user). + class AccessEventsController < ::Api::V1::Registrant::BaseController + # GET /api/v1/registrant/domains/:uuid/access_events + def index + # R8/AC9: does the caller CURRENTLY own THIS uuid? Same single branch for an + # unknown uuid and another registrant's uuid, so the two 404 bodies are + # byte-identical and reveal nothing about existence under another registrant. + domain = current_registrant_user.domains.find_by(uuid: params[:domain_uuid]) + return render json: { errors: [{ base: ['Domain not found'] }] }, + status: :not_found unless domain + + # Scoping derived EXCLUSIVELY from current_registrant_user (R4/N3): the action + # never reads params[:registrant_id] / params[:contact_id] / an ident header. + events = RegistrantAccessEventsQuery.new(domain: domain, + registrant_user: current_registrant_user).call + + # EXACTLY the three R9 keys (N1/AC13): withheld fields cannot appear structurally. + render json: events.map { |event| + { + accessed_at: event.requested_at.iso8601, + organization: event.organization_name, + category: event.category, + } + } + end + end + end + end +end diff --git a/app/services/registrant_access_events_query.rb b/app/services/registrant_access_events_query.rb new file mode 100644 index 0000000000..ed964a6076 --- /dev/null +++ b/app/services/registrant_access_events_query.rb @@ -0,0 +1,144 @@ +# Read-only PORO (spec 13, Surface A). Reconstructs the caller's HALF-OPEN tenure +# intervals on THIS domain's item_id from log_domains (PaperTrail), then returns the +# disclosable RdapAccessEvent rows for the domain: delay-filtered, ordered +# requested_at DESC, capped at 100. +# +# The load-bearing correctness property lives here: every returned event's +# requested_at MUST fall inside one of the caller's [start_i, end_i) intervals for +# item_id = domain.id. This excludes a prior/later owner's accesses (per-tenure) and +# a same-name event that belongs to a different (deleted-namesake) item_id (the +# per-interval LOWER bound is what does that — a name-only + upper-bound filter would +# leak it and is FORBIDDEN). +# +# PII (N1): reads only the caller's OWN contact ids (server-side, from +# current_registrant_user) and event ids/timestamps; writes NOTHING PII-bearing to any +# log/error line. Issues only SELECTs (N5) — never writes, mutates, or reschemas the +# store. No defensive rescue/retry/circuit-breaker — a plain SELECT pipeline. +class RegistrantAccessEventsQuery + FALLBACK_DELAY_MINUTES = 5 + RESULT_CAP = 100 + + def initialize(domain:, registrant_user:, now: Time.current) + @domain = domain + @now = now + # R5a: the caller's OWN contact id(s), resolved SERVER-SIDE from current_registrant_user. + # DIRECT (ident-matched personal) contact(s) only this release; never a client value. + @own_ids = Contact.registrant_user_direct_contacts(registrant_user).ids + end + + def call + intervals = own_tenure_intervals + return RdapAccessEvent.none if intervals.empty? + + scope = RdapAccessEvent.where(domain_name: @domain.name) + .where('requested_at <= ?', cutoff) + scope = scope.where(interval_predicate(intervals), *interval_binds(intervals)) + scope.order(requested_at: :desc).limit(RESULT_CAP) + end + + private + + # Delay cutoff with a SAFE NON-ZERO fallback (R11/R14/N4). Setting. returns nil + # for a missing/blank row; the `|| 5` and the `<= 0 => 5` clamp guarantee the path can + # never reach an effective 0-delay (never real-time disclosure). + def cutoff + delay = (Setting.rdap_access_transparency_disclosure_delay || FALLBACK_DELAY_MINUTES).to_i + delay = FALLBACK_DELAY_MINUTES if delay <= 0 + @now - delay.minutes + end + + # Ordered [start, end) intervals (end = @now for the still-current tenure) during which + # the effective registrant of item_id = @domain.id was one of @own_ids. Built from the + # ordered log_domains timeline for this item_id. + def own_tenure_intervals + rows = Version::DomainVersion.where(item_id: @domain.id).order(:created_at) + .pluck(:created_at, + Arel.sql("object_changes->>'registrant_id'"), + Arel.sql("object->>'registrant_id'")) + build_intervals(rows) + end + + # Pure walk (unit-testable in isolation). rows = [created_at, object_changes_registrant, object_registrant]. + # Derives the effective registrant AFTER each event: prefer the NEW value of + # object_changes.registrant_id ([old, new] array), else the object.registrant_id + # scalar snapshot, else carry forward the previous effective value. Emits half-open + # [start, end) intervals for consecutive runs where the effective registrant is one of + # @own_ids; the still-current tenure is closed at @now. + def build_intervals(rows) + intervals = [] + effective = nil + current_start = nil + current_owned = false + + rows.each do |created_at, changes_registrant, object_registrant| + new_effective = effective_registrant(changes_registrant, object_registrant, effective) + owned_now = own?(new_effective) + + if owned_now && !current_owned + current_start = created_at + elsif !owned_now && current_owned + intervals << [current_start, created_at] + current_start = nil + end + + effective = new_effective + current_owned = owned_now + end + + # The live tail: whoever domain.registrant_id currently is closes at @now. Trust the + # live domain over the reconstructed run so the open interval is anchored to reality. + if own?(@domain.registrant_id) + current_start ||= rows.first&.first + intervals << [current_start, @now] + elsif current_owned && current_start + # Reconstructed run left the caller owning but the live domain is no longer theirs + # without a closing version row — close at @now defensively (should not normally happen). + intervals << [current_start, @now] + end + + intervals + end + + # Effective registrant AFTER an event. object_changes.registrant_id is a [old, new] + # JSON array (take [1]); object.registrant_id is a scalar. Mirror the string-vs-parsed + # handling in legal_document.rb:102-111. When neither is present, carry forward. + def effective_registrant(changes_registrant, object_registrant, carry) + if changes_registrant.present? + parsed = parse_json(changes_registrant) + return parsed[1] if parsed.is_a?(Array) + + return parsed + end + + return to_id(object_registrant) if object_registrant.present? + + carry + end + + def parse_json(value) + JSON.parse(value) + rescue JSON::ParserError, TypeError + value + end + + def to_id(value) + Integer(value) + rescue ArgumentError, TypeError + value + end + + def own?(registrant_id) + return false if registrant_id.nil? + + @own_ids.include?(to_id(registrant_id)) + end + + # OR of per-interval half-open bounds: (requested_at >= ? AND requested_at < ?). + def interval_predicate(intervals) + Array.new(intervals.size, '(requested_at >= ? AND requested_at < ?)').join(' OR ') + end + + def interval_binds(intervals) + intervals.flat_map { |start_at, end_at| [start_at, end_at] } + end +end diff --git a/config/routes.rb b/config/routes.rb index d58d6245c4..bb25ac7a83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -205,6 +205,7 @@ resources :domains, only: %i[index show], param: :uuid do resource :registry_lock, only: %i[create destroy] + resources :access_events, only: %i[index] end resources :contacts, only: %i[index show update], param: :uuid do get 'do_need_update_contacts', to: 'contacts#do_need_update_contacts', diff --git a/db/migrate/20260716123000_add_rdap_access_transparency_disclosure_delay_setting.rb b/db/migrate/20260716123000_add_rdap_access_transparency_disclosure_delay_setting.rb new file mode 100644 index 0000000000..b34e11cc5f --- /dev/null +++ b/db/migrate/20260716123000_add_rdap_access_transparency_disclosure_delay_setting.rb @@ -0,0 +1,18 @@ +class AddRdapAccessTransparencyDisclosureDelaySetting < ActiveRecord::Migration[6.1] + def up + unless SettingEntry.exists?(code: 'rdap_access_transparency_disclosure_delay') + SettingEntry.create!( + code: 'rdap_access_transparency_disclosure_delay', + value: '5', + format: 'integer', + group: 'rdap' + ) + else + puts "SettingEntry rdap_access_transparency_disclosure_delay already exists" + end + end + + def down + SettingEntry.where(code: 'rdap_access_transparency_disclosure_delay').destroy_all + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 223edb4a87..3f18537ed8 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -75,6 +75,7 @@ SettingEntry.create(code: 'registry_whois_disclaimer', value: 'Search results may not be used for commercial, advertising, recompilation, repackaging, redistribution, reuse, obscuring or other similar activities.', format: 'string', group: 'contacts') SettingEntry.create(code: 'legal_document_is_mandatory', value: 'true', format: 'boolean', group: 'domain_validation') SettingEntry.create(code: 'ip_whitelist_max_count', value: '256', format: 'integer', group: 'other') + SettingEntry.create(code: 'rdap_access_transparency_disclosure_delay', value: '5', format: 'integer', group: 'rdap') AdminUser.where(username: 'admin').first_or_create!( username: 'admin', diff --git a/db/structure.sql b/db/structure.sql index ed558d6af3..2d22bfb7fa 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -6134,6 +6134,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260707120000'), ('20260713120000'), ('20260713120001'), -('20260716120000'); +('20260716120000'), +('20260716123000'); diff --git a/test/fixtures/domains.yml b/test/fixtures/domains.yml index 770559bdf0..955dfa772b 100644 --- a/test/fixtures/domains.yml +++ b/test/fixtures/domains.yml @@ -74,3 +74,34 @@ invalid: registrar: bestnames registrant: invalid uuid: 3c430ead-bb17-4b5b-aaa1-caa7dde7e138 + +# spec 13 (RDAP access transparency): a domain currently owned by :john (== users(:registrant)) +# whose id is the item_id referenced by the log_domains timeline fixtures (john_tenure_*) and +# matched by rdap_access_events.domain_name = mytenure.test. +mytenure: + name: mytenure.test + name_puny: mytenure.test + name_dirty: mytenure.test + registrar: bestnames + registrant: john + transfer_code: 9f13ab7 + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 7d1f6b2c-0e3a-4c5d-9b8e-1a2b3c4d5e6f + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13: a second john-owned domain used only for the DESC + 100-cap test (AC12), so its +# >100 events do not pollute the mytenure.test count assertions. +capdomain: + name: capdomain.test + name_puny: capdomain.test + name_dirty: capdomain.test + registrar: bestnames + registrant: john + transfer_code: 3ac71f9 + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 5c9a0d1e-2f3b-4a6c-8d7e-9f0a1b2c3d4e + created_at: <%= Time.zone.parse('2020-01-01') %> diff --git a/test/fixtures/log_domains.yml b/test/fixtures/log_domains.yml index 28a9a36c35..6a3e664394 100644 --- a/test/fixtures/log_domains.yml +++ b/test/fixtures/log_domains.yml @@ -95,4 +95,56 @@ create_seven: name: [null, 'metro.test'] registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:goodnames) %>] registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jack) %>] - created_at: <%= Time.zone.parse('2023-09-05') %> \ No newline at end of file + created_at: <%= Time.zone.parse('2023-09-05') %> + +# spec 13: the registrant timeline for domains(:mytenure) (item_id = identify(:mytenure)). +# john == users(:registrant); jane is the "other" (prior/intervening) owner. +# [2020-01-01, 2021-01-01) jane (prior owner) +# [2021-01-01, 2022-01-01) john (caller interval s1..e1) -> AC4/AC5 boundary +# [2022-01-01, 2023-01-01) jane (intervening owner) -> AC6/AC7 excluded +# [2023-01-01, now) john (caller re-acquired, live) -> AC7 second interval +john_tenure_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: create + object_changes: + name: [null, 'mytenure.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:bestnames) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +john_tenure_acquire_1: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2021-01-01') %> + +john_tenure_transfer_away: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:john) %>, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2022-01-01') %> + +john_tenure_reacquire: + item_id: <%= ActiveRecord::FixtureSet.identify(:mytenure) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2023-01-01') %> + +# spec 13: domains(:capdomain) is john's for its whole life [2020-01-01, now) — used for the +# DESC ordering + 100-cap test (AC12). +capdomain_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:capdomain) %> + item_type: Domain + event: create + object_changes: + name: [null, 'capdomain.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:bestnames) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> \ No newline at end of file diff --git a/test/fixtures/rdap_access_events.yml b/test/fixtures/rdap_access_events.yml index 28725a80da..8abfbacf5b 100644 --- a/test/fixtures/rdap_access_events.yml +++ b/test/fixtures/rdap_access_events.yml @@ -24,3 +24,132 @@ cert_lookup: grant_ref: a1b2c3d4-0000-0000-0000-000000000002 request_id: created_at: <%= 1.day.ago.to_s(:db) %> + +# --- spec 13 (RDAP access transparency) events for domains(:mytenure) = mytenure.test --- +# john's tenure intervals on this item_id: [2021-01-01, 2022-01-01) and [2023-01-01, now). +# All requested_at values below are absolute so they land deterministically relative to the +# tenure boundaries; those meant to be disclosable are also older than the 5-minute delay cutoff. + +# AC4 + AC7(first interval): inside john's first tenure [2021-01-01, 2022-01-01) -> PRESENT. +mytenure_in_first_tenure: + requested_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.30 + result_code: 200 + organization_name: police + accessor_name: Officer Alpha + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000010 + request_id: req-0010 + created_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + +# AC5: before john's earliest tenure start (prior owner jane's interval) -> ABSENT. +mytenure_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.31 + result_code: 200 + organization_name: cert_team + accessor_name: Prior Owner Access + category: cert + grant_ref: b2c3d4e5-0000-0000-0000-000000000011 + request_id: req-0011 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# AC6: EXACTLY at the transfer instant end=2022-01-01 -> belongs to the acquiring owner (jane), +# excluded from the departing caller (half-open) -> ABSENT. +mytenure_transfer_instant: + requested_at: <%= Time.zone.parse('2022-01-01 00:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.32 + result_code: 200 + organization_name: police + accessor_name: Transfer Instant Access + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000012 + request_id: req-0012 + created_at: <%= Time.zone.parse('2022-01-01 00:00:00') %> + +# AC6 + AC7(intervening): inside jane's intervening interval [2022-01-01, 2023-01-01) -> ABSENT. +mytenure_intervening_owner: + requested_at: <%= Time.zone.parse('2022-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.33 + result_code: 200 + organization_name: cert_team + accessor_name: Intervening Owner Access + category: cert + grant_ref: b2c3d4e5-0000-0000-0000-000000000013 + request_id: req-0013 + created_at: <%= Time.zone.parse('2022-06-01 10:00:00') %> + +# AC7(second interval): inside john's re-acquired tenure [2023-01-01, now) -> PRESENT. +# Also the AC13 null-organization case (organization_name is nil). +mytenure_in_second_tenure_null_org: + requested_at: <%= Time.zone.parse('2023-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.34 + result_code: 200 + organization_name: + accessor_name: Officer Beta + category: emergency + grant_ref: b2c3d4e5-0000-0000-0000-000000000014 + request_id: req-0014 + created_at: <%= Time.zone.parse('2023-06-01 10:00:00') %> + +# AC8: same domain_name = mytenure.test but requested_at (2015) is OUTSIDE every interval of +# THIS item_id (a deleted namesake on a different item_id) -> ABSENT. +mytenure_namesake: + requested_at: <%= Time.zone.parse('2015-06-01 10:00:00') %> + domain_name: mytenure.test + caller_ip: 192.0.2.35 + result_code: 200 + organization_name: police + accessor_name: Namesake Access + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000015 + request_id: req-0015 + created_at: <%= Time.zone.parse('2015-06-01 10:00:00') %> + +# AC10/AC11: inside john's current tenure, PAST the delay cutoff (2 hours old) -> PRESENT. +mytenure_current_past_cutoff: + requested_at: <%= 2.hours.ago.to_s(:db) %> + domain_name: mytenure.test + caller_ip: 192.0.2.36 + result_code: 200 + organization_name: police + accessor_name: Recent Officer + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000016 + request_id: req-0016 + created_at: <%= 2.hours.ago.to_s(:db) %> + +# AC10/AC11: inside john's current tenure but NEWER than now-delay (1 minute old) -> ABSENT +# (suppressed by the disclosure delay, and by the 5-minute fallback when the Setting is absent). +mytenure_current_in_window: + requested_at: <%= 1.minute.ago.to_s(:db) %> + domain_name: mytenure.test + caller_ip: 192.0.2.37 + result_code: 200 + organization_name: police + accessor_name: Just Now Officer + category: police + grant_ref: b2c3d4e5-0000-0000-0000-000000000017 + request_id: req-0017 + created_at: <%= 1.minute.ago.to_s(:db) %> + +# --- AC12: >100 disclosable events on domains(:capdomain) = capdomain.test (john's whole life), +# all past the delay cutoff, so the response is capped at 100 most-recent DESC. --- +<% (1..101).each do |i| %> +capdomain_event_<%= i %>: + requested_at: <%= (i + 1).hours.ago.to_s(:db) %> + domain_name: capdomain.test + caller_ip: 192.0.2.100 + result_code: 200 + organization_name: police + accessor_name: Cap Officer <%= i %> + category: police + grant_ref: <%= format('c3d4e5f6-0000-0000-0000-%012d', i) %> + request_id: cap-<%= i %> + created_at: <%= (i + 1).hours.ago.to_s(:db) %> +<% end %> diff --git a/test/integration/api/v1/registrant/access_events_test.rb b/test/integration/api/v1/registrant/access_events_test.rb new file mode 100644 index 0000000000..6b8dc14aec --- /dev/null +++ b/test/integration/api/v1/registrant/access_events_test.rb @@ -0,0 +1,264 @@ +require 'test_helper' +require 'auth_token/auth_token_creator' + +# spec 13, Surface A: GET /api/v1/registrant/domains/:uuid/access_events +# +# Fixture ground truth (see test/fixtures/{domains,contacts,log_domains,rdap_access_events}.yml): +# users(:registrant) == US-1234 resolves via Contact.registrant_user_direct_contacts to +# contacts(:john) only (ident 1234, US). contacts(:jane) is the other/prior/intervening owner. +# domains(:mytenure) (name mytenure.test) is currently john's; its log_domains timeline gives +# john the tenure intervals [2021-01-01, 2022-01-01) and [2023-01-01, now); jane owns +# [2020-01-01, 2021-01-01) and [2022-01-01, 2023-01-01). +class RegistrantApiV1AccessEventsTest < ActionDispatch::IntegrationTest + setup do + @user = users(:registrant) + @domain = domains(:mytenure) + end + + def test_route_exists_per_domain_only + get access_events_path(@domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_response :ok + assert_kind_of Array, JSON.parse(response.body) + end + + def test_missing_and_invalid_token_return_401 + get access_events_path(@domain.uuid), as: :json + assert_response :unauthorized + + get access_events_path(@domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => 'Bearer garbage-not-a-token' } + assert_response :unauthorized + end + + def test_ignores_client_supplied_registrant_and_contact_and_ident + clean_body = get_events(@domain.uuid) + + # Inject a foreign registrant id / contact id (jane, id 123456) and an ident header — + # all must be ignored; scoping is derived only from current_registrant_user. + get access_events_path(@domain.uuid) + '?registrant_id=999999&contact_id=999999', + as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token, + 'X-Registrant-Ident' => '123456' } + assert_response :ok + injected_body = JSON.parse(response.body) + + assert_equal clean_body, injected_body + end + + def test_event_during_caller_tenure_is_returned + body = get_events(@domain.uuid) + # mytenure_in_first_tenure (2021-06-01) is inside [2021-01-01, 2022-01-01). + assert_includes accessed_ats(body), iso('2021-06-01 10:00:00') + end + + def test_prior_owner_event_excluded + body = get_events(@domain.uuid) + # mytenure_prior_owner (2020-06-01) is before john's earliest tenure start. + refute_includes accessed_ats(body), iso('2020-06-01 10:00:00') + end + + def test_later_owner_and_transfer_instant_events_excluded + body = get_events(@domain.uuid) + # transfer instant == end (2022-01-01 00:00:00) belongs to the acquiring owner. + refute_includes accessed_ats(body), iso('2022-01-01 00:00:00') + # intervening owner's interval [2022-01-01, 2023-01-01). + refute_includes accessed_ats(body), iso('2022-06-01 10:00:00') + end + + def test_two_noncontiguous_tenures_return_both_own_intervals_only + body = get_events(@domain.uuid) + ats = accessed_ats(body) + assert_includes ats, iso('2021-06-01 10:00:00') # first own interval + assert_includes ats, iso('2023-06-01 10:00:00') # second own interval + refute_includes ats, iso('2022-06-01 10:00:00') # intervening owner + end + + def test_namesake_different_item_id_event_excluded + body = get_events(@domain.uuid) + # 2015-06-01 mytenure.test event lies outside every interval of this item_id. + refute_includes accessed_ats(body), iso('2015-06-01 10:00:00') + end + + def test_unknown_and_other_registrant_uuid_return_identical_404 + unknown_uuid = '00000000-0000-0000-0000-000000000000' + other_uuid = domains(:metro).uuid # owned by jack, not the caller + + get access_events_path(unknown_uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :not_found + unknown_body = response.body + + get access_events_path(other_uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :not_found + other_body = response.body + + assert_equal({ 'errors' => [{ 'base' => ['Domain not found'] }] }, JSON.parse(unknown_body)) + assert_equal unknown_body, other_body # byte-identical, no existence leak + end + + def test_delay_filter_in_window_absent_past_cutoff_present + with_setting('rdap_access_transparency_disclosure_delay', '5') do + body = get_events(@domain.uuid) + ats = accessed_ats(body) + # 2-hours-ago event is past the 5-minute cutoff -> present. + assert(ats.any? { |a| Time.zone.parse(a) < 30.minutes.ago }, + 'expected a past-cutoff event in the response') + # 1-minute-ago event is inside the suppression window -> absent. + refute(ats.any? { |a| Time.zone.parse(a) > 30.minutes.ago }, + 'in-window (newer than cutoff) event must be absent') + end + end + + def test_setting_value_and_missing_setting_5min_fallback + # (a) a controlled Setting value drives the cutoff. + with_setting('rdap_access_transparency_disclosure_delay', '5') do + body = get_events(@domain.uuid) + refute(accessed_ats(body).any? { |a| Time.zone.parse(a) > 30.minutes.ago }) + end + + # (b) with the Setting row ABSENT, the 5-minute non-zero fallback still suppresses the + # in-window event (never real-time / 0-delay). + SettingEntry.where(code: 'rdap_access_transparency_disclosure_delay').destroy_all + assert_nil Setting.rdap_access_transparency_disclosure_delay + body = get_events(@domain.uuid) + refute(accessed_ats(body).any? { |a| Time.zone.parse(a) > 30.minutes.ago }, + 'missing Setting must fall back to 5 minutes, not 0-delay') + end + + def test_order_desc_and_cap_100_after_filtering + cap_uuid = domains(:capdomain).uuid + body = get_events(cap_uuid) + + assert_equal 100, body.size + ats = body.map { |e| Time.zone.parse(e['accessed_at']) } + assert_equal ats.sort.reverse, ats, 'events must be ordered requested_at DESC' + # The 100 returned are the most-recent disclosable ones (cap applied post-filter): the + # oldest capdomain event (capdomain_event_101 at 102h ago) must be excluded. + refute_includes body.map { |e| e['accessed_at'] }, iso_time(102.hours.ago) + end + + def test_response_has_exactly_three_keys_and_no_withheld_fields + body = get_events(@domain.uuid) + assert body.any?, 'expected at least one disclosable event' + + body.each do |event| + assert_equal %w[accessed_at category organization], event.keys.sort + # accessed_at parses as ISO-8601 with a timezone offset. + assert_match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2}\z/, event['accessed_at']) + end + + # null organization_name -> organization: null (mytenure_in_second_tenure_null_org). + null_org = body.find { |e| e['accessed_at'] == iso('2023-06-01 10:00:00') } + assert null_org, 'expected the null-organization event to be disclosed' + assert_nil null_org['organization'] + + raw = response.body + %w[accessor_name grant_ref request_id caller_ip result_code created_at + eeid_subject personal_id_code Officer\ Alpha Namesake].each do |withheld| + refute_includes raw, withheld, "response body must not contain #{withheld}" + end + # the caller's own ident must not leak. + refute_includes raw, @user.ident + end + + def test_no_pii_in_logs_across_happy_404_401 + logged = capture_rails_log do + get_events(@domain.uuid) + get access_events_path('00000000-0000-0000-0000-000000000000'), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + get access_events_path(@domain.uuid), as: :json # 401 + end + + refute_includes logged, @user.ident # caller's ident string + refute_includes logged, 'Officer Alpha' # accessor personal identifier + refute_includes logged, 'Namesake Access' + refute_includes logged, 'Recent Officer' + end + + def test_read_only_no_store_or_related_writes + assert_no_difference ['RdapAccessEvent.count', 'Version::DomainVersion.count', + 'Domain.count', 'Contact.count'] do + get_events(@domain.uuid) + get access_events_path('00000000-0000-0000-0000-000000000000'), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + get access_events_path(@domain.uuid), as: :json # 401 + end + end + + def test_owned_domain_zero_events_returns_200_empty_array + # domains(:hospital) is john's but has no rdap_access_events rows (hospital.test). + empty_domain = domains(:hospital) + get access_events_path(empty_domain.uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + + assert_response :ok + assert_equal [], JSON.parse(response.body) + end + + private + + def access_events_path(uuid) + "/api/v1/registrant/domains/#{uuid}/access_events" + end + + def get_events(uuid) + get access_events_path(uuid), as: :json, + headers: { 'HTTP_AUTHORIZATION' => auth_token } + assert_response :ok + JSON.parse(response.body) + end + + def accessed_ats(body) + body.map { |e| e['accessed_at'] } + end + + def iso(parseable) + Time.zone.parse(parseable).iso8601 + end + + def iso_time(time) + time.iso8601 + end + + def with_setting(code, value) + entry = SettingEntry.find_by(code: code) + original = entry&.value + if entry + entry.update!(value: value) + else + SettingEntry.create!(code: code, value: value, format: 'integer', group: 'rdap') + end + yield + ensure + current = SettingEntry.find_by(code: code) + if original + current&.update!(value: original) + else + current&.destroy + end + end + + # Capture technical-log / error lines the endpoint emits at the application's own log level + # (info+; SQL debug lines are not part of the endpoint's emitted output). The assertion is that + # the endpoint's own logging carries no PII — our controller/query object make no log calls. + def capture_rails_log + io = StringIO.new + logger = ActiveSupport::Logger.new(io) + logger.level = Logger::INFO + original = Rails.logger + Rails.logger = logger + yield + io.string + ensure + Rails.logger = original + end + + def auth_token + token_creator = AuthTokenCreator.create_with_defaults(@user) + hash = token_creator.token_in_hash + "Bearer #{hash[:access_token]}" + end +end From 594f778ad6973f62d0da9ab24c08b0e0ea553901 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:08:45 +0300 Subject: [PATCH 12/15] fix(ci): add trailing newline to application.yml.sample Commit 9dae4a3aa added rdap_api_token_hmac_secret without a trailing newline. The CI test job appends config via 'echo >> config/application.yml' after copying the sample, so the first appended line was concatenated onto rdap_api_token_hmac_secret, producing invalid YAML and a figaro Psych parse error at boot (config/application.rb:18) that aborted the entire test suite before any test loaded. Adding the trailing newline restores CI. --- config/application.yml.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application.yml.sample b/config/application.yml.sample index edd5dfe914..f4b54f3576 100644 --- a/config/application.yml.sample +++ b/config/application.yml.sample @@ -275,4 +275,4 @@ staging: 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 +rdap_api_token_hmac_secret: 'testkey' From abd9305bccfc1ba23c0202d1bc04ecb63c88bb9c Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:23:26 +0300 Subject: [PATCH 13/15] test(rdap): stop spec-13 fixtures from perturbing shared test counts The spec-13 access-transparency fixtures (mytenure/capdomain domains + their log_domains timeline) grew the shared fixture corpus by two john-owned domains, which broke four unrelated shared assertions: - Market-share stats (ReppV1StatsMarketShareTest): moved mytenure/capdomain and their create log events onto a dedicated test_registrar (spec13_registrar). serialize_distribution/growth_rate skip any registrar with test_registrar:true, so the two domains no longer shift the Best Names distribution/growth numbers. No stats assertions changed. - Registrant domain counts (RegistrantApiV1DomainsTest, RegistrantApiDomainsTest): the corpus legitimately gained two john-owned domains, so bumped the hardcoded expected totals/counts by +2. - Admin domain-history CSV export (DomainVersionsTest#test_download_domain_history): regenerated the golden CSV to include the eight new log_domains rows (registrar now reads Spec13 Registrar for the two create rows). No production code changed; only fixtures and the specific assertions the fixture growth broke. The spec-13 access-events privacy tests are untouched. --- test/fixtures/domains.yml | 4 ++-- test/fixtures/files/domain_versions.csv | 5 +++++ test/fixtures/log_domains.yml | 4 ++-- test/fixtures/registrars.yml | 18 ++++++++++++++++++ .../registrant/registrant_api_domains_test.rb | 6 ++++-- .../api/v1/registrant/domains_test.rb | 10 ++++++---- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/test/fixtures/domains.yml b/test/fixtures/domains.yml index 955dfa772b..d86f3fb460 100644 --- a/test/fixtures/domains.yml +++ b/test/fixtures/domains.yml @@ -82,7 +82,7 @@ mytenure: name: mytenure.test name_puny: mytenure.test name_dirty: mytenure.test - registrar: bestnames + registrar: spec13_registrar registrant: john transfer_code: 9f13ab7 valid_to: 2035-07-05 @@ -97,7 +97,7 @@ capdomain: name: capdomain.test name_puny: capdomain.test name_dirty: capdomain.test - registrar: bestnames + registrar: spec13_registrar registrant: john transfer_code: 3ac71f9 valid_to: 2035-07-05 diff --git a/test/fixtures/files/domain_versions.csv b/test/fixtures/files/domain_versions.csv index 0b3629a5e2..ea0d88b8f8 100644 --- a/test/fixtures/files/domain_versions.csv +++ b/test/fixtures/files/domain_versions.csv @@ -8,5 +8,10 @@ airport.test,John,Best Names,create,2023-12-04 22:00:00 shop.test,John,Good Names,create,2023-10-04 21:00:00 cinema.test,John,Good Names,create,2023-09-04 21:00:00 metro.test,Jack,Good Names,create,2023-09-04 21:00:00 +,John,,update,2022-12-31 22:00:00 +,Jane,,update,2021-12-31 22:00:00 +,John,,update,2020-12-31 22:00:00 +mytenure.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 +capdomain.test,John,Spec13 Registrar,create,2019-12-31 22:00:00 ,test_code,Best Names,update,2018-04-23 15:50:48 ,John,,update,2010-07-04 21:00:00 diff --git a/test/fixtures/log_domains.yml b/test/fixtures/log_domains.yml index 6a3e664394..a959ea8912 100644 --- a/test/fixtures/log_domains.yml +++ b/test/fixtures/log_domains.yml @@ -109,7 +109,7 @@ john_tenure_create: event: create object_changes: name: [null, 'mytenure.test'] - registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:bestnames) %>] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] created_at: <%= Time.zone.parse('2020-01-01') %> @@ -145,6 +145,6 @@ capdomain_create: event: create object_changes: name: [null, 'capdomain.test'] - registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:bestnames) %>] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:john) %>] created_at: <%= Time.zone.parse('2020-01-01') %> \ No newline at end of file diff --git a/test/fixtures/registrars.yml b/test/fixtures/registrars.yml index b0ae6d2800..1231e69583 100644 --- a/test/fixtures/registrars.yml +++ b/test/fixtures/registrars.yml @@ -51,3 +51,21 @@ invalid: accounting_customer_code: any language: en reference_no: 42 + +# spec 13 (RDAP access transparency): a test_registrar that carries the mytenure/capdomain +# fixtures so they stay OUT of the registrar market-share stats (serialize_* skips any +# registrar with test_registrar: true), while still being real, john-owned domains for the +# access-events tenure fixtures. +spec13_registrar: + name: Spec13 Registrar + reg_no: 90013 + code: spec13 + email: info@spec13.test + address_street: Main Street 1 + address_city: NY + address_country_code: US + vat_no: any + accounting_customer_code: spec13 + language: en + reference_no: 13013 + test_registrar: true diff --git a/test/integration/api/registrant/registrant_api_domains_test.rb b/test/integration/api/registrant/registrant_api_domains_test.rb index a8801f1b4a..bbf14930f5 100644 --- a/test/integration/api/registrant/registrant_api_domains_test.rb +++ b/test/integration/api/registrant/registrant_api_domains_test.rb @@ -102,7 +102,8 @@ def test_domains_total_if_an_incomplete_list_is_returned response_json = JSON.parse(response.body, symbolize_names: true) assert_equal response_json[:domains].length, response_json[:count] - assert_equal response_json[:total], 5 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + assert_equal response_json[:total], 7 end def test_root_accepts_limit_and_offset_parameters @@ -116,7 +117,8 @@ def test_root_accepts_limit_and_offset_parameters get '/api/v1/registrant/domains', headers: @auth_headers response_json = JSON.parse(response.body, symbolize_names: true) - assert_equal(4, response_json[:domains].count) + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + assert_equal(6, response_json[:domains].count) end def test_root_does_not_accept_limit_higher_than_200 diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index 367df9e48a..a31bf248e9 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -21,8 +21,9 @@ def test_get_default_counts_of_domains assert_response :ok response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + assert_equal response_json['total'], 6 + assert_equal response_json['count'], 6 end def test_get_default_counts_of_direct_domains @@ -32,8 +33,9 @@ def test_get_default_counts_of_direct_domains end response_json = JSON.parse(response.body) - assert_equal response_json['total'], 4 - assert_equal response_json['count'], 4 + # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) + assert_equal response_json['total'], 6 + assert_equal response_json['count'], 6 end private From 2cf19c5b8415a248e32e03ac844bc589e90cd516 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:29:30 +0300 Subject: [PATCH 14/15] test(rdap): correct market-share growth percentages for spec-13 corpus The spec-13 mytenure/capdomain test_registrar domains are excluded from the market-share domains breakdown (serialize_growth_rate_result filters test registrars), but calculate_market_share divides by the total across ALL registrars, so the two extra domains enlarge the denominator. Update the growth-rate percentage expectations accordingly (prev Good 60.0; today Good 25.0 / Best 50.0). Raw domain counts are unchanged. --- test/integration/repp/v1/stats/market_share_test.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/integration/repp/v1/stats/market_share_test.rb b/test/integration/repp/v1/stats/market_share_test.rb index fa7ea75899..074058e441 100644 --- a/test/integration/repp/v1/stats/market_share_test.rb +++ b/test/integration/repp/v1/stats/market_share_test.rb @@ -34,11 +34,17 @@ def test_shows_market_share_growth_rate_data assert_equal 1000, json[:code] assert_equal 'Command completed successfully', json[:message] + # domains counts are unchanged: the spec-13 mytenure/capdomain fixtures sit on a + # test_registrar, which serialize_growth_rate_result excludes. calculate_market_share, + # however, divides by the total across ALL registrars (test ones included), so the two + # extra domains enlarge the denominator and lower each shown share: + # prev (2023-11): Good 3 / (3 + 0 + 2 spec13) = 60.0 % + # today (07.26): Good 2 / (2 + 4 + 2 spec13) = 25.0 %, Best 4 / 8 = 50.0 % assert_equal json[:data], prev_data: { name: prev_date, domains: [['Good Names', 3], ['Best Names', 0]], - market_share: [['Good Names', 100.0], ['Best Names', 0.0]] }, + market_share: [['Good Names', 60.0], ['Best Names', 0.0]] }, data: { name: @today, domains: [['Good Names', 2], ['Best Names', 4]], - market_share: [['Good Names', 33.3], ['Best Names', 66.7]] } + market_share: [['Good Names', 25.0], ['Best Names', 50.0]] } end end From 848488f6762eacc8c6b7d990b607d778a7cdaf4f Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 16 Jul 2026 19:43:07 +0300 Subject: [PATCH 15/15] fix(rdap): fail-closed the spec-13 live-tail so it cannot leak a prior owner's accesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (HIGH, found by security + code review): RegistrantAccessEventsQuery#build_intervals opened the caller's live tenure interval with `current_start ||= rows.first&.first` whenever the LIVE domain.registrant_id was one of the caller's contacts. When the reconstructed walk had NOT itself established the caller's current tenure (current_start nil — the acquiring version row is missing, or its object_changes was empty so carry-forward kept the prior owner), that fabricated current_start = the domain's FIRST-EVER version and emitted [first_version, now), spanning every prior owner's tenure and disclosing their rdap_access_events (police/CERT lookups) to the current caller — the cross-registrant leak R5c/R7/N3 forbid. Fail closed instead: when the caller currently owns the domain but the walk did not establish their current tenure start, anchor the open interval to the most recent version the timeline actually shows the caller as effective registrant (backward scan), and if the caller never appears as registrant in the recorded history, emit NO open interval — under- disclose rather than risk exposing a prior owner's accesses. The normal case (walk established current tenure) is unchanged. Regression coverage (this path was untested — all prior fixtures had well-formed timelines): domains(:orphan) — john owns it live but the timeline only records prior owner jane; asserts jane's prior-tenure event is excluded (fail closed, no interval). domains(:partial) — john appears earlier (2021) then it's handed back to jane (2022) with no john re-acquire row; asserts only events at/after john's last effective-registrant version (2021) are disclosed, jane's pre-2021 event excluded. S2: order the timeline query by (:created_at, :id) for a deterministic tie-break so two same-instant registrant changes cannot order nondeterministically and invert an interval. S3: add item_type: 'Domain' to the timeline where so the (item_type, item_id) index's leading column is used and the scope is explicit (correctness-neutral). Fixture bookkeeping: the two new john-owned spec13_registrar domains add +2 to john's registrant domain counts and +2 spec-13 domains to the market-share denominator; updated the specific count/percentage assertions with comments, and regenerated the golden domain_versions.csv for the added log_domains rows. --- .../registrant_access_events_query.rb | 38 +++++++++++++-- test/fixtures/domains.yml | 35 ++++++++++++++ test/fixtures/files/domain_versions.csv | 4 ++ test/fixtures/log_domains.yml | 47 ++++++++++++++++++- test/fixtures/rdap_access_events.yml | 45 ++++++++++++++++++ .../registrant/registrant_api_domains_test.rb | 6 ++- .../api/v1/registrant/access_events_test.rb | 28 +++++++++++ .../api/v1/registrant/domains_test.rb | 10 ++-- .../repp/v1/stats/market_share_test.rb | 17 +++---- 9 files changed, 210 insertions(+), 20 deletions(-) diff --git a/app/services/registrant_access_events_query.rb b/app/services/registrant_access_events_query.rb index ed964a6076..eaee73e9eb 100644 --- a/app/services/registrant_access_events_query.rb +++ b/app/services/registrant_access_events_query.rb @@ -51,7 +51,8 @@ def cutoff # the effective registrant of item_id = @domain.id was one of @own_ids. Built from the # ordered log_domains timeline for this item_id. def own_tenure_intervals - rows = Version::DomainVersion.where(item_id: @domain.id).order(:created_at) + rows = Version::DomainVersion.where(item_type: 'Domain', item_id: @domain.id) + .order(:created_at, :id) .pluck(:created_at, Arel.sql("object_changes->>'registrant_id'"), Arel.sql("object->>'registrant_id'")) @@ -85,11 +86,22 @@ def build_intervals(rows) current_owned = owned_now end - # The live tail: whoever domain.registrant_id currently is closes at @now. Trust the - # live domain over the reconstructed run so the open interval is anchored to reality. + # The live tail: whoever domain.registrant_id currently is closes at @now. if own?(@domain.registrant_id) - current_start ||= rows.first&.first - intervals << [current_start, @now] + if current_start + # The walk already established the caller's current, still-open tenure — close it at @now. + intervals << [current_start, @now] + else + # FAIL CLOSED (R5c/R7/N3): the LIVE domain says the caller owns, but the reconstructed + # timeline never left the caller as the effective registrant (e.g. the acquiring version + # row is missing, or its object_changes was empty so carry-forward kept the prior owner). + # Do NOT anchor to rows.first (the whole-domain start) — that would span every prior + # owner's tenure and leak their accesses. Anchor only to the most recent version the + # timeline actually shows the caller as effective registrant; if none exists, emit NO + # open interval (under-disclose rather than risk exposing a prior owner's events). + anchor = last_own_registrant_change(rows) + intervals << [anchor, @now] if anchor + end elsif current_owned && current_start # Reconstructed run left the caller owning but the live domain is no longer theirs # without a closing version row — close at @now defensively (should not normally happen). @@ -99,6 +111,22 @@ def build_intervals(rows) intervals end + # Backward scan over the ordered timeline for the created_at of the most recent version whose + # EFFECTIVE registrant (same derivation as the forward walk: object_changes new value, else + # object scalar, else carry-forward) is one of the caller's own contact ids. Returns nil when + # the caller never appears as the effective registrant anywhere in the recorded history. + def last_own_registrant_change(rows) + effective = nil + last = nil + + rows.each do |created_at, changes_registrant, object_registrant| + effective = effective_registrant(changes_registrant, object_registrant, effective) + last = created_at if own?(effective) + end + + last + end + # Effective registrant AFTER an event. object_changes.registrant_id is a [old, new] # JSON array (take [1]); object.registrant_id is a scalar. Mirror the string-vs-parsed # handling in legal_document.rb:102-111. When neither is present, carry forward. diff --git a/test/fixtures/domains.yml b/test/fixtures/domains.yml index d86f3fb460..60d95d9bbe 100644 --- a/test/fixtures/domains.yml +++ b/test/fixtures/domains.yml @@ -105,3 +105,38 @@ capdomain: period_unit: m uuid: 5c9a0d1e-2f3b-4a6c-8d7e-9f0a1b2c3d4e created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED regression: john CURRENTLY owns this domain (registrant: john) but its +# log_domains timeline NEVER records john becoming registrant — the only recorded registrant is +# the prior owner (jane). The old fail-OPEN code anchored the live tail to rows.first (2020) and +# leaked jane's prior-tenure access event; the fail-closed fix emits NO interval here. +orphan: + name: orphan.test + name_puny: orphan.test + name_dirty: orphan.test + registrar: spec13_registrar + registrant: john + transfer_code: 7be21ac + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 8e2f7c3d-1f4b-4d6e-ac8f-2b3c4d5e6f70 + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED sub-case: john CURRENTLY owns this domain, and the timeline DOES show john +# as effective registrant earlier (2021), but the LAST recorded registrant-change hands it back +# to jane (2022) with no row recording john re-acquiring. The fail-closed fix anchors the live +# tail to john's most-recent effective-registrant version (2021), so only events at/after 2021 +# are disclosable and jane's pre-2021 prior-tenure event stays excluded. +partial: + name: partial.test + name_puny: partial.test + name_dirty: partial.test + registrar: spec13_registrar + registrant: john + transfer_code: 4de89bc + valid_to: 2035-07-05 + period: 1 + period_unit: m + uuid: 9f3a8d4e-2a5c-4e7f-bd90-3c4d5e6f7081 + created_at: <%= Time.zone.parse('2020-01-01') %> diff --git a/test/fixtures/files/domain_versions.csv b/test/fixtures/files/domain_versions.csv index ea0d88b8f8..d5a0de8140 100644 --- a/test/fixtures/files/domain_versions.csv +++ b/test/fixtures/files/domain_versions.csv @@ -10,7 +10,11 @@ cinema.test,John,Good Names,create,2023-09-04 21:00:00 metro.test,Jack,Good Names,create,2023-09-04 21:00:00 ,John,,update,2022-12-31 22:00:00 ,Jane,,update,2021-12-31 22:00:00 +,Jane,,update,2021-12-31 22:00:00 +,John,,update,2020-12-31 22:00:00 ,John,,update,2020-12-31 22:00:00 +orphan.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 +partial.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 mytenure.test,Jane,Spec13 Registrar,create,2019-12-31 22:00:00 capdomain.test,John,Spec13 Registrar,create,2019-12-31 22:00:00 ,test_code,Best Names,update,2018-04-23 15:50:48 diff --git a/test/fixtures/log_domains.yml b/test/fixtures/log_domains.yml index a959ea8912..265ab8f039 100644 --- a/test/fixtures/log_domains.yml +++ b/test/fixtures/log_domains.yml @@ -147,4 +147,49 @@ capdomain_create: name: [null, 'capdomain.test'] registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:john) %>] - created_at: <%= Time.zone.parse('2020-01-01') %> \ No newline at end of file + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED regression: domains(:orphan). The ONLY recorded registrant-change is the +# create → jane (prior owner). No row records john (the CURRENT live registrant) acquiring it, so +# the reconstructed walk never leaves the caller as the effective registrant. Fail-closed: emit +# no interval; jane's prior-tenure access event must NOT leak to john. +orphan_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:orphan) %> + item_type: Domain + event: create + object_changes: + name: [null, 'orphan.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +# spec 13 FAIL-CLOSED sub-case: domains(:partial). jane creates (2020), john acquires (2021), +# then it is transferred back to jane (2022) — and NO row records john re-acquiring even though +# the live domain says john owns it now. Fail-closed anchors the live tail to john's last +# effective-registrant version (2021), so an event at/after 2021 is disclosed while jane's +# pre-2021 prior-tenure event stays excluded. +partial_create: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: create + object_changes: + name: [null, 'partial.test'] + registrar_id: [null, <%= ActiveRecord::FixtureSet.identify(:spec13_registrar) %>] + registrant_id: [null, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2020-01-01') %> + +partial_to_john: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:jane) %>, <%= ActiveRecord::FixtureSet.identify(:john) %>] + created_at: <%= Time.zone.parse('2021-01-01') %> + +partial_back_to_jane: + item_id: <%= ActiveRecord::FixtureSet.identify(:partial) %> + item_type: Domain + event: update + object_changes: + registrant_id: [<%= ActiveRecord::FixtureSet.identify(:john) %>, <%= ActiveRecord::FixtureSet.identify(:jane) %>] + created_at: <%= Time.zone.parse('2022-01-01') %> \ No newline at end of file diff --git a/test/fixtures/rdap_access_events.yml b/test/fixtures/rdap_access_events.yml index 8abfbacf5b..c6ad9cdb2b 100644 --- a/test/fixtures/rdap_access_events.yml +++ b/test/fixtures/rdap_access_events.yml @@ -138,6 +138,51 @@ mytenure_current_in_window: request_id: req-0017 created_at: <%= 1.minute.ago.to_s(:db) %> +# --- spec 13 FAIL-CLOSED regression events for domains(:orphan) = orphan.test --- +# john currently owns orphan.test, but the timeline only records jane (prior owner). This police +# lookup happened during jane's recorded tenure. The old fail-OPEN tail would have leaked it to +# john via a fabricated [2020, now) interval; the fail-closed fix emits NO interval -> ABSENT. +orphan_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: orphan.test + caller_ip: 192.0.2.40 + result_code: 200 + organization_name: police + accessor_name: Orphan Prior Owner Access + category: police + grant_ref: d4e5f6a7-0000-0000-0000-000000000020 + request_id: req-0020 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# --- spec 13 FAIL-CLOSED sub-case events for domains(:partial) = partial.test --- +# jane's prior tenure [2020-01-01, 2021-01-01): BEFORE john's last effective-registrant version +# (2021) -> ABSENT. +partial_prior_owner: + requested_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + domain_name: partial.test + caller_ip: 192.0.2.41 + result_code: 200 + organization_name: cert_team + accessor_name: Partial Prior Owner Access + category: cert + grant_ref: d4e5f6a7-0000-0000-0000-000000000021 + request_id: req-0021 + created_at: <%= Time.zone.parse('2020-06-01 10:00:00') %> + +# during/after john's effective-registrant version (2021): the fail-closed anchor is 2021-01-01, +# so this event is inside the emitted [2021-01-01, now) interval -> PRESENT. +partial_after_john_acquire: + requested_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + domain_name: partial.test + caller_ip: 192.0.2.42 + result_code: 200 + organization_name: police + accessor_name: Partial Caller Access + category: police + grant_ref: d4e5f6a7-0000-0000-0000-000000000022 + request_id: req-0022 + created_at: <%= Time.zone.parse('2021-06-01 10:00:00') %> + # --- AC12: >100 disclosable events on domains(:capdomain) = capdomain.test (john's whole life), # all past the delay cutoff, so the response is capped at 100 most-recent DESC. --- <% (1..101).each do |i| %> diff --git a/test/integration/api/registrant/registrant_api_domains_test.rb b/test/integration/api/registrant/registrant_api_domains_test.rb index bbf14930f5..8c08491284 100644 --- a/test/integration/api/registrant/registrant_api_domains_test.rb +++ b/test/integration/api/registrant/registrant_api_domains_test.rb @@ -103,7 +103,8 @@ def test_domains_total_if_an_incomplete_list_is_returned response_json = JSON.parse(response.body, symbolize_names: true) assert_equal response_json[:domains].length, response_json[:count] # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) - assert_equal response_json[:total], 7 + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json[:total], 9 end def test_root_accepts_limit_and_offset_parameters @@ -118,7 +119,8 @@ def test_root_accepts_limit_and_offset_parameters response_json = JSON.parse(response.body, symbolize_names: true) # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) - assert_equal(6, response_json[:domains].count) + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal(8, response_json[:domains].count) end def test_root_does_not_accept_limit_higher_than_200 diff --git a/test/integration/api/v1/registrant/access_events_test.rb b/test/integration/api/v1/registrant/access_events_test.rb index 6b8dc14aec..9180787bff 100644 --- a/test/integration/api/v1/registrant/access_events_test.rb +++ b/test/integration/api/v1/registrant/access_events_test.rb @@ -188,6 +188,34 @@ def test_read_only_no_store_or_related_writes end end + # FAIL-CLOSED regression (M1): the caller CURRENTLY owns domains(:orphan) but the log_domains + # timeline never records john becoming registrant — only the prior owner (jane) appears. The old + # fail-OPEN live tail anchored to rows.first (2020) and emitted [2020, now), leaking jane's + # prior-tenure police lookup. The fix emits NO interval, so nothing is disclosed. + def test_fail_closed_caller_never_in_timeline_excludes_prior_owner_event + orphan_uuid = domains(:orphan).uuid + body = get_events(orphan_uuid) + + assert_equal [], body, + 'caller absent from the recorded timeline must disclose NO events (fail closed)' + refute_includes accessed_ats(body), iso('2020-06-01 10:00:00') + end + + # FAIL-CLOSED sub-case (M1): the caller CURRENTLY owns domains(:partial) and DID appear as + # effective registrant earlier (2021), but the last recorded change hands it back to jane (2022) + # with no john re-acquire row. The fix anchors the live tail to john's last effective-registrant + # version (2021): only events at/after 2021 are disclosed, jane's pre-2021 event stays excluded. + def test_fail_closed_caller_earlier_in_timeline_anchors_to_last_own_version + partial_uuid = domains(:partial).uuid + body = get_events(partial_uuid) + ats = accessed_ats(body) + + assert_includes ats, iso('2021-06-01 10:00:00'), + 'event after the caller last-own version (2021) must be disclosed' + refute_includes ats, iso('2020-06-01 10:00:00'), + 'prior-owner event before the caller last-own version must be excluded' + end + def test_owned_domain_zero_events_returns_200_empty_array # domains(:hospital) is john's but has no rdap_access_events rows (hospital.test). empty_domain = domains(:hospital) diff --git a/test/integration/api/v1/registrant/domains_test.rb b/test/integration/api/v1/registrant/domains_test.rb index a31bf248e9..30f6f60ba5 100644 --- a/test/integration/api/v1/registrant/domains_test.rb +++ b/test/integration/api/v1/registrant/domains_test.rb @@ -22,8 +22,9 @@ def test_get_default_counts_of_domains response_json = JSON.parse(response.body) # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) - assert_equal response_json['total'], 6 - assert_equal response_json['count'], 6 + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json['total'], 8 + assert_equal response_json['count'], 8 end def test_get_default_counts_of_direct_domains @@ -34,8 +35,9 @@ def test_get_default_counts_of_direct_domains response_json = JSON.parse(response.body) # +2: spec-13 mytenure/capdomain fixtures (both registrant: john) - assert_equal response_json['total'], 6 - assert_equal response_json['count'], 6 + # +2: spec-13 fail-closed regression fixtures orphan/partial (both registrant: john) + assert_equal response_json['total'], 8 + assert_equal response_json['count'], 8 end private diff --git a/test/integration/repp/v1/stats/market_share_test.rb b/test/integration/repp/v1/stats/market_share_test.rb index 074058e441..6a8bd2f72e 100644 --- a/test/integration/repp/v1/stats/market_share_test.rb +++ b/test/integration/repp/v1/stats/market_share_test.rb @@ -34,17 +34,18 @@ def test_shows_market_share_growth_rate_data assert_equal 1000, json[:code] assert_equal 'Command completed successfully', json[:message] - # domains counts are unchanged: the spec-13 mytenure/capdomain fixtures sit on a - # test_registrar, which serialize_growth_rate_result excludes. calculate_market_share, - # however, divides by the total across ALL registrars (test ones included), so the two - # extra domains enlarge the denominator and lower each shown share: - # prev (2023-11): Good 3 / (3 + 0 + 2 spec13) = 60.0 % - # today (07.26): Good 2 / (2 + 4 + 2 spec13) = 25.0 %, Best 4 / 8 = 50.0 % + # domains counts are unchanged: the spec-13 test_registrar fixtures are excluded by + # serialize_growth_rate_result. calculate_market_share, however, divides by the total across + # ALL registrars (test ones included), so the spec-13 domains enlarge the denominator and + # lower each shown share. There are now 4 spec-13 domains (mytenure/capdomain + + # orphan/partial, all created 2020-01-01, so present at both dates): + # prev (2023-11): Good 3 / (3 + 0 + 4 spec13) = 42.9 % + # today (07.26): Good 2 / (2 + 4 + 4 spec13) = 20.0 %, Best 4 / 10 = 40.0 % assert_equal json[:data], prev_data: { name: prev_date, domains: [['Good Names', 3], ['Best Names', 0]], - market_share: [['Good Names', 60.0], ['Best Names', 0.0]] }, + market_share: [['Good Names', 42.9], ['Best Names', 0.0]] }, data: { name: @today, domains: [['Good Names', 2], ['Best Names', 4]], - market_share: [['Good Names', 25.0], ['Best Names', 50.0]] } + market_share: [['Good Names', 20.0], ['Best Names', 40.0]] } end end