Skip to content

Internal RDAP data API + privileged-grant store (backend)#2937

Open
OlegPhenomenon wants to merge 15 commits into
masterfrom
feat/rdap-data-api
Open

Internal RDAP data API + privileged-grant store (backend)#2937
OlegPhenomenon wants to merge 15 commits into
masterfrom
feat/rdap-data-api

Conversation

@OlegPhenomenon

Copy link
Copy Markdown
Contributor

Internal RDAP data API + privileged-grant store (backend only)

The public .ee RDAP service must stop reading the registry DB directly and instead consume a registry-side API (lead decision 2026-06-25). RDAP already shipped the consumer side (a client interface + an in-memory mock). This PR is the real API behind that mock.

Spec (authoritative): lives in the RDAP repo — specs/specs/completed/08-registry-side-rdap-api/ (proposal, requirements, api-contract.md, registry-grounding.md). In-repo copy: doc/api/v1/rdap.md.

Endpoints (new namespace /api/v1/internal/rdap/*)

Method · Path Returns
GET …/domains/:name full privileged domain (registrant + admin/tech contacts + glue + DNSSEC)
GET …/registrars/:code {code,name,phone,website} only
GET …/nameservers/:host {hostname,hostname_puny} (DISTINCT-collapsed)
GET …/grants/active?subject= the single active grant, or 404
POST …/grants/:id/touch best-effort last_used_at (204)

What's in it

  • Api::V1::Internal::BaseController + Api::V1::Internal::Rdap::{Domains,Registrars,Nameservers,Grants}Controller.
  • Auth = pre-shared key + IP-allowlist (the authenticate_shared_key pattern; secure_compare; fail-closed when the key is unset). mTLS is a later prod-hardening step. ENV: rdap_internal_api_shared_key, rdap_internal_api_allowed_ips.
  • Net-new RdapPrivilegeGrant model + migration (categories police/cert/ria/eis_internal, statuses active/revoked/suspended, server-authoritative active_for_subject). The registry had no authority-access concept before.
  • Secrets-free Serializers::Rdap::Domain — never emits transfer_code/auth_info/registrant_verification_token (does NOT reuse Serializers::Repp::Domain(sponsored: true), which leaks transfer_code). Returns raw disclosure flags (union) — RDAP applies disclosure policy, not the registry.

Scope

Backend only — no admin UI this PR (grants seeded via fixtures/console; admin CRUD is a later spec).

Tests

minitest integration + model tests. Verified locally inside the docker-images stack (docker exec docker-images-registry-1): 36 runs, 101 assertions, 0 failures, 0 errors. structure.sql updated with only the new table and validated by reloading from scratch (db:test:prepare).

⚠️ Please review before merging. The open questions in doc/api/v1/rdap.md were already resolved with the RDAP lead; the namespace/URL is the one thing easiest to change if you prefer a different home.

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.
Auth = pre-shared key + IP-allowlist; backend-only (no admin UI this spec);
categories/states confirmed; defaults for namespace/touch/delete-date/disclosure.
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).
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.
…rar test

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).
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.
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.
…it history

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.
…int (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.
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 [].
Commit 9dae4a3 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.
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.
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.
…r owner's accesses

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant