Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ MIZAN_DB_MAX_CONNECTIONS=10
MIZAN_ADMIN_EMAIL=
MIZAN_ADMIN_PASSWORD=
MIZAN_ADMIN_ROLE=admin
MIZAN_PROVIDER_SECRET_KEY=
86 changes: 86 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ async-trait = "0.1"
axum = "0.8"
redis = "1.2"
sha2 = "0.10.8"
base64 = "0.22"
aes-gcm = "0.10"
bcrypt = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ runtime limit engine before building a large dashboard.

## Status

Mizan is in bootstrap stage. The repository now contains product docs and a
minimal Rust workspace foundation with shared core types, modular crate
boundaries, a health endpoint, Docker Compose, and placeholder provider, gateway,
metering, wallet, limit, and RTK modules.
Mizan is in active bootstrap-to-MVP delivery. Milestone 3 (auth/API keys) and
Milestone 4 (provider/model management + `GET /v1/models`) are implemented.
Milestone 5 has a first `POST /v1/chat/completions` route with non-streaming
flow and model routing in place; streaming, upstream error shaping, and request
trace propagation are still in-progress.

## MVP Scope

Expand Down Expand Up @@ -106,6 +107,13 @@ cargo check --workspace
cargo test --workspace
```

Environment variables:

- `MIZAN_PROVIDER_SECRET_KEY` (required before creating provider connections, used to encrypt provider API keys at rest)
- `MIZAN_HTTP_ADDR` (default `0.0.0.0:18180`)
- `MIZAN_DATABASE_URL`, `MIZAN_DB_MAX_CONNECTIONS`, `MIZAN_RUN_MIGRATIONS` for storage
- `MIZAN_ADMIN_EMAIL`, `MIZAN_ADMIN_PASSWORD`, `MIZAN_ADMIN_ROLE` for optional bootstrap

Run the API locally:

```sh
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ connection as sensitive.
- Hash virtual API keys.
- Hash user passwords.
- Encrypt provider secrets at rest.
- Store `MIZAN_PROVIDER_SECRET_KEY` securely and rotate it on compromise.
- Never return provider credentials from APIs.
- Disable raw prompt/response logging by default.
- Audit admin changes to providers, model routes, pricing, and credits.
Expand Down
7 changes: 5 additions & 2 deletions crates/mizan-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ rust-version.workspace = true

[dependencies]
axum.workspace = true
aes-gcm.workspace = true
base64.workspace = true
bcrypt.workspace = true
mizan-core = { path = "../mizan-core" }
mizan-gateway = { path = "../mizan-gateway" }
redis.workspace = true
sha2.workspace = true
serde.workspace = true
Expand All @@ -19,3 +19,6 @@ tokio.workspace = true
tower-http.workspace = true
tracing.workspace = true
uuid.workspace = true
mizan-core = { path = "../mizan-core" }
mizan-gateway = { path = "../mizan-gateway" }
mizan-providers = { path = "../mizan-providers" }
76 changes: 4 additions & 72 deletions crates/mizan-api/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::time::{SystemTime, UNIX_EPOCH};

use crate::utils::{
from_app_error, is_unique_constraint_error, now_utc_epoch_seconds, prepare_sql,
unix_timestamp_string,
};
use axum::{
Extension, Json,
body::Body,
Expand Down Expand Up @@ -721,46 +723,10 @@ fn session_token_from_headers(headers: &HeaderMap) -> Option<&str> {
})
}

fn prepare_sql(database_backend: DatabaseBackend, query: &'static str) -> String {
match database_backend {
DatabaseBackend::Sqlite => query.to_string(),
DatabaseBackend::Postgres => to_dollar_params(query),
}
}

fn to_dollar_params(query: &str) -> String {
let mut parameter_index = 0usize;
let mut converted = String::with_capacity(query.len());

for character in query.chars() {
if character == '?' {
parameter_index += 1;
converted.push('$');
converted.push_str(&parameter_index.to_string());
continue;
}

converted.push(character);
}

converted
}

fn map_error(status: StatusCode, error: AppError) -> (StatusCode, Json<ErrorEnvelope>) {
(status, Json(ErrorEnvelope::from(&error)))
}

fn from_app_error(error: AppError) -> (StatusCode, Json<ErrorEnvelope>) {
let status = match error {
AppError::InvalidConfig { .. } => StatusCode::BAD_REQUEST,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Forbidden => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
map_error(status, error)
}

fn hash_value(value: &str) -> String {
let mut digest = Sha256::new();
digest.update(value.as_bytes());
Expand All @@ -771,26 +737,10 @@ fn hash_value(value: &str) -> String {
.collect::<String>()
}

fn now_utc_epoch_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs() as i64)
}

fn unix_timestamp_string() -> String {
now_utc_epoch_seconds().to_string()
}

fn normalize_email(email: &str) -> String {
email.trim().to_lowercase()
}

fn is_unique_constraint_error(message: &str) -> bool {
message.contains("already exists")
|| message.contains("UNIQUE constraint failed")
|| message.contains("duplicate key")
}

struct SessionRecord {
token: String,
expires_at: String,
Expand All @@ -813,24 +763,6 @@ mod tests {
assert_eq!(normalize_email(" User@Example.COM "), "user@example.com");
}

#[test]
fn prepare_sql_keeps_question_marks_for_sqlite() {
let prepared = prepare_sql(
DatabaseBackend::Sqlite,
"SELECT id FROM users WHERE id = ? AND role = ?",
);
assert_eq!(prepared, "SELECT id FROM users WHERE id = ? AND role = ?");
}

#[test]
fn prepare_sql_converts_question_marks_for_postgres() {
let prepared = prepare_sql(
DatabaseBackend::Postgres,
"SELECT id FROM users WHERE id = ? AND role = ?",
);
assert_eq!(prepared, "SELECT id FROM users WHERE id = $1 AND role = $2");
}

#[test]
fn authorization_token_supports_bearer_scheme() {
assert_eq!(
Expand Down
Loading