[model-gateway] add JWT/OIDC authentication for control plane APIs (#15850)
This commit is contained in:
@@ -5,8 +5,12 @@ from sglang_router.sglang_router_rs import (
|
||||
BackendType,
|
||||
HistoryBackendType,
|
||||
PolicyType,
|
||||
PyApiKeyEntry,
|
||||
PyControlPlaneAuthConfig,
|
||||
PyJwtConfig,
|
||||
PyOracleConfig,
|
||||
PyPostgresConfig,
|
||||
PyRole,
|
||||
)
|
||||
from sglang_router.sglang_router_rs import Router as _Router
|
||||
|
||||
@@ -61,6 +65,60 @@ def history_backend_from_str(backend_str: Optional[str]) -> HistoryBackendType:
|
||||
raise ValueError(f"Unknown history backend: {backend_str}")
|
||||
|
||||
|
||||
def role_from_str(role_str: str) -> PyRole:
|
||||
"""Convert role string to PyRole enum."""
|
||||
if role_str.lower() == "admin":
|
||||
return PyRole.Admin
|
||||
return PyRole.User
|
||||
|
||||
|
||||
def build_control_plane_auth_config(
|
||||
args_dict: dict,
|
||||
) -> Optional[PyControlPlaneAuthConfig]:
|
||||
"""Build control plane auth config from args dict."""
|
||||
api_keys = args_dict.get("control_plane_api_keys", [])
|
||||
jwt_issuer = args_dict.get("jwt_issuer")
|
||||
jwt_audience = args_dict.get("jwt_audience")
|
||||
audit_enabled = args_dict.get("control_plane_audit_enabled", False)
|
||||
|
||||
# Check if any auth is configured
|
||||
has_api_keys = bool(api_keys)
|
||||
has_jwt = jwt_issuer is not None and jwt_audience is not None
|
||||
|
||||
if not has_api_keys and not has_jwt:
|
||||
return None
|
||||
|
||||
# Build API key entries
|
||||
py_api_keys = []
|
||||
for key_tuple in api_keys:
|
||||
# Tuple format: (id, name, key, role)
|
||||
key_id, name, key, role = key_tuple
|
||||
py_api_keys.append(
|
||||
PyApiKeyEntry(
|
||||
id=key_id,
|
||||
name=name,
|
||||
key=key,
|
||||
role=role_from_str(role),
|
||||
)
|
||||
)
|
||||
|
||||
# Build JWT config if present
|
||||
jwt_config = None
|
||||
if has_jwt:
|
||||
jwt_config = PyJwtConfig(
|
||||
issuer=jwt_issuer,
|
||||
audience=jwt_audience,
|
||||
jwks_uri=args_dict.get("jwt_jwks_uri"),
|
||||
role_mapping=args_dict.get("jwt_role_mapping", {}),
|
||||
)
|
||||
|
||||
return PyControlPlaneAuthConfig(
|
||||
jwt=jwt_config,
|
||||
api_keys=py_api_keys,
|
||||
audit_enabled=audit_enabled,
|
||||
)
|
||||
|
||||
|
||||
class Router:
|
||||
"""
|
||||
A high-performance router for distributing requests across worker nodes.
|
||||
@@ -202,6 +260,9 @@ class Router:
|
||||
)
|
||||
args_dict["postgres_config"] = postgres_config
|
||||
|
||||
# Build control plane auth config
|
||||
args_dict["control_plane_auth"] = build_control_plane_auth_config(args_dict)
|
||||
|
||||
# Remove fields that shouldn't be passed to Rust Router constructor
|
||||
fields_to_remove = [
|
||||
"mini_lb",
|
||||
@@ -215,6 +276,13 @@ class Router:
|
||||
"oracle_pool_timeout_secs",
|
||||
"postgres_db_url",
|
||||
"postgres_pool_max",
|
||||
# Control plane auth fields (converted to control_plane_auth)
|
||||
"control_plane_api_keys",
|
||||
"control_plane_audit_enabled",
|
||||
"jwt_issuer",
|
||||
"jwt_audience",
|
||||
"jwt_jwks_uri",
|
||||
"jwt_role_mapping",
|
||||
]
|
||||
for field in fields_to_remove:
|
||||
args_dict.pop(field, None)
|
||||
|
||||
@@ -126,6 +126,15 @@ class RouterArgs:
|
||||
# Trace
|
||||
enable_trace: bool = False
|
||||
otlp_traces_endpoint: str = "localhost:4317"
|
||||
# Control plane authentication
|
||||
# API keys for control plane auth (list of tuples: id, name, key, role)
|
||||
control_plane_api_keys: List[tuple] = dataclasses.field(default_factory=list)
|
||||
control_plane_audit_enabled: bool = False
|
||||
# JWT/OIDC configuration for control plane auth
|
||||
jwt_issuer: Optional[str] = None
|
||||
jwt_audience: Optional[str] = None
|
||||
jwt_jwks_uri: Optional[str] = None
|
||||
jwt_role_mapping: Dict[str, str] = dataclasses.field(default_factory=dict)
|
||||
|
||||
@staticmethod
|
||||
def add_cli_args(
|
||||
@@ -686,6 +695,47 @@ class RouterArgs:
|
||||
default="localhost:4317",
|
||||
help="Config opentelemetry collector endpoint if --enable-trace is set. format: <ip>:<port>",
|
||||
)
|
||||
# Control plane authentication
|
||||
parser.add_argument(
|
||||
f"--{prefix}control-plane-api-keys",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="API keys for control plane authentication. Format: 'id:name:role:key' where role is 'admin' or 'user'. "
|
||||
"Example: --control-plane-api-keys 'key1:Service Account:admin:secret123' 'key2:Read Only:user:secret456'",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}control-plane-audit-enabled",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable audit logging for control plane operations",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}jwt-issuer",
|
||||
type=str,
|
||||
default=None,
|
||||
help="OIDC issuer URL for JWT authentication (e.g., https://login.microsoftonline.com/{tenant}/v2.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}jwt-audience",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Expected audience claim for JWT tokens (usually the client ID or API identifier)",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}jwt-jwks-uri",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Explicit JWKS URI. If not provided, discovered from issuer via .well-known/openid-configuration",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{prefix}jwt-role-mapping",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Mapping from IDP role/group names to gateway roles. Format: 'idp_role=gateway_role'. "
|
||||
"Example: --jwt-role-mapping 'Gateway.Admin=admin' 'Gateway.User=user'",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cli_args(
|
||||
@@ -741,6 +791,16 @@ class RouterArgs:
|
||||
# Mooncake-specific annotation
|
||||
args_dict["bootstrap_port_annotation"] = "sglang.ai/bootstrap-port"
|
||||
|
||||
# Parse control plane API keys
|
||||
args_dict["control_plane_api_keys"] = cls._parse_control_plane_api_keys(
|
||||
cli_args_dict.get(f"{prefix}control_plane_api_keys", [])
|
||||
)
|
||||
|
||||
# Parse JWT role mapping
|
||||
args_dict["jwt_role_mapping"] = cls._parse_jwt_role_mapping(
|
||||
cli_args_dict.get(f"{prefix}jwt_role_mapping", [])
|
||||
)
|
||||
|
||||
return cls(**args_dict)
|
||||
|
||||
def _validate_router_args(self):
|
||||
@@ -830,3 +890,52 @@ class RouterArgs:
|
||||
|
||||
# decode_list is a list of single-element lists due to nargs=1
|
||||
return [url[0] for url in decode_list]
|
||||
|
||||
@staticmethod
|
||||
def _parse_control_plane_api_keys(api_keys_list):
|
||||
"""Parse control plane API keys from --control-plane-api-keys arguments.
|
||||
|
||||
Format: id:name:role:key
|
||||
Example: --control-plane-api-keys 'key1:Service Account:admin:secret123'
|
||||
"""
|
||||
if not api_keys_list:
|
||||
return []
|
||||
|
||||
parsed_keys = []
|
||||
for key_str in api_keys_list:
|
||||
parts = key_str.split(":", 3) # Split into at most 4 parts
|
||||
if len(parts) != 4:
|
||||
raise ValueError(
|
||||
f"Invalid API key format: '{key_str}'. Expected 'id:name:role:key'"
|
||||
)
|
||||
key_id, name, role, key = parts
|
||||
role_lower = role.lower()
|
||||
if role_lower not in ("admin", "user"):
|
||||
raise ValueError(f"Invalid role: '{role}'. Must be 'admin' or 'user'")
|
||||
parsed_keys.append((key_id, name, key, role_lower))
|
||||
return parsed_keys
|
||||
|
||||
@staticmethod
|
||||
def _parse_jwt_role_mapping(role_mapping_list):
|
||||
"""Parse JWT role mapping from --jwt-role-mapping arguments.
|
||||
|
||||
Format: idp_role=gateway_role
|
||||
Example: --jwt-role-mapping 'Gateway.Admin=admin' 'Gateway.User=user'
|
||||
"""
|
||||
if not role_mapping_list:
|
||||
return {}
|
||||
|
||||
mapping = {}
|
||||
for mapping_str in role_mapping_list:
|
||||
if "=" not in mapping_str:
|
||||
raise ValueError(
|
||||
f"Invalid role mapping format: '{mapping_str}'. Expected 'idp_role=gateway_role'"
|
||||
)
|
||||
idp_role, gateway_role = mapping_str.split("=", 1)
|
||||
gateway_role_lower = gateway_role.lower()
|
||||
if gateway_role_lower not in ("admin", "user"):
|
||||
raise ValueError(
|
||||
f"Invalid gateway role: '{gateway_role}'. Must be 'admin' or 'user'"
|
||||
)
|
||||
mapping[idp_role] = gateway_role_lower
|
||||
return mapping
|
||||
|
||||
@@ -31,6 +31,152 @@ pub enum HistoryBackendType {
|
||||
Postgres,
|
||||
}
|
||||
|
||||
#[pyclass(eq)]
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub enum PyRole {
|
||||
Admin,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl PyRole {
|
||||
pub fn to_auth_role(&self) -> auth::Role {
|
||||
match self {
|
||||
PyRole::Admin => auth::Role::Admin,
|
||||
PyRole::User => auth::Role::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PyApiKeyEntry {
|
||||
#[pyo3(get, set)]
|
||||
pub id: String,
|
||||
#[pyo3(get, set)]
|
||||
pub name: String,
|
||||
#[pyo3(get, set)]
|
||||
pub key: String,
|
||||
#[pyo3(get, set)]
|
||||
pub role: PyRole,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyApiKeyEntry {
|
||||
#[new]
|
||||
#[pyo3(signature = (id, name, key, role = PyRole::User))]
|
||||
fn new(id: String, name: String, key: String, role: PyRole) -> Self {
|
||||
PyApiKeyEntry { id, name, key, role }
|
||||
}
|
||||
}
|
||||
|
||||
impl PyApiKeyEntry {
|
||||
pub fn to_auth_api_key_entry(&self) -> auth::ApiKeyEntry {
|
||||
auth::ApiKeyEntry::new(&self.id, &self.name, &self.key, self.role.to_auth_role())
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PyJwtConfig {
|
||||
#[pyo3(get, set)]
|
||||
pub issuer: String,
|
||||
#[pyo3(get, set)]
|
||||
pub audience: String,
|
||||
#[pyo3(get, set)]
|
||||
pub jwks_uri: Option<String>,
|
||||
#[pyo3(get, set)]
|
||||
pub role_mapping: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyJwtConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
issuer,
|
||||
audience,
|
||||
jwks_uri = None,
|
||||
role_mapping = HashMap::new(),
|
||||
))]
|
||||
fn new(
|
||||
issuer: String,
|
||||
audience: String,
|
||||
jwks_uri: Option<String>,
|
||||
role_mapping: HashMap<String, String>,
|
||||
) -> Self {
|
||||
PyJwtConfig {
|
||||
issuer,
|
||||
audience,
|
||||
jwks_uri,
|
||||
role_mapping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PyJwtConfig {
|
||||
pub fn to_auth_jwt_config(&self) -> auth::JwtConfig {
|
||||
let mut config = auth::JwtConfig::new(&self.issuer, &self.audience);
|
||||
|
||||
// Conditionally set JWKS URI
|
||||
if let Some(ref uri) = self.jwks_uri {
|
||||
config = config.with_jwks_uri(uri);
|
||||
}
|
||||
|
||||
// Add role mappings
|
||||
for (idp_role, gateway_role) in &self.role_mapping {
|
||||
let role = match gateway_role.to_lowercase().as_str() {
|
||||
"admin" => auth::Role::Admin,
|
||||
_ => auth::Role::User,
|
||||
};
|
||||
config = config.with_role_mapping(idp_role, role);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PyControlPlaneAuthConfig {
|
||||
#[pyo3(get, set)]
|
||||
pub jwt: Option<PyJwtConfig>,
|
||||
#[pyo3(get, set)]
|
||||
pub api_keys: Vec<PyApiKeyEntry>,
|
||||
#[pyo3(get, set)]
|
||||
pub audit_enabled: bool,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyControlPlaneAuthConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
jwt = None,
|
||||
api_keys = vec![],
|
||||
audit_enabled = true,
|
||||
))]
|
||||
fn new(
|
||||
jwt: Option<PyJwtConfig>,
|
||||
api_keys: Vec<PyApiKeyEntry>,
|
||||
audit_enabled: bool,
|
||||
) -> Self {
|
||||
PyControlPlaneAuthConfig {
|
||||
jwt,
|
||||
api_keys,
|
||||
audit_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PyControlPlaneAuthConfig {
|
||||
pub fn to_auth_control_plane_config(&self) -> auth::ControlPlaneAuthConfig {
|
||||
auth::ControlPlaneAuthConfig {
|
||||
jwt: self.jwt.as_ref().map(|j| j.to_auth_jwt_config()),
|
||||
api_keys: self.api_keys.iter().map(|k| k.to_auth_api_key_entry()).collect(),
|
||||
audit_enabled: self.audit_enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct PyOracleConfig {
|
||||
@@ -232,6 +378,7 @@ struct Router {
|
||||
server_key_path: Option<String>,
|
||||
enable_trace: bool,
|
||||
otlp_traces_endpoint: String,
|
||||
control_plane_auth: Option<PyControlPlaneAuthConfig>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
@@ -502,6 +649,7 @@ impl Router {
|
||||
server_key_path = None,
|
||||
enable_trace = false,
|
||||
otlp_traces_endpoint = String::from("localhost:4317"),
|
||||
control_plane_auth = None,
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
@@ -583,6 +731,7 @@ impl Router {
|
||||
server_key_path: Option<String>,
|
||||
enable_trace: bool,
|
||||
otlp_traces_endpoint: String,
|
||||
control_plane_auth: Option<PyControlPlaneAuthConfig>,
|
||||
) -> PyResult<Self> {
|
||||
let mut all_urls = worker_urls.clone();
|
||||
|
||||
@@ -678,6 +827,7 @@ impl Router {
|
||||
server_key_path,
|
||||
enable_trace,
|
||||
otlp_traces_endpoint,
|
||||
control_plane_auth,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,6 +886,10 @@ impl Router {
|
||||
request_timeout_secs: self.request_timeout_secs,
|
||||
request_id_headers: self.request_id_headers.clone(),
|
||||
shutdown_grace_period_secs: self.shutdown_grace_period_secs,
|
||||
control_plane_auth: self
|
||||
.control_plane_auth
|
||||
.as_ref()
|
||||
.map(|c| c.to_auth_control_plane_config()),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))
|
||||
@@ -772,6 +926,10 @@ fn sglang_router_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PolicyType>()?;
|
||||
m.add_class::<BackendType>()?;
|
||||
m.add_class::<HistoryBackendType>()?;
|
||||
m.add_class::<PyRole>()?;
|
||||
m.add_class::<PyApiKeyEntry>()?;
|
||||
m.add_class::<PyJwtConfig>()?;
|
||||
m.add_class::<PyControlPlaneAuthConfig>()?;
|
||||
m.add_class::<PyOracleConfig>()?;
|
||||
m.add_class::<PyPostgresConfig>()?;
|
||||
m.add_class::<Router>()?;
|
||||
|
||||
Reference in New Issue
Block a user