Skip to content

Commit 2e0d4da

Browse files
committed
add api-key auth in http and grpc servers
Signed-off-by: Arshdeep54 <balarsh535@gmail.com>
1 parent bad4f0e commit 2e0d4da

20 files changed

Lines changed: 490 additions & 80 deletions

File tree

‎.env.example‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ HTTP_PORT=3000
55
# gRPC Server
66
GRPC_HOST=127.0.0.1
77
GRPC_PORT=50051
8-
# required
9-
GRPC_ROOT_PASSWORD=your-secure-password
8+
9+
# API keys used by both the HTTP and gRPC servers (required)
10+
# See keys.example.json for the file format
11+
VORTEXDB_KEYS_FILE=./keys.json
1012

1113
# Database Configuration
1214
# Storage: inmemory, rocksdb

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
.TODO
88
/databases
99
.env
10+
/keys.json
1011
__pycache__/
1112
build/
1213
.venv/

‎README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,23 @@ cp .env.example .env
6767
```
6868

6969
The following env vars are important, and are required to be set by the user:
70-
`GRPC_ROOT_PASSWORD`
70+
`VORTEXDB_KEYS_FILE`
7171
`DIMENSION`
7272
`DATA_PATH`
7373

7474
**NOTE**: `DATA_PATH` is the directory within the container where persistent data is stored
7575

76+
**NOTE**: `VORTEXDB_KEYS_FILE` points to a JSON file of API keys shared by both the HTTP and gRPC servers, e.g.:
77+
```json
78+
{
79+
"keys": [
80+
{ "name": "admin", "role": "readwrite", "key": "some-random-secret" },
81+
{ "name": "search-service", "role": "readonly", "key": "another-random-secret" }
82+
]
83+
}
84+
```
85+
Requests authenticate with an `api-key: <key>` header over HTTP, or an `authorization: Bearer <key>` header over gRPC. `readonly` keys can read/search but not insert, batch-insert, or delete; `readwrite` keys can do all of the above. See `keys.example.json`.
86+
7687

7788
Setting of the following env vars is optional, as they fallback to safe defaults, but recommended:
7889
| .env Var | Function | Safe Default |

‎client/python/USAGE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pip install -e .
1818
The client communicates with VortexDB over gRPC and requires:
1919

2020
- gRPC endpoint (host:port)
21-
- API key (maps to `GRPC_ROOT_PASSWORD` on the server)
21+
- API key (must match a `key` entry in the server's `VORTEXDB_KEYS_FILE`)
2222

2323
These can be provided either:
2424

‎crates/defs/src/auth.rs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4+
#[serde(rename_all = "lowercase")]
5+
pub enum ApiKeyRole {
6+
ReadOnly,
7+
ReadWrite,
8+
}
9+
10+
#[derive(Debug, Clone, Serialize, Deserialize)]
11+
pub struct ApiKeyEntry {
12+
pub name: String,
13+
pub role: ApiKeyRole,
14+
pub key: String,
15+
}
16+
17+
#[derive(Debug, Clone, Default)]
18+
pub struct ApiKeyStore {
19+
keys: Vec<ApiKeyEntry>,
20+
}
21+
22+
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
23+
if a.len() != b.len() {
24+
return false;
25+
}
26+
let mut diff = 0u8;
27+
for (x, y) in a.iter().zip(b.iter()) {
28+
diff |= x ^ y;
29+
}
30+
diff == 0
31+
}
32+
33+
impl ApiKeyStore {
34+
pub fn new(keys: Vec<ApiKeyEntry>) -> Self {
35+
Self { keys }
36+
}
37+
38+
pub fn find(&self, presented: &str) -> Option<&ApiKeyEntry> {
39+
let mut matched = None;
40+
for entry in &self.keys {
41+
if constant_time_eq(entry.key.as_bytes(), presented.as_bytes()) {
42+
matched = Some(entry);
43+
}
44+
}
45+
matched
46+
}
47+
48+
pub fn is_empty(&self) -> bool {
49+
self.keys.is_empty()
50+
}
51+
52+
pub fn duplicate_key(&self) -> Option<&str> {
53+
for (i, entry) in self.keys.iter().enumerate() {
54+
for other in &self.keys[i + 1..] {
55+
if constant_time_eq(entry.key.as_bytes(), other.key.as_bytes()) {
56+
return Some(&entry.key);
57+
}
58+
}
59+
}
60+
None
61+
}
62+
}

‎crates/defs/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
pub mod auth;
12
pub mod error;
23
pub mod types;
34

45
// Without re-exports, users would need to write defs::types::SomeType instead of just defs::SomeType. Re-exports simplify the API by flattening the module hierarchy. The * means "everything public" from that module.
6+
pub use auth::*;
57
pub use error::*;
68
use std::path::{Path, PathBuf};
79
pub use types::*;

‎crates/grpc/src/interceptors.rs‎

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,56 @@
1+
use std::sync::Arc;
2+
3+
use defs::{ApiKeyEntry, ApiKeyRole, ApiKeyStore};
14
use tonic::{Status, service::Interceptor};
25
use tracing::{Level, event};
36

47
use crate::constants::AUTHORIZATION_HEADER_KEY;
58

69
#[derive(Clone)]
710
pub struct AuthInterceptor {
8-
root_password: String,
11+
keys: Arc<ApiKeyStore>,
12+
}
13+
14+
fn extract_bearer_token<T>(req: &tonic::Request<T>) -> Option<&str> {
15+
req.metadata()
16+
.get(AUTHORIZATION_HEADER_KEY)?
17+
.to_str()
18+
.ok()?
19+
.strip_prefix("Bearer ")
20+
.filter(|token| !token.is_empty())
21+
}
22+
23+
pub fn require_write_role<T>(req: &tonic::Request<T>) -> Result<(), Status> {
24+
match req.extensions().get::<ApiKeyEntry>() {
25+
Some(entry) if entry.role == ApiKeyRole::ReadWrite => Ok(()),
26+
Some(_) => Err(Status::permission_denied(
27+
"This api key does not have write access",
28+
)),
29+
None => Err(Status::unauthenticated("Invalid credentials")),
30+
}
931
}
1032

1133
impl Interceptor for AuthInterceptor {
12-
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
13-
let auth_token = match req.metadata().get(AUTHORIZATION_HEADER_KEY) {
14-
Some(t) => t,
15-
None => return Err(Status::unauthenticated("Invalid credentials")),
16-
};
17-
if auth_token
18-
.to_str()
19-
.unwrap_or_default()
20-
.strip_prefix("Bearer ")
21-
.unwrap_or_default()
22-
== self.root_password
23-
{
24-
Ok(req)
25-
} else {
26-
event!(Level::WARN, "Unauthorized Request");
27-
Err(Status::unauthenticated("Invalid credentials"))
34+
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
35+
let matched = extract_bearer_token(&req)
36+
.and_then(|token| self.keys.find(token))
37+
.cloned();
38+
39+
match matched {
40+
Some(entry) => {
41+
req.extensions_mut().insert(entry);
42+
Ok(req)
43+
}
44+
None => {
45+
event!(Level::WARN, "Unauthorized Request");
46+
Err(Status::unauthenticated("Invalid credentials"))
47+
}
2848
}
2949
}
3050
}
3151

3252
impl AuthInterceptor {
33-
pub fn new(root_password: String) -> AuthInterceptor {
34-
AuthInterceptor { root_password }
53+
pub fn new(keys: Arc<ApiKeyStore>) -> AuthInterceptor {
54+
AuthInterceptor { keys }
3555
}
3656
}

‎crates/grpc/src/lib.rs‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,13 @@ use utils::ServerEndpoint;
1515
pub async fn run_grpc_server(
1616
db: Arc<VectorDb>,
1717
addr: SocketAddr,
18-
root_password: String,
18+
keys: Arc<defs::ApiKeyStore>,
1919
logging: bool,
2020
) -> Result<(), BoxError> {
2121
let vector_db_service = VectorDBService::new(db, logging);
22-
run_server(
23-
vector_db_service,
24-
ServerEndpoint::Address(addr),
25-
root_password,
26-
)
27-
.await
28-
.map_err(|e| -> BoxError { e.to_string().into() })
22+
run_server(vector_db_service, ServerEndpoint::Address(addr), keys)
23+
.await
24+
.map_err(|e| -> BoxError { e.to_string().into() })
2925
}
3026

3127
#[cfg(test)]

‎crates/grpc/src/service.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ impl VectorDb for VectorDBService {
3838
request: Request<InsertVectorRequest>,
3939
) -> Result<Response<PointId>, Status> {
4040
log_rpc("insert_vector", self.logging);
41+
interceptors::require_write_role(&request)?;
4142

4243
let inner_request = request.into_inner();
4344

@@ -157,6 +158,7 @@ impl VectorDb for VectorDBService {
157158

158159
async fn delete_point(&self, request: Request<PointId>) -> Result<Response<()>, Status> {
159160
log_rpc("delete_point", self.logging);
161+
interceptors::require_write_role(&request)?;
160162

161163
let point_id = request.into_inner().id.unwrap_or_default().value;
162164

@@ -179,6 +181,7 @@ impl VectorDb for VectorDBService {
179181
&self,
180182
request: tonic::Request<InsertVectorsBatchRequest>,
181183
) -> Result<tonic::Response<InsertVectorsBatchResponse>, tonic::Status> {
184+
interceptors::require_write_role(&request)?;
182185
let req = request.into_inner();
183186
let mut ids = Vec::with_capacity(req.vectors.len());
184187

@@ -254,11 +257,11 @@ impl VectorDb for VectorDBService {
254257
pub async fn run_server(
255258
vector_db_service: VectorDBService,
256259
endpoint: ServerEndpoint,
257-
root_password: String,
260+
keys: Arc<defs::ApiKeyStore>,
258261
) -> Result<(), Box<dyn std::error::Error>> {
259262
event!(Level::INFO, "Starting gRPC server at: {:?}", endpoint);
260263

261-
let auth_interceptor = interceptors::AuthInterceptor::new(root_password);
264+
let auth_interceptor = interceptors::AuthInterceptor::new(keys);
262265

263266
let router = Server::builder()
264267
.layer(InterceptorLayer::new(auth_interceptor))

‎crates/grpc/src/tests.rs‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::service::vectordb::{
66
use crate::service::{VectorDBService, run_server};
77
use crate::utils::ServerEndpoint;
88
use api::DbConfig;
9-
use defs::Similarity;
9+
use defs::{ApiKeyEntry, ApiKeyRole, ApiKeyStore, Similarity};
1010
use index::{IndexType, hnsw::HnswConfig, kd_tree::KDTreeConfig};
1111
use std::net::SocketAddr;
1212
use std::sync::Arc;
@@ -17,6 +17,7 @@ use tonic::transport::Channel;
1717
// Inspired from https://github.com/hyperium/tonic/discussions/924#discussioncomment-9854088
1818

1919
const TEST_AUTH_BEARER_TOKEN: &str = "123";
20+
const TEST_READONLY_BEARER_TOKEN: &str = "456";
2021

2122
fn append_test_auth_header<T>(request: &mut tonic::Request<T>, token: &str) {
2223
let auth_value = format!("Bearer {}", token);
@@ -41,19 +42,28 @@ async fn start_test_server() -> Result<(SocketAddr, TempDir), Box<dyn std::error
4142

4243
let vector_db_api = api::init_api(db_config)?;
4344

45+
let keys = Arc::new(ApiKeyStore::new(vec![
46+
ApiKeyEntry {
47+
name: "test".to_string(),
48+
role: ApiKeyRole::ReadWrite,
49+
key: TEST_AUTH_BEARER_TOKEN.to_string(),
50+
},
51+
ApiKeyEntry {
52+
name: "test-readonly".to_string(),
53+
role: ApiKeyRole::ReadOnly,
54+
key: TEST_READONLY_BEARER_TOKEN.to_string(),
55+
},
56+
]));
57+
4458
let vector_db_service = VectorDBService::new(Arc::new(vector_db_api), false);
4559

4660
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
4761
let listener_addr = listener.local_addr()?;
4862

4963
tokio::spawn(async move {
50-
let _ = run_server(
51-
vector_db_service,
52-
ServerEndpoint::Listener(listener),
53-
TEST_AUTH_BEARER_TOKEN.to_string(),
54-
)
55-
.await
56-
.inspect_err(|err| panic!("Could not start test server : {:?}", err));
64+
let _ = run_server(vector_db_service, ServerEndpoint::Listener(listener), keys)
65+
.await
66+
.inspect_err(|err| panic!("Could not start test server : {:?}", err));
5767
});
5868

5969
Ok((listener_addr, temp_dir))
@@ -254,3 +264,41 @@ async fn test_unauthorized_rpc() {
254264
// request must fail
255265
assert!(resp.is_err());
256266
}
267+
268+
#[tokio::test]
269+
async fn test_readonly_key_cannot_write() {
270+
let (server_addr, _temp_dir) = start_test_server().await.unwrap();
271+
let mut client = create_test_client(server_addr).await.unwrap();
272+
273+
let mut request = tonic::Request::new(InsertVectorRequest {
274+
vector: Some(DenseVector {
275+
values: vec![1.0, 2.0, 3.0],
276+
}),
277+
payload: Some(Payload::default()),
278+
});
279+
append_test_auth_header(&mut request, TEST_READONLY_BEARER_TOKEN);
280+
281+
let resp = client.insert_vector(request).await;
282+
283+
assert_eq!(resp.unwrap_err().code(), tonic::Code::PermissionDenied);
284+
}
285+
286+
#[tokio::test]
287+
async fn test_readonly_key_can_search() {
288+
let (server_addr, _temp_dir) = start_test_server().await.unwrap();
289+
let mut client = create_test_client(server_addr).await.unwrap();
290+
291+
let mut request = tonic::Request::new(SearchRequest {
292+
query_vector: Some(DenseVector {
293+
values: vec![1.0, 2.0, 3.0],
294+
}),
295+
similarity: 0,
296+
limit: 1,
297+
ef: 0,
298+
});
299+
append_test_auth_header(&mut request, TEST_READONLY_BEARER_TOKEN);
300+
301+
let resp = client.search_points(request).await;
302+
303+
assert!(resp.is_ok());
304+
}

0 commit comments

Comments
 (0)