feat(proxy): implement JWT session minting with scope validation (P5.21.b, §13.21)
Implement plan §13.21 auth layer 2 for search UI session tokens: **JWT Claims Structure (plan §13.21):** - Add `iss: "miroir"` claim to identify token issuer - Add `scope: Vec<String>` for allowed actions (search, multi_search, beacon) - Keep `idx`, `sub`, `iat`, `exp` claims - Update `sign_jwt` to use "search-ui-session" as default sub **Scope Validation (defense-in-depth):** - Add `validate_jwt_scope()` function to check (method, path) against scope - Validate `idx` claim matches target index for search/beacon endpoints - Return `JwtValidationError::ScopeDenied` on mismatch - Integrate into `dispatch_bearer()` for automatic enforcement **Session Response (plan §13.21):** - Update `SearchUiSessionResponse` to include `index` and `rate_limit` fields - Return `token`, `expires_at`, `index`, `rate_limit` from session endpoint **Authentication Modes:** - `public`: unauthenticated, IP rate-limited - `shared_key`: requires X-Search-UI-Key header - `oauth_proxy`: requires upstream auth headers Closes: miroir-uhj.21.2 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
70f8401940
commit
bb5f46403a
4 changed files with 349 additions and 45 deletions
|
|
@ -46,15 +46,17 @@ pub struct CsrfState {
|
|||
// JWT claims (plan §13.21)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Claims embedded in a search UI JWT session token.
|
||||
/// Claims embedded in a search UI JWT session token (plan §13.21).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
/// Subject — user identifier or "anonymous".
|
||||
/// Issuer — always "miroir".
|
||||
pub iss: String,
|
||||
/// Subject — "search-ui-session" or user identifier in oauth_proxy mode.
|
||||
pub sub: String,
|
||||
/// Index this token grants access to.
|
||||
pub idx: String,
|
||||
/// Granted scope (e.g. "search").
|
||||
pub scope: String,
|
||||
/// Granted scope — array of allowed action names.
|
||||
pub scope: Vec<String>,
|
||||
/// Issued-at timestamp (seconds since epoch).
|
||||
pub iat: u64,
|
||||
/// Expiration timestamp (seconds since epoch).
|
||||
|
|
@ -183,15 +185,17 @@ impl std::fmt::Debug for AuthState {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl AuthState {
|
||||
/// Create a new signed JWT session token for the given index.
|
||||
/// Create a new signed JWT session token for the given index (plan §13.21).
|
||||
/// Always signs with the primary secret; `kid` header identifies it.
|
||||
pub fn sign_jwt(&self, sub: &str, idx: &str, scope: &str, ttl_s: u64) -> Option<String> {
|
||||
/// Scope defaults to ["search", "multi_search", "beacon"] for search UI sessions.
|
||||
pub fn sign_jwt(&self, sub: &str, idx: &str, scope: &[&str], ttl_s: u64) -> Option<String> {
|
||||
let secret = self.jwt_primary.as_ref()?;
|
||||
let now = epoch_seconds();
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: sub.to_string(),
|
||||
idx: idx.to_string(),
|
||||
scope: scope.to_string(),
|
||||
scope: scope.iter().map(|s| s.to_string()).collect(),
|
||||
iat: now,
|
||||
exp: now + ttl_s,
|
||||
};
|
||||
|
|
@ -242,6 +246,8 @@ pub enum JwtValidationError {
|
|||
Expired,
|
||||
/// `SEARCH_UI_JWT_SECRET_PREVIOUS` is set to the empty string (leak response).
|
||||
PreviousSecretEmpty,
|
||||
/// Token scope does not permit this (method, path) or idx claim mismatch.
|
||||
ScopeDenied,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -551,6 +557,86 @@ pub fn constant_time_compare(token: &[u8], expected: &[u8]) -> bool {
|
|||
token.ct_eq(expected).into()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope and index validation (plan §13.21 defense-in-depth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Action name for a given (method, path) combination per plan §13.21.
|
||||
/// Returns the scope action name if the path is a search UI endpoint, None otherwise.
|
||||
fn action_for_method_path(method: &Method, path: &str) -> Option<&'static str> {
|
||||
// POST /indexes/{idx}/search → "search"
|
||||
if method == Method::POST {
|
||||
if let Some(rest) = path.strip_prefix("/indexes/") {
|
||||
if let Some(idx_rest) = rest.strip_suffix("/search") {
|
||||
// Ensure the middle part is a valid index uid (non-empty, no slashes)
|
||||
if !idx_rest.is_empty() && !idx_rest.contains('/') {
|
||||
return Some("search");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /multi-search → "multi_search"
|
||||
if method == Method::POST && path == "/multi-search" {
|
||||
return Some("multi_search");
|
||||
}
|
||||
|
||||
// POST /_miroir/ui/search/{idx}/beacon → "beacon"
|
||||
if method == Method::POST {
|
||||
if let Some(rest) = path.strip_prefix("/_miroir/ui/search/") {
|
||||
if let Some(idx_rest) = rest.strip_suffix("/beacon") {
|
||||
if !idx_rest.is_empty() && !idx_rest.contains('/') {
|
||||
return Some("beacon");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate JWT scope and index claims against the request (plan §13.21).
|
||||
/// Returns Ok(()) if the (method, path) is allowed by the scope and idx claim,
|
||||
/// or Err(JwtScopeDenied) if the validation fails.
|
||||
pub fn validate_jwt_scope(
|
||||
claims: &JwtClaims,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Result<(), JwtValidationError> {
|
||||
// Determine the required action for this (method, path)
|
||||
let Some(required_action) = action_for_method_path(method, path) else {
|
||||
// This endpoint doesn't require scope validation
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Check if the required action is in the scope
|
||||
if !claims.scope.contains(&required_action.to_string()) {
|
||||
return Err(JwtValidationError::ScopeDenied);
|
||||
}
|
||||
|
||||
// For multi_search, we need to validate that every sub-query's indexUid matches idx
|
||||
// This is handled later in the request handler since we need to parse the body.
|
||||
// For search and beacon, validate the index in the path matches the claim.
|
||||
if required_action == "search" || required_action == "beacon" {
|
||||
let expected_idx = &claims.idx;
|
||||
let actual_idx = if required_action == "search" {
|
||||
// Extract index from /indexes/{idx}/search
|
||||
path.strip_prefix("/indexes/")
|
||||
.and_then(|rest| rest.strip_suffix("/search"))
|
||||
} else {
|
||||
// Extract index from /_miroir/ui/search/{idx}/beacon
|
||||
path.strip_prefix("/_miroir/ui/search/")
|
||||
.and_then(|rest| rest.strip_suffix("/beacon"))
|
||||
};
|
||||
|
||||
if actual_idx != Some(expected_idx) {
|
||||
return Err(JwtValidationError::ScopeDenied);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core dispatch — rules 0–4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -575,10 +661,19 @@ pub fn dispatch_bearer(
|
|||
None => return AuthVerdict::InvalidAuth, // Rule 4 — missing auth
|
||||
};
|
||||
|
||||
// Rule 1 — JWT-shape probe, then full validation
|
||||
// Rule 1 — JWT-shape probe, then full validation including scope check
|
||||
if probe_jwt_shape(token) {
|
||||
match state.validate_jwt(token) {
|
||||
Ok(_claims) => return AuthVerdict::Authenticated(TokenKind::Jwt),
|
||||
Ok(claims) => {
|
||||
// Defense-in-depth: validate scope and index claims (plan §13.21)
|
||||
match validate_jwt_scope(&claims, method, path) {
|
||||
Ok(()) => return AuthVerdict::Authenticated(TokenKind::Jwt),
|
||||
Err(JwtValidationError::ScopeDenied) => {
|
||||
return AuthVerdict::JwtScopeDenied;
|
||||
}
|
||||
Err(_) => return AuthVerdict::JwtInvalid,
|
||||
}
|
||||
}
|
||||
Err(JwtValidationError::PreviousSecretEmpty) => {
|
||||
return AuthVerdict::JwtInvalid;
|
||||
}
|
||||
|
|
@ -1216,18 +1311,22 @@ mod tests {
|
|||
#[test]
|
||||
fn sign_and_validate_primary_jwt() {
|
||||
let state = test_state_with_jwt();
|
||||
let token = state.sign_jwt("user1", "products", "search", 900).unwrap();
|
||||
let token = state
|
||||
.sign_jwt("user1", "products", &["search"], 900)
|
||||
.unwrap();
|
||||
|
||||
let claims = state.validate_jwt(&token).unwrap();
|
||||
assert_eq!(claims.sub, "user1");
|
||||
assert_eq!(claims.idx, "products");
|
||||
assert_eq!(claims.scope, "search");
|
||||
assert_eq!(claims.scope, vec!["search"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_jwt_authenticates_via_dispatch() {
|
||||
let state = test_state_with_jwt();
|
||||
let token = state.sign_jwt("user1", "products", "search", 900).unwrap();
|
||||
let token = state
|
||||
.sign_jwt("user1", "products", &["search"], 900)
|
||||
.unwrap();
|
||||
|
||||
let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(&token), &state);
|
||||
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt));
|
||||
|
|
@ -1238,9 +1337,10 @@ mod tests {
|
|||
let state = test_state_with_jwt();
|
||||
let now = epoch_seconds();
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: "search".to_string(),
|
||||
scope: vec!["search".to_string()],
|
||||
iat: now - 3600,
|
||||
exp: now - 100, // expired well beyond 30s leeway
|
||||
};
|
||||
|
|
@ -1263,7 +1363,9 @@ mod tests {
|
|||
#[test]
|
||||
fn tampered_signature_returns_invalid_signature() {
|
||||
let state = test_state_with_jwt();
|
||||
let mut token = state.sign_jwt("user1", "products", "search", 900).unwrap();
|
||||
let mut token = state
|
||||
.sign_jwt("user1", "products", &["search"], 900)
|
||||
.unwrap();
|
||||
// Tamper with the signature
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
token = format!("{}.{}.tampered_sig", parts[0], parts[1]);
|
||||
|
|
@ -1284,9 +1386,10 @@ mod tests {
|
|||
// Sign token with the previous secret
|
||||
let now = epoch_seconds();
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: "search".to_string(),
|
||||
scope: vec!["search".to_string()],
|
||||
iat: now,
|
||||
exp: now + 900,
|
||||
};
|
||||
|
|
@ -1324,7 +1427,7 @@ mod tests {
|
|||
#[test]
|
||||
fn rotation_new_token_validates_via_primary_secret() {
|
||||
let state = test_state_with_dual_jwt();
|
||||
let new_token = state.sign_jwt("user2", "orders", "search", 900).unwrap();
|
||||
let new_token = state.sign_jwt("user2", "orders", &["search"], 900).unwrap();
|
||||
|
||||
let validated = state.validate_jwt(&new_token).unwrap();
|
||||
assert_eq!(validated.sub, "user2");
|
||||
|
|
@ -1350,9 +1453,10 @@ mod tests {
|
|||
// Token signed with a completely different secret
|
||||
let now = epoch_seconds();
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: "search".to_string(),
|
||||
scope: vec!["search".to_string()],
|
||||
iat: now,
|
||||
exp: now + 900,
|
||||
};
|
||||
|
|
@ -1410,14 +1514,17 @@ mod tests {
|
|||
};
|
||||
|
||||
// Tokens signed with current primary work
|
||||
let token = state.sign_jwt("user1", "products", "search", 900).unwrap();
|
||||
let token = state
|
||||
.sign_jwt("user1", "products", &["search"], 900)
|
||||
.unwrap();
|
||||
assert!(state.validate_jwt(&token).is_ok());
|
||||
|
||||
// Old tokens signed with now-removed previous fail
|
||||
let old_claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: "search".to_string(),
|
||||
scope: vec!["search".to_string()],
|
||||
iat: epoch_seconds() - 100,
|
||||
exp: epoch_seconds() + 800,
|
||||
};
|
||||
|
|
@ -1458,7 +1565,7 @@ mod tests {
|
|||
))
|
||||
.unwrap(),
|
||||
};
|
||||
let token_v1 = pre.sign_jwt("alice", "idx", "search", 900).unwrap();
|
||||
let token_v1 = pre.sign_jwt("alice", "idx", &["search"], 900).unwrap();
|
||||
assert!(pre.validate_jwt(&token_v1).is_ok());
|
||||
|
||||
// During rotation: v2 primary, v1 previous
|
||||
|
|
@ -1478,7 +1585,7 @@ mod tests {
|
|||
// Old token still validates
|
||||
assert!(during.validate_jwt(&token_v1).is_ok());
|
||||
// New tokens work too
|
||||
let token_v2 = during.sign_jwt("bob", "idx", "search", 900).unwrap();
|
||||
let token_v2 = during.sign_jwt("bob", "idx", &["search"], 900).unwrap();
|
||||
assert!(during.validate_jwt(&token_v2).is_ok());
|
||||
|
||||
// Post-rotation: only v2
|
||||
|
|
@ -1783,4 +1890,189 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Scope and index validation tests (plan §13.21)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scope_validation_allows_search_on_matching_index() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec![
|
||||
"search".to_string(),
|
||||
"multi_search".to_string(),
|
||||
"beacon".to_string(),
|
||||
],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_denies_search_on_different_index() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec![
|
||||
"search".to_string(),
|
||||
"multi_search".to_string(),
|
||||
"beacon".to_string(),
|
||||
],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/orders/search");
|
||||
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_denies_missing_scope_action() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec!["beacon".to_string()], // missing "search"
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
|
||||
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_allows_multi_search() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec![
|
||||
"search".to_string(),
|
||||
"multi_search".to_string(),
|
||||
"beacon".to_string(),
|
||||
],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_denies_multi_search_without_scope() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec!["search".to_string()], // missing "multi_search"
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
|
||||
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_allows_beacon_on_matching_index() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec![
|
||||
"search".to_string(),
|
||||
"multi_search".to_string(),
|
||||
"beacon".to_string(),
|
||||
],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result =
|
||||
validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/products/beacon");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_denies_beacon_on_different_index() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec![
|
||||
"search".to_string(),
|
||||
"multi_search".to_string(),
|
||||
"beacon".to_string(),
|
||||
],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
let result = validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/orders/beacon");
|
||||
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_validation_skips_non_scoped_endpoints() {
|
||||
let claims = JwtClaims {
|
||||
iss: "miroir".to_string(),
|
||||
sub: "user1".to_string(),
|
||||
idx: "products".to_string(),
|
||||
scope: vec!["search".to_string()],
|
||||
iat: epoch_seconds(),
|
||||
exp: epoch_seconds() + 900,
|
||||
};
|
||||
|
||||
// Endpoints that don't require scope validation should pass
|
||||
assert!(validate_jwt_scope(&claims, &Method::GET, "/indexes/products").is_ok());
|
||||
assert!(validate_jwt_scope(&claims, &Method::POST, "/indexes/products/documents").is_ok());
|
||||
assert!(validate_jwt_scope(&claims, &Method::GET, "/_miroir/admin/settings").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_with_jwt_scope_denied_returns_scope_denied_verdict() {
|
||||
let state = test_state_with_jwt();
|
||||
let token = state
|
||||
.sign_jwt("user1", "products", &["search"], 900)
|
||||
.unwrap();
|
||||
|
||||
// Token should be valid, but trying to use it on a different index should fail
|
||||
let verdict = dispatch_bearer(
|
||||
&Method::POST,
|
||||
"/indexes/orders/search",
|
||||
Some(&token),
|
||||
&state,
|
||||
);
|
||||
assert_eq!(verdict, AuthVerdict::JwtScopeDenied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_with_jwt_correct_index_and_scope_succeeds() {
|
||||
let state = test_state_with_jwt();
|
||||
let token = state
|
||||
.sign_jwt(
|
||||
"user1",
|
||||
"products",
|
||||
&["search", "multi_search", "beacon"],
|
||||
900,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let verdict = dispatch_bearer(
|
||||
&Method::POST,
|
||||
"/indexes/products/search",
|
||||
Some(&token),
|
||||
&state,
|
||||
);
|
||||
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,10 +233,12 @@ impl FromRef<UnifiedState> for std::sync::Arc<miroir_core::cdc::CdcManager> {
|
|||
Arc::clone(cdc)
|
||||
} else {
|
||||
// Create a disabled CDC manager
|
||||
Arc::new(miroir_core::cdc::CdcManager::new(miroir_core::cdc::CdcConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}))
|
||||
Arc::new(miroir_core::cdc::CdcManager::new(
|
||||
miroir_core::cdc::CdcConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -525,7 +527,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
let pruner_config = config.task_registry.clone();
|
||||
tokio::spawn(async move {
|
||||
// The pruner runs in its own thread via spawn_pruner
|
||||
let _pruner_handle = task_pruner::spawn_pruner::<fn(&str) -> bool>(store, pruner_config, None);
|
||||
let _pruner_handle =
|
||||
task_pruner::spawn_pruner::<fn(&str) -> bool>(store, pruner_config, None);
|
||||
// The handle is dropped here only on process exit
|
||||
info!("task registry TTL pruner started");
|
||||
// Keep this task alive forever
|
||||
|
|
|
|||
|
|
@ -781,23 +781,24 @@ impl AppState {
|
|||
cdc_manager: {
|
||||
// Create CDC manager if enabled in config
|
||||
if config.cdc.enabled {
|
||||
let task_store: Option<Arc<dyn TaskStore>> = match config.task_store.backend.as_str() {
|
||||
"redis" => redis_store
|
||||
.as_ref()
|
||||
.map(|s| Arc::new(s.clone()) as Arc<dyn TaskStore>),
|
||||
"sqlite" if !config.task_store.path.is_empty() => Some(Arc::new(
|
||||
miroir_core::task_store::SqliteTaskStore::open(std::path::Path::new(
|
||||
&config.task_store.path,
|
||||
))
|
||||
.expect("Failed to open SQLite task store"),
|
||||
)
|
||||
as Arc<dyn TaskStore>),
|
||||
_ => None,
|
||||
};
|
||||
let task_store: Option<Arc<dyn TaskStore>> =
|
||||
match config.task_store.backend.as_str() {
|
||||
"redis" => redis_store
|
||||
.as_ref()
|
||||
.map(|s| Arc::new(s.clone()) as Arc<dyn TaskStore>),
|
||||
"sqlite" if !config.task_store.path.is_empty() => Some(Arc::new(
|
||||
miroir_core::task_store::SqliteTaskStore::open(
|
||||
std::path::Path::new(&config.task_store.path),
|
||||
)
|
||||
.expect("Failed to open SQLite task store"),
|
||||
)
|
||||
as Arc<dyn TaskStore>),
|
||||
_ => None,
|
||||
};
|
||||
Some(Arc::new(miroir_core::cdc::CdcManager::with_metrics(
|
||||
config.cdc.clone().into(), // Convert config::advanced::CdcConfig to cdc::CdcConfig
|
||||
None, // suppressed_metric_callback
|
||||
None, // dropped_metric_callback
|
||||
None, // suppressed_metric_callback
|
||||
None, // dropped_metric_callback
|
||||
task_store,
|
||||
)))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -65,11 +65,13 @@ pub struct AdminSessionResponse {
|
|||
pub expires_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Search UI session response.
|
||||
/// Search UI session response (plan §13.21).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchUiSessionResponse {
|
||||
pub token: String,
|
||||
pub expires_at: i64,
|
||||
pub index: String,
|
||||
pub rate_limit: String,
|
||||
}
|
||||
|
||||
/// POST /_miroir/admin/login - admin login with credentials.
|
||||
|
|
@ -399,7 +401,7 @@ where
|
|||
.sign_jwt(
|
||||
&subject,
|
||||
&index,
|
||||
"search",
|
||||
&["search", "multi_search", "beacon"],
|
||||
config.search_ui.auth.session_ttl_s,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
|
|
@ -410,6 +412,7 @@ where
|
|||
})?;
|
||||
|
||||
let expires_at = epoch_seconds() + config.search_ui.auth.session_ttl_s as i64;
|
||||
let rate_limit = config.search_ui.auth.session_rate_limit.clone();
|
||||
|
||||
info!(
|
||||
index = %index,
|
||||
|
|
@ -421,8 +424,13 @@ where
|
|||
// Build CSP header
|
||||
let csp_value = build_csp_header(&config.search_ui.csp, &config.search_ui.csp_overrides);
|
||||
|
||||
// Build response with CSP header
|
||||
let response = SearchUiSessionResponse { token, expires_at };
|
||||
// Build response with CSP header (plan §13.21)
|
||||
let response = SearchUiSessionResponse {
|
||||
token,
|
||||
expires_at,
|
||||
index: index.clone(),
|
||||
rate_limit,
|
||||
};
|
||||
let mut resp = Json(response).into_response();
|
||||
resp.headers_mut().insert(
|
||||
"Content-Security-Policy",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue