Cross-Tenant Drone Visibility
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.
| Requirement | Detail |
|---|---|
| Scope | Live telemetry only — no account merging, no shared namespaces, no auth changes |
| Automation | Fully managed through the RTM UI and partnership API — no manual infrastructure changes per partnership |
| Latency | Partner drones appear or disappear within 10 seconds of approval or denial |
| Isolation | No 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.
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 bridge | Kafka connector | |
|---|---|---|
| Latency | Minimal | ~100–200 ms extra hop |
| Tenant B involvement | Must create EMQX user | Zero |
| Credential sharing | Cross-tenant EMQX credentials | None |
| Revoke | Delete EMQX user + bridge config | Delete one KafkaConnector |
| Infrastructure | New data path | Reuses 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 / Action | Outcome |
|---|---|
Tenant not in approved_partners | 403 immediately — nothing written |
Existing pending request | 409 — new request allowed only after denied |
Existing suspended request | 409 — use /resume instead of submitting a new request |
| Admin approves | KafkaConnector created → status enabled |
| Admin denies | KafkaConnector deleted if present → status denied (terminal) |
| Admin suspends | KafkaConnector deleted → status suspended; tenant stays on allowlist |
| Admin resumes | KafkaConnector 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"
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.
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.
| Topic | Producer | Consumer | Rate |
|---|---|---|---|
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 | — |
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
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"
}
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
| Endpoint | Who can call | Token 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 |
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
- 1Extracts the Bearer token from the
Authorizationheader. - 2Fetches Keycloak's JWKS from
KEYCLOAK_JWKS_URL(cached 5 minutes in memory). - 3Matches the token's
kidheader to a key in the JWKS. - 4Verifies RS256 signature, expiry, and
issagainstKEYCLOAK_ISSUER. - 5Checks the
groupsclaim for the required group membership. Returns 403 if absent. - 6On 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.
-
1A's admin calls
POST /partnerships/approved-partnerswith{ "partner_tenant": "B" }. -
2Controller writes
INSERT INTO approved_partners (partner_tenant='B')→ 201 OK.
Flow 2 — B Submits a Partnership Request
-
1B's admin calls
POST https://partnerships.A.stage.flysafe.live/partnerships/requestswith{ "requesting_tenant": "B" }. -
2Controller checks
approved_partnersfor "B". Not found → 403 (stops here). Found → continue. -
3Controller checks
approval_requestsfor an existingpendingorsuspendedrequest from "B".
pendingexists → 409 (re-submit allowed only afterdenied).
suspendedexists → 409 with message to use/resumeon 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
-
1A's admin calls
GET /partnerships/requests?status=pendingto review the queue. -
2Approve:
PATCH /partnerships/requests/7/approve
Controller verifies request ispending.
Builds and creates KafkaConnector inshared-kafka(PATCHes if 409).
Updates status →enabled.
Returns 200{ "status": "enabled", "partner_tenant": "B", "mqtt_wildcard": "v1/partner/B/aircraft/+/#" }. -
3Deny:
PATCH /partnerships/requests/7/deny
Controller verifies request ispending. 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. -
4Suspend:
PATCH /partnerships/requests/7/suspend
Controller verifies request isenabled.
Deletes KafkaConnector — streaming stops immediately. Tenant stays on allowlist.
Updates status →suspended.
Returns 200{ "status": "suspended" }. -
5Resume:
PATCH /partnerships/requests/7/resume
Controller verifies request issuspended.
Recreates KafkaConnector — streaming restarts within seconds.
Updates status →enabled.
Returns 200{ "status": "enabled", "partner_tenant": "B", "mqtt_wildcard": "v1/partner/B/aircraft/+/#" }.
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.
What Is Not Touched During Any Status Change
| Component | Touched? |
|---|---|
| Tenant A's Flink job | No |
| Tenant A's EMQX | No |
| Tenant A's MQTT-to-Kafka bridge | No |
| Tenant B's entire stack | No |
| Any Helm release | No |
| Any DNS record | No |
REST API Surface
All endpoints at https://partnerships.{tenant}.{env}.flysafe.live/
Allowlist management
| Method | Path | Description |
|---|---|---|
| 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
| Method | Path | Actor | Description |
|---|---|---|---|
| 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