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:
- Where does the Layer-1 proxy live in Console's module tree?
- What value does
X-Merge-Permission-Grantedcarry? - 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
- Follows established Console blueprint pattern — feature-developer has a clear model
(
customers.pystructure) to follow. - Zero new Raptor code required for #3249 — Raptor's enforcement was already complete.
@require_rbac_permissiondecorator reuses the existing RBAC BFS traversal (including inheritance), so all five merge permissions work with Console's existing group structure without any new middleware.- No Console migration required for #3249 — all RBAC data was seeded in migration 0215.
- Console proxy is stateless — no Console-side merge state to diverge from Raptor truth.
Negative / risks
- Resend endpoint gap: Raptor's
internal_merges.pystub does not implementPOST /api/internal/merges/<id>/resend. The Console detail view specifies "Resend code" buttons. Feature-developer on #3769 must confirm whether this endpoint exists from #3247's scope before claiming the sub-card, or file a Raptor patch card. - Actor hash consistency: Console sends
X-Merge-CS-Actor-Idas SHA-256 ofadmin.email. Raptor stores it via_hash_actor()which is also SHA-256. If Raptor ever changes its hashing algorithm, Console must match. Cross-service coupling on hash algorithm is minor but documented. - RAPTOR_INTERNAL_BASE_URL misconfiguration: If the env var is missing, all proxy calls fail with 500. Console should surface a clear error: "Raptor endpoint not configured — contact ops." Feature-developer must add this misconfiguration guard.
Neutral
- Console proxy adds one RBAC DB query and one HTTP call to Raptor per merge API request. Merge operations are low-frequency CS actions (not a hot path), so this is acceptable.
- The Antlers verify page (
/merge/verify/<id>) calls Raptor directly on the customer-facing base URL — not through Console. This is the correct architectural separation: Console is the CS operator surface; Antlers is the customer surface.
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
- PII collected: None. Console proxy writes no merge data to Console DB.
X-Merge-CS-Actor-Idis SHA-256 of operator email — not recoverable to plaintext. - Retention period: No new data retained by Console for this feature.
- Deletion on DSR: Not applicable — Console holds no merge-related PII.
- Audit trail: Console writes
console_audit_eventsrows for each operator action (console.merge.initiate,.cancel,.reversal_initiate,.reversal_approve,.reversal_abort). Non-fatal parallel writes — Raptor's M5-gated audit is the authoritative record. - Stored credentials:
ADMIN_SERVICE_TOKENread from env at request time. Never written to Console DB. Never logged. Rotatable without redeploy. - Breach notification path: Console proxy holds no PII. Not in scope beyond parent design's Raptor-side coverage (ADR-0113 §Security / GDPR checklist).
- Secrets location + rotation:
ADMIN_SERVICE_TOKENandRAPTOR_INTERNAL_BASE_URLin Infisical/SSM. Rotatable via env-var update, no code redeploy required. - Kill-switch:
FLAG_ACCOUNT_MERGE=offcauses all Console merge routes to return 404. Antlers verify page returns 404. No UI or API surface is reachable.
Revisit when
- Queue Phase 2 ships and customer identity moves to Queue. The Console proxy target
URL (
RAPTOR_INTERNAL_BASE_URL) may need a Queue-aware variant, but the proxy blueprint structure does not change. - The RBAC inheritance model changes significantly (e.g. permission sets replace named
permissions).
@require_rbac_permissionwould need updating, but that is a system-wide change, not merge-specific. - Console is replaced by a different framework (unlikely in v1 window).