Cross-Tenant Drone Visibility

Architecture · Last updated June 2026  |  Implementation: cross-tenant-partnership-implementation.md

Problem Statement

Two tenants (A and B) want to cooperate so that Tenant A can see Tenant B's live drone positions on their RTM frontend.

RequirementDetail
ScopeLive telemetry only — no account merging, no shared namespaces, no auth changes
AutomationFully managed through the RTM UI and partnership API — no manual infrastructure changes per partnership
LatencyPartner drones appear or disappear within 10 seconds of approval or denial
IsolationNo restarts of any existing service on either tenant

Core Principle

B's drone telemetry is already in the shared Kafka cluster as B.telemetry.enriched, produced by B's FlightBinder (Flink) pipeline. It just needs to be routed to A's EMQX — already in canonical RTTP schema, no additional transform required at consume time.

The unit of a partnership at the infrastructure level is a single KafkaConnector resource in the shared-kafka namespace.
Creating it = start streaming  ·  Deleting it = stop streaming

Why Kafka — Not a Direct EMQX Bridge

A direct EMQX-to-EMQX bridge was considered first: Tenant A's EMQX subscribes to Tenant B's EMQX and re-publishes messages locally. EMQX 5 supports this natively and it has lower latency.

Why it was rejected: Tenant A's EMQX needs a username and password on Tenant B's EMQX to connect. This creates a cross-tenant credential dependency — Tenant B must create a dedicated EMQX user for every partner, transfer those credentials to Tenant A, and delete the user on revoke. Tenant B has an operational dependency on every active partnership.

With the Kafka approach, Tenant B does nothing. Its telemetry is already in Kafka. The KafkaConnector is created and deleted entirely within shared-kafka by Tenant A's controller — Tenant B is never touched.

Direct EMQX bridgeKafka connector
LatencyMinimal~100–200 ms extra hop
Tenant B involvementMust create EMQX userZero
Credential sharingCross-tenant EMQX credentialsNone
RevokeDelete EMQX user + bridge configDelete one KafkaConnector
InfrastructureNew data pathReuses existing Kafka topics

Data Model

Each tenant's PostgreSQL database has a partnerships schema with two tables.

approved_partners — allowlist

Manually populated by a tenant admin. Only tenants listed here can submit a partnership request. Acts as an access control gate before any request is considered.

partner_tenant  TEXT PRIMARY KEY
added_at        TIMESTAMPTZ NOT NULL DEFAULT now()

approval_requests — request lifecycle log

Every incoming partnership request with full status history.

id                  SERIAL PRIMARY KEY
requesting_tenant   TEXT NOT NULL
status              TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending', 'enabled', 'suspended', 'denied'))
requested_at        TIMESTAMPTZ NOT NULL DEFAULT now()
status_changed_at   TIMESTAMPTZ
notes               TEXT

Status state machine

pending ──approve──▶ enabled ──suspend──▶ suspended
                       ▲                      │
                       └────────resume─────────┘

pending ──deny──▶ denied  ← terminal, no further transitions
Condition / ActionOutcome
Tenant not in approved_partners403 immediately — nothing written
Existing pending request409 — new request allowed only after denied
Existing suspended request409 — use /resume instead of submitting a new request
Admin approvesKafkaConnector created → status enabled
Admin deniesKafkaConnector deleted if present → status denied (terminal)
Admin suspendsKafkaConnector deleted → status suspended; tenant stays on allowlist
Admin resumesKafkaConnector recreated → status enabled

Component Diagram

┌─────────────────────────── Tenant A namespace ──────────────────────────────┐
│                                                                               │
│  RTM Frontend (Next.js)                                                       │
│      │  POST   /partnerships/approved-partners   → add to allowlist           │
│      │  POST   /partnerships/requests            → submit request (by B)      │
│      │  PATCH  /partnerships/requests/{id}/approve → admin approves           │
│      │  PATCH  /partnerships/requests/{id}/deny    → admin denies             │
│      ▼                                                                        │
│  Partnership Controller  (Python FastAPI)                NEW SERVICE      │
│      │  ServiceAccount: partnership-controller-svc                            │
│      │  ├─ reads:  emqx-user-svc-mqtt-client (k8s secret, own ns)            │
│      │  ├─ reads:  postgres-partnership-credentials (k8s secret, own ns)      │
│      │  ├─ writes: approved_partners, approval_requests (PostgreSQL, own ns)  │
│      │  └─ calls:  Kubernetes API → KafkaConnector in shared-kafka            │
│      │             (no outbound calls to other tenants)                       │
│                                                                               │
│  PostgreSQL                                                                   │
│      schema: partnerships                                                     │
│      tables: approved_partners, approval_requests                             │
│                                                                               │
│  Tenant-A EMQX  (tenant-emqx.A.svc:1883)                                    │
│      topics (per active partner serial):                                      │
│        v1/partner/B/aircraft/{serial}/drones/telemetry  ← position + status │
│        v1/partner/B/aircraft/{serial}/drones/networkid  ← pilot + mission   │
│        v1/partner/B/aircraft/{serial}/oi/declaration    ← OI boundary       │
│      ▼                                                                        │
│  RTM Frontend subscribes: v1/partner/+/aircraft/+/#                          │
│      renders B's drones: grey icon, "Partner: B" label                       │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────── shared-kafka namespace ──────────────────────────┐
│                                                                               │
│  B.telemetry.raw   (Kafka topic — already exists, produced by B's pipeline)  │
│      │                                                                        │
│      ▼                                                                        │
│  partner-B-to-A-mqtt-sink   (KafkaConnector)         CREATED ON APPROVE  │
│      class:      Lenses MqttSinkConnector                                     │
│      reads:      B.telemetry.enriched  ·  B.networkid.dd  ·  B.flightplan   │
│      transform:  InsertField  partner_tenant = "B"                            │
│      key:        Kafka message key = aircraft serial → builds per-serial path │
│      publishes:  v1/partner/B/aircraft/{serial}/drones/telemetry  → A EMQX  │
│                  v1/partner/B/aircraft/{serial}/drones/networkid  → A EMQX   │
│                  v1/partner/B/aircraft/{serial}/oi/declaration    → A EMQX   │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────── Tenant B namespace ──────────────────────────────┐
│                                                                               │
│  (B's Flink, EMQX, and primary pipeline are never touched)                   │
│                                                                               │
│  B's Partnership Controller                                                   │
│      POST /partnerships/requests  → B submits a request to A's controller    │
│      (B calls A's API — not the other way around)                            │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘

Topic Mappings

Topic patterns are configured in Helm values — no image rebuild required to add a topic. {partner_tenant} is substituted at connector-creation time. The Kafka message key (aircraft serial) is used by the connector to construct the per-aircraft MQTT path — one connector handles all aircraft for a partnership via a multi-statement KCQL.

connector:
  topicMappings:
    - kafkaTopicTemplate: "{partner_tenant}.telemetry.enriched"
      mqttTopicTemplate: "v1/partner/{partner_tenant}/aircraft/{serial}/drones/telemetry"

    - kafkaTopicTemplate: "{partner_tenant}.networkid.dd"
      mqttTopicTemplate: "v1/partner/{partner_tenant}/aircraft/{serial}/drones/networkid"

    - kafkaTopicTemplate: "{partner_tenant}.flightplan"
      mqttTopicTemplate: "v1/partner/{partner_tenant}/aircraft/{serial}/oi/declaration"
Source topics must be accessible in shared-kafka. Confirm that Tenant B's telemetry.enriched, networkid.dd, and flightplan topics are produced into the shared-kafka namespace (or mirrored there) before the connector is created. Using the enriched topic means payloads are already in canonical RTTP schema — the frontend receives correct units and decoded fields with no additional transform.

MQTT Topic Design

Partner topics use a partner root that is entirely separate from own-drone topics. Because every EMQX broker is already scoped to a single tenant, the {tenant} segment is implicit in the connection endpoint — repeating it in the partner path would be redundant. Instead, the literal keyword partner occupies segment 2 in place of {tenant}, giving the frontend an immediate, unambiguous signal that a message originates from a cross-tenant feed without inspecting the payload.

Own-drone topics: v1/{tenant}/aircraft/{serial}/…  ·  Partner topics: v1/partner/{partnerTenant}/aircraft/{serial}/…
The keywords tenant and partner at segment 2 are the sole differentiator. A tenant can have multiple active partners simultaneously — the wildcard v1/partner/+/aircraft/+/# captures all subtopics for all active partners in a single frontend subscription. New partnerships appear and disappear without any subscription change in the client.
TopicProducerConsumerRate
v1/A/aircraft/+/drones/telemetry A's FlightBinder A's frontend — own fleet map layer 10 Hz
v1/partner/B/aircraft/{serial}/drones/telemetry partner-B-to-A connector A's frontend — partner map layer (grey icon, "Partner: B") 10 Hz
v1/partner/B/aircraft/{serial}/drones/networkid partner-B-to-A connector A's frontend — partner drone detail panel (pilot, callsign, mission) ~1 Hz
v1/partner/B/aircraft/{serial}/oi/declaration partner-B-to-A connector A's frontend — partner OI boundary overlay + conformance status On change
v1/partner/+/aircraft/+/# any active partner connector A's frontend — wildcard covering all partners, all three subtopics
ACL: Web app clients may subscribe to v1/partner/+/aircraft/+/# — publish is denied. The v1/partner/# namespace is exclusively written by KafkaConnectors in shared-kafka; A's own Flink jobs never publish here, enforcing a clean boundary between own-fleet output and inbound partner streams.

RBAC Model

The Partnership Controller's ServiceAccount lives in the tenant namespace but manages KafkaConnectors in shared-kafka. A cross-namespace Role and RoleBinding are created in shared-kafka:

Tenant-A namespace                        shared-kafka namespace
─────────────────────────────             ──────────────────────────────────────
ServiceAccount                            Role
  partnership-controller-svc  ←────────   partnership-controller-A
                                            rules:
                                              kafkaconnectors:
                                                get, list, create,
                                                update, patch, delete

                                          RoleBinding
                                            subject: SA from Tenant-A ns
                                            roleRef: partnership-controller-A
Each tenant gets its own named Role and RoleBinding in shared-kafka. Connector isolation is enforced by naming convention: partner-{source}-to-{dest}-mqtt-sink.

Authentication

All endpoints on the partnership-controller require a valid Keycloak JWT. Token validation happens in the FastAPI application — no ingress-level auth is involved, making it consistent whether the controller is reached via its public ingress or over ClusterIP.

Keycloak Realm and Group Structure

A single Keycloak realm (flysafe at auth2.airmarket.io) serves all tenants. Tenants are separated by Keycloak Groups with an admins subgroup per tenant:

/airmarket
    /admins    ← full access to all partnership endpoints
    /users     ← no access to the partnership API
/sandbox
    /admins    ← full access to all partnership endpoints
    /users     ← no access to the partnership API

Keycloak embeds group membership in the JWT groups claim:

{
  "iss": "https://auth2.airmarket.io/realms/flysafe",
  "groups": ["/sandbox", "/sandbox/admins"],
  "sub": "user-uuid"
}
Keycloak configuration required: Add a Groups mapper to the flysafe realm (Realm Settings → Client Scopes → Add mapper → Group Membership, claim name: groups, full group path: on, add to access token: on). Without this mapper the groups claim is absent from all tokens and every API call returns 403.

Endpoint Access Control

EndpointWho can callToken check
GET POST DELETE /approved-partners This tenant's admins /{TENANT_NAME}/admins in groups
GET /requests This tenant's admins /{TENANT_NAME}/admins in groups
PATCH /requests/{id}/approve|deny This tenant's admins /{TENANT_NAME}/admins in groups
POST /requests Any tenant's admin /{requesting_tenant}/admins in groups
The POST /requests rule verifies the caller is an admin of the tenant they name in the request body. This prevents Tenant C from impersonating Tenant B by setting requesting_tenant: "B".

JWT Validation Flow

  • 1
    Extracts the Bearer token from the Authorization header.
  • 2
    Fetches Keycloak's JWKS from KEYCLOAK_JWKS_URL (cached 5 minutes in memory).
  • 3
    Matches the token's kid header to a key in the JWKS.
  • 4
    Verifies RS256 signature, expiry, and iss against KEYCLOAK_ISSUER.
  • 5
    Checks the groups claim for the required group membership. Returns 403 if absent.
  • 6
    On key-rotation miss: forces an immediate JWKS refresh and retries once before returning 401.

Both KEYCLOAK_JWKS_URL and KEYCLOAK_ISSUER are injected via Helm values — no image rebuild is required to change the Keycloak realm or base URL.


Flow 1 — Admin Populates the Allowlist

Done once per trusted partner. Only tenants on this list can submit requests — unknown tenants are rejected immediately with 403.

  • 1
    A's admin calls POST /partnerships/approved-partners with { "partner_tenant": "B" }.
  • 2
    Controller writes INSERT INTO approved_partners (partner_tenant='B')201 OK.

Flow 2 — B Submits a Partnership Request

  • 1
    B's admin calls POST https://partnerships.A.stage.flysafe.live/partnerships/requests with { "requesting_tenant": "B" }.
  • 2
    Controller checks approved_partners for "B". Not found → 403 (stops here). Found → continue.
  • 3
    Controller checks approval_requests for an existing pending or suspended request from "B".
    pending exists → 409 (re-submit allowed only after denied).
    suspended exists → 409 with message to use /resume on the existing request.
    Neither exists → continue.
  • 4
    INSERT INTO approval_requests (requesting_tenant='B', status='pending')201 { "id": 7, "status": "pending", ... }

Flow 3 — A's Admin Manages the Partnership

  • 1
    A's admin calls GET /partnerships/requests?status=pending to review the queue.
  • 2
    Approve: PATCH /partnerships/requests/7/approve
    Controller verifies request is pending.
    Builds and creates KafkaConnector in shared-kafka (PATCHes if 409).
    Updates status → enabled.
    Returns 200 { "status": "enabled", "partner_tenant": "B", "mqtt_wildcard": "v1/partner/B/aircraft/+/#" }.
  • 3
    Deny: PATCH /partnerships/requests/7/deny
    Controller verifies request is pending. Denied requests are terminal — no further transitions allowed.
    Deletes KafkaConnector if present.
    Updates status → denied.
    Returns 200 { "status": "denied" }. B must submit a new request to try again.
  • 4
    Suspend: PATCH /partnerships/requests/7/suspend
    Controller verifies request is enabled.
    Deletes KafkaConnector — streaming stops immediately. Tenant stays on allowlist.
    Updates status → suspended.
    Returns 200 { "status": "suspended" }.
  • 5
    Resume: PATCH /partnerships/requests/7/resume
    Controller verifies request is suspended.
    Recreates KafkaConnector — streaming restarts within seconds.
    Updates status → enabled.
    Returns 200 { "status": "enabled", "partner_tenant": "B", "mqtt_wildcard": "v1/partner/B/aircraft/+/#" }.
Within seconds of approval or resume, B's drone telemetry, mission context, and OI data begin flowing to A's EMQX under v1/partner/B/aircraft/{serial}/…. A's frontend wildcard subscription v1/partner/+/aircraft/+/# picks up all three subtopics for all active partners automatically — no subscription change required in the client.
No Kafka offset cleanup is required on suspend, deny, or revoke. The connector starts from the latest offset when recreated — correct for live tracking (no position replay).

What Is Not Touched During Any Status Change

ComponentTouched?
Tenant A's Flink jobNo
Tenant A's EMQXNo
Tenant A's MQTT-to-Kafka bridgeNo
Tenant B's entire stackNo
Any Helm releaseNo
Any DNS recordNo

REST API Surface

All endpoints at https://partnerships.{tenant}.{env}.flysafe.live/

Allowlist management

MethodPathDescription
GET /partnerships/approved-partners List all allowlisted tenants.
POST /partnerships/approved-partners Add a tenant to the allowlist. Body: { "partner_tenant": "B" }
DELETE /partnerships/approved-partners/{tenant} Remove a tenant from the allowlist.

Partnership requests

MethodPathActorDescription
POST /partnerships/requests Requesting tenant (B) Submit a request. 403 if not on allowlist. 409 if a pending request exists, or a suspended partnership exists (must resume instead).
GET /partnerships/requests A's admin List all requests. Filter with ?status=pending|enabled|suspended|denied.
PATCH /partnerships/requests/{id}/approve A's admin Approve pending request — creates KafkaConnector, sets status enabled. Optional body: { "notes": "..." }
PATCH /partnerships/requests/{id}/deny A's admin Deny pending request — terminal, no further transitions. Deletes KafkaConnector if present. Optional body: { "notes": "..." }
PATCH /partnerships/requests/{id}/suspend A's admin Suspend active partnership — deletes KafkaConnector, stops streaming. Tenant stays on allowlist.
PATCH /partnerships/requests/{id}/resume A's admin Resume suspended partnership — recreates KafkaConnector, restores streaming within seconds.

End-to-End Data Flow (Partnership Active)

B's Drone
    │
    ▼  (existing, unchanged)
B's DJI Integration Service
    │  MQTT publish
    ▼
Tenant-B EMQX  →  B's FlightBinder (Flink)  +  B's OI Conformance Tracker
    │
    ▼
Kafka (shared-kafka namespace):
    B.telemetry.enriched   ← canonical RTTP telemetry, already decoded
    B.networkid.dd         ← deduplicated networkid (pilot, mission, waypoints)
    B.flightplan           ← OI declaration + conformance state
    │
    │  ← partnership active: KafkaConnector exists
    ▼
partner-B-to-A-mqtt-sink  (KafkaConnector, shared-kafka)  ← CREATED ON APPROVE
    │  transform: InsertField  partner_tenant = "B"
    │  key: Kafka message key = aircraft serial → builds per-aircraft MQTT path
    │  connects to: tenant-emqx.A.svc.cluster.local:1883
    ▼
Tenant-A EMQX
    │  v1/partner/B/aircraft/{serial}/drones/telemetry   10 Hz
    │  v1/partner/B/aircraft/{serial}/drones/networkid   ~1 Hz
    │  v1/partner/B/aircraft/{serial}/oi/declaration     on change
    ▼
RTM Frontend (A)  via WebSocket  wss://emqx-ws.A.stage.flysafe.live/mqtt
    │  wildcard sub: v1/partner/+/aircraft/+/#
    ▼
Map renders:
    ● Blue icon  — A's own drones   (v1/A/aircraft/+/drones/telemetry)
    ● Grey icon  — B's drones       (v1/partner/B/aircraft/{serial}/…)
      label: "Partner: B"  ·  pilot + mission from networkid
      OI boundary overlay from oi/declaration + conformance colour