When building search functionality for enterprise SaaS products, traditional keyword matching has long fallen short. Users now expect intelligent, semantic search that understands their intent. Vector search is the answer, but introducing it into a multi-tenant environment presents a significant new set of challenges. Each tenant’s data must be strictly isolated, with no possibility of data leakage between them under any circumstances. At the same time, traditional password-based authentication has become a critical security bottleneck. The frequency of data breaches is a stark reminder that we must shift to more secure solutions.
Our goal is to build an end-to-end solution: a fluid native experience on Android using Jetpack Compose, passwordless authentication via WebAuthn, and a backend powered by Weaviate’s robust multi-tenancy features for data isolation, all fronted by a lightweight Ktor service acting as a secure gateway. This isn’t just about stacking features; it’s an architectural exercise in security, isolation, and modern user experience.
Step 1: Weaviate Multi-tenancy Configuration and Data Isolation
Weaviate’s native support for multi-tenancy was the linchpin of our technical selection. It allows us to manage data for different tenants within a single instance, enforcing request-level isolation via the X-Tenant HTTP header. This design is far more economical and efficient than deploying a separate instance for each tenant.
First, let’s look at our docker-compose.yml configuration. There’s no magic here, but it’s crucial to enable authentication, which is a good practice even in development environments.
# docker-compose.yml
version: '3.4'
services:
weaviate:
image: cr.weaviate.io/semitechnologies/weaviate:1.23.7
ports:
- "8080:8080"
- "50051:50051"
restart: on-failure:0
environment:
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false'
AUTHENTICATION_APIKEY_ENABLED: 'true'
AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'my-secret-api-key' # Production environments should use a more secure key management system
AUTHENTICATION_APIKEY_USERS: '[email protected]'
DEFAULT_VECTORIZER_MODULE: 'text2vec-openai' # Open-source models are also an option
ENABLE_MODULES: 'text2vec-openai,generative-openai'
OPENAI_APIKEY: 'YOUR_OPENAI_API_KEY' # Replace with your OpenAI key
CLUSTER_HOSTNAME: 'node1'
PERSISTENCE_DATA_PATH: './data'
Next is defining the data schema. The core element is the multiTenancyConfig. Once enabled is set to true, this collection operates in multi-tenant mode.
// Define the schema using the Weaviate Kotlin client
import io.weaviate.client.WeaviateClient
import io.weaviate.client.base.Result
import io.weaviate.client.v1.schema.model.Property
import io.weaviate.client.v1.schema.model.WeaviateClass
import io.weaviate.client.v1.schema.model.MultiTenancyConfig
suspend fun createMultiTenantSchema(client: WeaviateClient) {
val documentClass = WeaviateClass.builder()
.className("PrivateDocument")
.description("A class to store tenant-specific documents")
.vectorizer("text2vec-openai")
.multiTenancyConfig(
MultiTenancyConfig.builder()
.enabled(true)
.build()
)
.properties(
listOf(
Property.builder()
.name("content")
.dataType(listOf("text"))
.build(),
Property.builder()
.name("source")
.dataType(listOf("string"))
.build()
)
)
.build()
val result: Result<Boolean> = client.schema().classCreator().withClass(documentClass).run()
if (result.hasErrors()) {
// In a real project, this should be structured logging
println("Schema creation error: ${result.error.messages}")
return
}
println("Schema 'PrivateDocument' created successfully.")
}
After creating the schema, we must explicitly create the tenants. A tenant is just a string identifier, but in our architecture, it will be tightly coupled with user authentication information.
import io.weaviate.client.v1.schema.model.Tenant
suspend fun addTenants(client: WeaviateClient) {
val tenants = listOf(
Tenant.builder().name("tenant-a").build(),
Tenant.builder().name("tenant-b").build()
)
val result = client.schema().tenantsCreator()
.withClassName("PrivateDocument")
.withTenants(tenants.toTypedArray())
.run()
if (result.hasErrors()) {
println("Tenant creation error: ${result.error.messages}")
} else {
println("Tenants 'tenant-a' and 'tenant-b' added.")
}
}
Now, all data operations (create, read, update, delete) must specify a tenant. For example, adding some documents for tenant-a:
import io.weaviate.client.v1.data.model.WeaviateObject
suspend fun addDataForTenant(client: WeaviateClient, tenantId: String) {
val doc1 = WeaviateObject.builder()
.className("PrivateDocument")
.tenant(tenantId) // Crucial: specify the tenant
.properties(mapOf("content" to "Project Alpha internal memo regarding Q3 strategy.", "source" to "memo-q3.doc"))
.build()
val doc2 = WeaviateObject.builder()
.className("PrivateDocument")
.tenant(tenantId)
.properties(mapOf("content" to "Financial forecast for the upcoming fiscal year for Alpha team.", "source" to "forecast-alpha.xls"))
.build()
val result = client.data().creator()
.withObjects(doc1, doc2)
.run()
if (result.hasErrors()) {
println("Data import error for $tenantId: ${result.error.messages}")
} else {
println("Data imported successfully for $tenantId.")
}
}
The same principle applies to queries. If we don’t provide the X-Tenant header, the query will fail. If we provide X-Tenant: tenant-a, the search will be scoped exclusively to documents belonging to tenant-a. This is our guarantee of physical data isolation.
// Pseudocode showing how the client constructs the request
fun search(query: String, tenantId: String): SearchResults {
// The Weaviate client internally translates the tenantId to the X-Tenant header
val result = weaviateClient.graphQL()
.get()
.withClassName("PrivateDocument")
.withTenant(tenantId) // Specify the tenant
.withFields(...)
.withNearText(...)
.run()
// ... process results
}
The key gotcha here is that once multi-tenancy is enabled for a collection, it cannot be disabled. This decision requires careful consideration during schema design.
Step 2: Ktor Backend as an Authentication and Security Proxy
Allowing the mobile app to connect directly to Weaviate is a major security risk. We need a middle layer to handle authentication and proxy requests to Weaviate based on the authentication outcome. Ktor, with its lightweight nature and excellent coroutine support, is an ideal choice for a Kotlin-based stack.
The core flow is as follows:
- The user initiates the WebAuthn registration/login flow from the Android app.
- The Ktor backend handles the WebAuthn challenges and verification.
- Upon successful verification, Ktor generates a JWT containing a
tenant_idanduser_id. - All subsequent requests from the app carry this JWT.
- A protected
/searchendpoint in Ktor validates the JWT, extracts thetenant_id, and securely proxies the request to Weaviate, attaching the appropriateX-Tenantheader.
sequenceDiagram
participant App as Jetpack Compose App
participant Backend as Ktor Backend
participant WeaviateDB as Weaviate
App->>+Backend: 1. Initiate WebAuthn login
Backend-->>-App: 2. Return login challenge
App->>+Backend: 3. Sign challenge with biometrics
Backend-->>-App: 4. Verify signature, login success, issue JWT (with tenant_id)
App->>+Backend: 5. Make search request (with JWT)
Backend->>Backend: 6. Validate JWT, extract tenant_id
Backend->>+WeaviateDB: 7. Proxy search request (with X-Tenant header)
WeaviateDB-->>-Backend: 8. Return isolated search results
Backend-->>-App: 9. Return results to App
We’ll focus on the WebAuthn and secure proxy implementation in the Ktor backend, using a popular Java WebAuthn library.
1. Dependency Configuration (build.gradle.kts)
dependencies {
// Ktor
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("io.ktor:ktor-server-auth:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
// WebAuthn Library
implementation("com.yubico:webauthn-server-core:2.0.0")
// Logging
implementation("ch.qos.logback:logback-classic:$logback_version")
}
2. Core WebAuthn Logic
This part of the code is quite complex due to its cryptographic operations and state management. In a real project, userRepository and credentialRepository would interact with a database.
// WebAuthnServer.kt
import com.yubico.webauthn.*
import com.yubico.webauthn.data.*
// ... other imports
class WebAuthnServer(
private val relyingParty: RelyingParty,
private val userRepository: UserRepository, // Your user repository
private val credentialRepository: CredentialRepository // Your credential store
) {
// Start registration
fun startRegistration(username: String, tenantId: String): RegistrationResult {
val user = userRepository.findByUsername(username)
?: userRepository.createUser(username, tenantId) // Create user if not exists
val registration = relyingParty.startRegistration(
StartRegistrationOptions.builder()
.user(user.toUserIdentity())
.build()
)
// Critical: Temporarily store the challenge, associating it with the user for later verification.
// In production, this should be stored in Redis with a TTL.
userRepository.updateRegistrationChallenge(user.id, registration.getChallenge())
return RegistrationResult(
success = true,
publicKeyCredentialCreationOptions = registration
)
}
// Finish registration
fun finishRegistration(responseJson: String, username: String): FinishRegistrationResult {
val user = userRepository.findByUsername(username) ?: throw Exception("User not found")
val challenge = user.registrationChallenge ?: throw Exception("No registration challenge found")
val response = PublicKeyCredential.parseRegistrationResponseJson(responseJson)
val registrationResult = relyingParty.finishRegistration(
FinishRegistrationOptions.builder()
.request(
PublicKeyCredentialCreationOptions.builder()
.challenge(challenge)
.build()
)
.response(response)
.build()
)
if (registrationResult.isSuccess) {
credentialRepository.addCredential(
userId = user.id,
keyId = registrationResult.keyId.id,
publicKey = registrationResult.publicKeyCose,
// ... other metadata
)
// Clear the challenge
userRepository.clearRegistrationChallenge(user.id)
return FinishRegistrationResult(success = true)
}
return FinishRegistrationResult(success = false, message = "Registration failed")
}
// ... Login logic (startLogin/finishLogin) is similar
}
3. Ktor Routing and JWT Issuance
// Application.kt
fun Application.module() {
// ... plugin installation (ContentNegotiation, Authentication)
// Configure JWT
val secret = environment.config.property("jwt.secret").getString()
val issuer = environment.config.property("jwt.issuer").getString()
val audience = environment.config.property("jwt.audience").getString()
install(Authentication) {
jwt("auth-jwt") {
// ... JWT verifier configuration
verifier(
JWT
.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build()
)
validate { credential ->
if (credential.payload.getClaim("username").asString() != "") {
JWTPrincipal(credential.payload)
} else {
null
}
}
}
}
routing {
// ... WebAuthn registration and login routes
post("/login/finish") {
val loginRequest = call.receive<LoginRequest>()
// ... call webAuthnServer.finishLogin() ...
if (loginSuccessful) {
val user = userRepository.findByUsername(loginRequest.username)!!
val token = JWT.create()
.withAudience(audience)
.withIssuer(issuer)
.withClaim("username", user.username)
.withClaim("tenant_id", user.tenantId) // Core: Embed tenantId into the JWT
.withExpiresAt(Date(System.currentTimeMillis() + 60000 * 60 * 24)) // 24-hour validity
.sign(Algorithm.HMAC256(secret))
call.respond(mapOf("token" to token))
} else {
call.respond(HttpStatusCode.Unauthorized)
}
}
// Protected search proxy endpoint
authenticate("auth-jwt") {
post("/search") {
val principal = call.principal<JWTPrincipal>()!!
val tenantId = principal.payload.getClaim("tenant_id").asString()
val searchQuery = call.receive<SearchQuery>()
// In a real project, use Weaviate's Kotlin client here.
// For demonstration clarity, we simulate with Ktor client.
val weaviateResponse = httpClient.post("http://weaviate:8080/v1/graphql") {
header("Authorization", "Bearer my-secret-api-key")
header("X-Tenant", tenantId) // Critical: Inject the tenant ID
contentType(ContentType.Application.Json)
setBody(buildGraphQLQuery(searchQuery))
}
call.respond(weaviateResponse.status, weaviateResponse.bodyAsText())
}
}
}
}
This proxy endpoint is the cornerstone of the entire security architecture. It decouples authentication (JWT) from the data access policy (X-Tenant header). The client remains completely unaware of Weaviate’s existence, forcing all requests through this secure gateway.
Step 3: Jetpack Compose Client Implementation
On the Android side, we need to handle two key areas: the complex WebAuthn flow and secure communication with our backend API.
1. WebAuthn Client Logic
Google’s androidx.credentials library greatly simplifies the client-side implementation of Passkeys (WebAuthn).
// AuthViewModel.kt
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.CredentialManager
import androidx.credentials.GetPublicKeyCredentialOption
class AuthViewModel(
private val credentialManager: CredentialManager,
private val authRepository: AuthRepository, // Encapsulates API calls to the Ktor backend
private val application: Application
) : AndroidViewModel(application) {
// ... LiveData/StateFlow for UI state ...
suspend fun register(username: String) {
try {
// 1. Fetch the challenge from the backend
val options = authRepository.startRegistration(username)
// 2. Create the request using CredentialManager
val request = CreatePublicKeyCredentialRequest(options.publicKeyCredentialCreationOptionsJson)
// 3. Launch the system UI for user biometrics
val result = credentialManager.createCredential(
context = application.applicationContext,
request = request
)
// 4. Send the result back to the backend to finalize registration
authRepository.finishRegistration(username, result.data.getString("REGISTRATION_RESPONSE"))
// ... Update UI state ...
} catch (e: Exception) {
// Handle errors, e.g., user canceled the dialog
Log.e("AuthViewModel", "Registration failed", e)
}
}
suspend fun login() {
try {
// 1. Fetch the login challenge from the backend
val options = authRepository.startLogin()
// 2. Create the request
val request = GetPublicKeyCredentialOption(options.publicKeyCredentialRequestOptionsJson)
// 3. Launch the system UI
val result = credentialManager.getCredential(
context = application.applicationContext,
request = request
)
// 4. Send the signed result to the backend to exchange for a JWT
val token = authRepository.finishLogin(result.data.getString("AUTHENTICATION_RESPONSE"))
// 5. Persist the JWT securely (e.g., EncryptedSharedPreferences)
// ...
} catch (e: Exception) {
Log.e("AuthViewModel", "Login failed", e)
}
}
}
2. Secure API Communication
We use Retrofit with an OkHttp Interceptor to automatically add the Authorization header to authenticated requests.
// AuthInterceptor.kt
class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenManager.getToken()
val request = if (token != null) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
// ApiClient.kt
object ApiClient {
private fun provideOkHttpClient(): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.addInterceptor(logging)
.addInterceptor(AuthInterceptor(TokenManager.getInstance())) // Inject the interceptor
.build()
}
// ... Retrofit builder ...
}
3. Search Screen UI
The Compose UI is relatively straightforward. A TextField handles input, a LazyColumn displays results, and the ViewModel drives UI updates via StateFlow.
// SearchScreen.kt
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
val searchQuery by viewModel.searchQuery.collectAsState()
val searchResults by viewModel.searchResults.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.onQueryChanged(it) },
label = { Text("Enter semantic query...") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else {
LazyColumn(modifier = Modifier.weight(1.0f)) {
items(searchResults) { result ->
SearchResultItem(result)
}
}
}
}
}
@Composable
fun SearchResultItem(result: SearchResult) {
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = result.content, style = MaterialTheme.typography.bodyLarge)
Text(text = "Source: ${result.source}", style = MaterialTheme.typography.bodySmall)
}
}
}
// SearchViewModel.kt
class SearchViewModel(private val searchRepository: SearchRepository) : ViewModel() {
// ... StateFlow for query, results, loading state ...
fun onQueryChanged(newQuery: String) {
_searchQuery.value = newQuery
// Use debounce to avoid frequent requests
viewModelScope.launch {
delay(300) // Debounce
if (newQuery.isNotBlank()) {
_isLoading.value = true
try {
val results = searchRepository.performSearch(newQuery)
_searchResults.value = results
} catch (e: Exception) {
// Handle API errors
_searchResults.value = emptyList()
} finally {
_isLoading.value = false
}
} else {
_searchResults.value = emptyList()
}
}
}
}
This solution implements an end-to-end secure chain. Users authenticate with hardware-protected keys on their devices, the resulting JWT contains their tenant identity, and the backend service enforces data access policies based on that identity, ultimately rendering isolated and relevant search results in a native UI.
Limitations and Future Iterations
While this architecture is robust in terms of security and isolation, it’s not without its trade-offs.
First, the Ktor proxy layer is a potential performance bottleneck and single point of failure. In a production environment, it should be deployed as a horizontally scalable cluster of services, likely behind an API gateway that handles concerns like TLS termination and rate limiting.
Second, while the WebAuthn user experience is secure, its credential recovery mechanism remains a challenge. If a user changes devices without syncing their passkeys to a cloud service (like Google Password Manager), the credential is lost. An alternative account recovery flow, such as email verification to re-bind a new device, would be necessary. However, this could introduce new security risks and must be carefully balanced.
Third, while Weaviate’s multi-tenancy feature is powerful, managing metadata and performance could become an issue as the number of tenants scales into the tens of thousands or higher. At that point, more complex strategies, like sharding tenants across different Weaviate clusters, might be required, significantly increasing operational complexity.
Finally, the current vectorization model is shared globally. For tenants in specialized domains, a universal model might not provide optimal search relevance. A future optimization would be to explore fine-tuning or substituting dedicated embedding models for specific tenants or groups of tenants to achieve higher search quality.