Raxx · internal docs

internal · gated

ADR 0132 — Account Merge: Console Layer-1 RBAC Proxy Design

Status: Accepted Date: 2026-06-21 UTC Deciders: software-architect agent (answers groomer-flagged open questions on #3249) Scope: Console (Flask, merges.py blueprint), Raptor (internal_merges.py — no changes), Antlers (raxx-next, customer verify page)


Context

3253 (PR #3760) shipped Raptor's internal merge routes and explicitly deferred the

Console Layer-1 RBAC proxy to #3249. Three open questions blocked #3249 from being claimed:

  1. Where does the Layer-1 proxy live in Console's module tree?
  2. What value does X-Merge-Permission-Granted carry?
  3. Does Raptor enforce the header's presence, or is that new work in #3249?

The groomer required design answers before issuing ready-for-dev. This ADR records those three decisions plus the rationale that makes them durable.


Decision

D1 — New merges.py blueprint, not middleware

The Console Layer-1 RBAC proxy is a new standalone blueprint: console/app/blueprints/merges.py. It uses the existing @require_rbac_permission decorator (already implemented in console/app/middleware/rbac.py) for the permission gate, and a helper function _proxy_to_raptor(path, method, permission, body) for the HTTP forward. This matches the established Console pattern for domain-specific route groups (customers.py, billing.py, flags.py).

Middleware extension was rejected because the per-route permission string (customers:merge:initiate, customers:merge:cancel, etc.) varies by route and is a request-routing concern, not a cross-cutting pre-request concern. Embedding it in middleware would require a URL-to-permission mapping table — unnecessary complexity when decorators already handle this cleanly per route.

D2 — X-Merge-Permission-Granted carries the exact permission string

Console injects the exact customers:merge:<X> string it just verified against the RBAC tables. Raptor's _check_merge_permission(permission) in internal_merges.py performs _hmac.compare_digest(granted, permission) — a constant-time string equality check against the route-required permission string.

Alternatives rejected: - Signed token: Would require a shared signing key and a decode step in Raptor, coupling Raptor to Console's key rotation. Unnecessary given Raptor already validates X-Admin-Service-Token as the trust anchor. - Role enum: Raptor explicitly does not query rbac_* tables (those tables live in the Console DB). A role enum would require a mapping table in Raptor — undesirable.

D3 — Raptor already enforces X-Merge-Permission-Granted; #3249 is Console-only

_require_merge_permission(permission) is applied to every /api/internal/merges/* route in the already-merged PR #3760. #3249 is entirely Console-side: RBAC check, header injection, UI rendering, response forwarding. No Raptor code changes ship with

3249.


Language choice rationale

Service: No new service introduced. The merge proxy is a new blueprint within the existing Console Flask application (Python). No new process or daemon.

Language tier: Tier 2 — Python.

Rationale: Console is already Python/Flask (ADR-0004). The Layer-1 proxy is standard Flask request-handling with HTTP client calls to Raptor. There are no sub-5ms latency requirements, no memory-safety requirements that Python cannot meet, and no operator designation as Tier 1. The proxy adds latency only in proportion to the Raptor round-trip; the RBAC DB query is a single indexed join with a per-request in-memory cache already implemented in _admin_has_rbac_permission.

API contract portability (Tier 2): Not applicable — this is a blueprint within an existing service, not a standalone deployable. The REST contract between Console and Raptor is already defined in ADR-0113 and docs/architecture/account-merge-2026-06-05.md.


Consequences

Positive

Negative / risks

Neutral


Alternatives considered

Alternative A: Embed proxy logic in existing customers.py blueprint

Rejected: customers.py is already large (1600 lines) and the merge proxy is a coherent domain boundary. A dedicated blueprint keeps customer lifecycle operations separate from merge operations and makes the permission-to-route mapping clear at a glance.

Alternative B: Add require_merge_permission middleware layer

Rejected: the permission string varies per route, making a URL-pattern table necessary. Decorators express this more clearly and follow the established pattern. Middleware-level enforcement would also require the blueprint routes to still call a proxy helper — no code is saved.

Alternative C: Console stores a copy of merge state in its own DB

Rejected: introduces state synchronisation risk between Console DB and Raptor DB. Console is already a thin proxy to Raptor for customer detail, session revocation, and lifecycle operations (customer_detail.py, customer_lifecycle.py). Merge should follow the same stateless-proxy pattern.

Alternative D: Signed X-Merge-Permission-Granted token

Rejected: see D2 rationale. Adds a shared signing key, a decode path in Raptor, and rotation coupling. Plain permission string with service-token as trust anchor is sufficient and already implemented.


Security / GDPR checklist


Revisit when