FLYsafe.live - v1 - Cross Tenant Partnerships
Problem Statement
Two tenants want to cooperate so that the Home Tenant can see the Partner Tenant'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
The Partner Tenant's drone telemetry is already in the shared Kafka cluster as {partner_tenant}.telemetry.enriched, produced by the Partner Tenant's FlightBinder (Flink) pipeline. It just needs to be routed to the Home Tenant'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: Home Tenant's EMQX subscribes to Partner Tenant's EMQX and re-publishes messages locally. EMQX 5 supports this natively and it has lower latency.
Why it was rejected: The Home Tenant's EMQX needs a username and password on the Partner Tenant's EMQX to connect. This creates a cross-tenant credential dependency — the Partner Tenant must create a dedicated EMQX user for every partner, transfer those credentials to the Home Tenant, and delete the user on revoke. The Partner Tenant has an operational dependency on every active partnership.
With the Kafka approach, the Partner Tenant does nothing. Its telemetry is already in Kafka. The KafkaConnector is created and deleted entirely within shared-kafka by the Home Tenant's controller — the Partner Tenant is never touched.
| Direct EMQX bridge | Kafka connector | |
|---|---|---|
| Latency | Minimal | ~100–200 ms extra hop |
| Partner Tenant 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
┌───────────────────────── Home Tenant namespace ─────────────────────────────┐
│ │
│ RTM Frontend (Next.js) │
│ │ POST /partnerships/approved-partners → add to allowlist │
│ │ POST /partnerships/requests → submit request (partner) │
│ │ 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 │
│ │
│ Home Tenant EMQX (tenant-emqx.{home_tenant}.svc:1883) │
│ topics (per active partner serial): │
│ v1/partner/{partner_tenant}/aircraft/{serial}/drones/telemetry ← position + status │
│ v1/partner/{partner_tenant}/aircraft/{serial}/drones/networkid ← pilot + mission │
│ v1/partner/{partner_tenant}/aircraft/{serial}/oi/declaration ← OI boundary │
│ ▼ │
│ RTM Frontend subscribes: v1/partner/+/aircraft/+/# │
│ renders partner drones: grey icon, "Partner: {partner_tenant}" label │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────── shared-kafka namespace ──────────────────────────┐
│ │
│ {partner_tenant}.telemetry.enriched (Kafka topic — already exists) │
│ {partner_tenant}.networkid.dd (Kafka topic — already exists) │
│ {partner_tenant}.flightplan (Kafka topic — already exists) │
│ │ │
│ ▼ │
│ partner-{partner_tenant}-to-{home_tenant}-mqtt-sink (KafkaConnector) CREATED ON APPROVE │
│ class: Lenses MqttSinkConnector │
│ reads: {partner_tenant}.telemetry.enriched · {partner_tenant}.networkid.dd · {partner_tenant}.flightplan │
│ transform: InsertField partner_tenant = "{partner_tenant}" │
│ key: Kafka message key = aircraft serial → builds per-serial path │
│ publishes: v1/partner/{partner_tenant}/aircraft/{serial}/drones/telemetry → Home Tenant EMQX │
│ v1/partner/{partner_tenant}/aircraft/{serial}/drones/networkid → Home Tenant EMQX │
│ v1/partner/{partner_tenant}/aircraft/{serial}/oi/declaration → Home Tenant EMQX │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────── Partner Tenant namespace ───────────────────────────┐
│ │
│ (Partner Tenant's Flink, EMQX, and primary pipeline are never touched) │
│ │
│ Partner Tenant's Partnership Controller │
│ POST /partnerships/requests → Partner Tenant submits a request to │
│ Home Tenant's controller (Partner Tenant calls Home Tenant's API) │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
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/{home_tenant}/aircraft/+/drones/telemetry |
Home Tenant's FlightBinder | Home Tenant's frontend — own fleet map layer | 10 Hz |
v1/partner/{partner_tenant}/aircraft/{serial}/drones/telemetry |
partner connector | Home Tenant's frontend — partner map layer (grey icon, "Partner: {partner_tenant}") | 10 Hz |
v1/partner/{partner_tenant}/aircraft/{serial}/drones/networkid |
partner connector | Home Tenant's frontend — partner drone detail panel (pilot, callsign, mission) | ~1 Hz |
v1/partner/{partner_tenant}/aircraft/{serial}/oi/declaration |
partner connector | Home Tenant's frontend — partner OI boundary overlay + conformance status | On change |
v1/partner/+/aircraft/+/# |
any active partner connector | Home Tenant'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; the Home Tenant'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:
Home Tenant namespace shared-kafka namespace
───────────────────────────── ──────────────────────────────────────
ServiceAccount Role
partnership-controller-svc ←──────── partnership-controller-{home_tenant}
rules:
kafkaconnectors:
get, list, create,
update, patch, delete
RoleBinding
subject: SA from Home Tenant ns
roleRef: partnership-controller-{home_tenant}
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 one Partner Tenant from impersonating another by setting requesting_tenant to a value they do not own.
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.
-
1Home Tenant's admin calls
POST /partnerships/approved-partnerswith{ "partner_tenant": "{partner_tenant}" }. -
2Controller writes
INSERT INTO approved_partners (partner_tenant='{partner_tenant}')→ 201 OK.
Flow 2 — Partner Tenant Submits a Partnership Request
-
1Partner Tenant's admin calls
POST https://partnerships.{home_tenant}.stage.flysafe.live/partnerships/requestswith{ "requesting_tenant": "{partner_tenant}" }. -
2Controller checks
approved_partnersfor{partner_tenant}. Not found → 403 (stops here). Found → continue. -
3Controller checks
approval_requestsfor an existingpendingorsuspendedrequest from{partner_tenant}.
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='{partner_tenant}', status='pending')→ 201{ "id": 7, "status": "pending", ... }
Flow 3 — Home Tenant Admin Manages the Partnership
-
1Home Tenant'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": "{partner_tenant}", "mqtt_wildcard": "v1/partner/{partner_tenant}/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" }. The Partner Tenant 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": "{partner_tenant}", "mqtt_wildcard": "v1/partner/{partner_tenant}/aircraft/+/#" }.
v1/partner/{partner_tenant}/aircraft/{serial}/…. The Home Tenant'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? |
|---|---|
| Home Tenant's Flink job | No |
| Home Tenant's EMQX | No |
| Home Tenant's MQTT-to-Kafka bridge | No |
| Partner Tenant'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 |
Partner Tenant | 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 |
Home Tenant's admin | List all requests. Filter with ?status=pending|enabled|suspended|denied. |
| PATCH | /partnerships/requests/{id}/approve |
Home Tenant's admin | Approve pending request — creates KafkaConnector, sets status enabled. Optional body: { "notes": "..." } |
| PATCH | /partnerships/requests/{id}/deny |
Home Tenant's admin | Deny pending request — terminal, no further transitions. Deletes KafkaConnector if present. Optional body: { "notes": "..." } |
| PATCH | /partnerships/requests/{id}/suspend |
Home Tenant's admin | Suspend active partnership — deletes KafkaConnector, stops streaming. Tenant stays on allowlist. |
| PATCH | /partnerships/requests/{id}/resume |
Home Tenant's admin | Resume suspended partnership — recreates KafkaConnector, restores streaming within seconds. |
End-to-End Data Flow (Partnership Active)
Partner Tenant's Drone
│
▼ (existing, unchanged)
Partner Tenant's DJI Integration Service
│ MQTT publish
▼
Partner Tenant EMQX → Partner Tenant's FlightBinder (Flink) + Partner Tenant's OI Conformance Tracker
│
▼
Kafka (shared-kafka namespace):
{partner_tenant}.telemetry.enriched ← canonical RTTP telemetry, already decoded
{partner_tenant}.networkid.dd ← deduplicated networkid (pilot, mission, waypoints)
{partner_tenant}.flightplan ← OI declaration + conformance state
│
│ ← partnership active: KafkaConnector exists
▼
partner-{partner_tenant}-to-{home_tenant}-mqtt-sink (KafkaConnector, shared-kafka) ← CREATED ON APPROVE
│ transform: InsertField partner_tenant = "{partner_tenant}"
│ key: Kafka message key = aircraft serial → builds per-aircraft MQTT path
│ connects to: tenant-emqx.{home_tenant}.svc.cluster.local:1883
▼
Home Tenant EMQX
│ v1/partner/{partner_tenant}/aircraft/{serial}/drones/telemetry 10 Hz
│ v1/partner/{partner_tenant}/aircraft/{serial}/drones/networkid ~1 Hz
│ v1/partner/{partner_tenant}/aircraft/{serial}/oi/declaration on change
▼
RTM Frontend (Home Tenant) via WebSocket wss://emqx-ws.{home_tenant}.stage.flysafe.live/mqtt
│ wildcard sub: v1/partner/+/aircraft/+/#
▼
Map renders:
● Blue icon — Home Tenant's own drones (v1/{home_tenant}/aircraft/+/drones/telemetry)
● Grey icon — Partner Tenant's drones (v1/partner/{partner_tenant}/aircraft/{serial}/…)
label: "Partner: {partner_tenant}" · pilot + mission from networkid
OI boundary overlay from oi/declaration + conformance colour