diff --git a/ceres/src/api_service/admin_ops.rs b/ceres/src/api_service/admin_ops.rs index c81994621..9cae01972 100644 --- a/ceres/src/api_service/admin_ops.rs +++ b/ceres/src/api_service/admin_ops.rs @@ -1,14 +1,13 @@ -//! Path-aware admin permission operations. +//! Global admin permission operations. //! -//! This module provides admin permission checking with path context support. -//! Each root directory (e.g., project, doc, release) can have its own admin list -//! defined in its `.mega_cedar.json` file. +//! This module provides admin permission checking for the monorepo system. +//! All admin permissions are defined in a single `.mega_cedar.json` file +//! located in the root directory (`/`). //! //! # Design -//! - Admin permissions are scoped to root directories (first-level dirs under /) -//! - Each root directory has its own `.mega_cedar.json` file -//! - Cache keys are namespaced by both instance prefix and root directory -//! - Empty paths fall back to the default directory (project) +//! - A single global admin list applies to the entire monorepo +//! - The admin configuration file is stored at `/.mega_cedar.json` +//! - Redis caching is used to avoid repeated file parsing use common::errors::MegaError; use git_internal::internal::object::tree::Tree; @@ -17,123 +16,82 @@ use redis::AsyncCommands; use crate::api_service::mono_api_service::MonoApiService; -/// Cache TTL for admin lists (10 minutes). +/// Cache TTL for admin list (10 minutes). pub const ADMIN_CACHE_TTL: u64 = 600; -/// The Cedar entity file name in each root directory. +/// The Cedar entity file name in root directory. pub const ADMIN_FILE: &str = ".mega_cedar.json"; -/// Default root directory when path is empty or root. -pub const DEFAULT_ROOT_DIR: &str = "project"; - -/// Extract the root directory from a path. -/// -/// Examples: -/// - `/project/src/main.rs` -> `project` -/// - `/doc/readme.md` -> `doc` -/// - `/` or empty -> `project` (default) -pub fn extract_root_dir(path: &str) -> String { - let path = path.trim_start_matches('/'); - path.split('/') - .next() - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_ROOT_DIR) - .to_string() -} +/// Redis cache key suffix for admin list. +const ADMIN_CACHE_KEY_SUFFIX: &str = "admin:list"; impl MonoApiService { - /// Check if a user is an admin for the specified path context. - pub async fn check_is_admin(&self, username: &str, path: &str) -> Result { - let root_dir = extract_root_dir(path); - let admins = self.get_effective_admins(&root_dir).await?; + /// Check if a user is an admin. + pub async fn check_is_admin(&self, username: &str) -> Result { + let admins = self.get_effective_admins().await?; Ok(admins.contains(&username.to_string())) } - /// Retrieve all admin usernames for the specified path context. - pub async fn get_all_admins(&self, path: &str) -> Result, MegaError> { - let root_dir = extract_root_dir(path); - self.get_effective_admins(&root_dir).await + /// Retrieve all admin usernames. + pub async fn get_all_admins(&self) -> Result, MegaError> { + self.get_effective_admins().await } /// Get admins from cache or storage. - async fn get_effective_admins(&self, root_dir: &str) -> Result, MegaError> { - if let Ok(admins) = self.get_admins_from_cache(root_dir).await { + /// This method first attempts to read from Redis cache. On cache miss, + /// it loads the admin list from the `.mega_cedar.json` file and caches + /// the result. + async fn get_effective_admins(&self) -> Result, MegaError> { + if let Ok(admins) = self.get_admins_from_cache().await { return Ok(admins); } - let store = self.load_admin_entity_store(root_dir).await?; + let store = self.load_admin_entity_store().await?; let resolver = saturn::admin_resolver::AdminResolver::from_entity_store(&store); let admins = resolver.admin_list(); - if let Err(e) = self.cache_admins(root_dir, &admins).await { - tracing::warn!("Failed to write admin cache for {}: {}", root_dir, e); + if let Err(e) = self.cache_admins(&admins).await { + tracing::warn!("Failed to write admin cache: {}", e); } Ok(admins) } - /// Invalidate the admin list cache for a root directory. - pub async fn invalidate_admin_cache(&self, root_dir: &str) { + /// Invalidate the admin list cache. + /// This should be called when the `.mega_cedar.json` file is modified. + pub async fn invalidate_admin_cache(&self) { let mut conn = self.git_object_cache.connection.clone(); - let key = format!("{}:admin:list:{}", self.git_object_cache.prefix, root_dir); + let key = format!( + "{}:{}", + self.git_object_cache.prefix, ADMIN_CACHE_KEY_SUFFIX + ); if let Err(e) = conn.del::<_, ()>(&key).await { - tracing::warn!("Failed to invalidate admin cache for {}: {}", root_dir, e); + tracing::warn!("Failed to invalidate admin cache: {}", e); } } - /// Load EntityStore from `/{root_dir}/.mega_cedar.json`. - async fn load_admin_entity_store( - &self, - root_dir: &str, - ) -> Result { - let dir_path = format!("/{}", root_dir); + /// Load EntityStore from `/.mega_cedar.json`. + async fn load_admin_entity_store(&self) -> Result { let mono_storage = self.storage.mono_storage(); - let target_tree = if let Ok(Some(dir_ref)) = mono_storage.get_main_ref(&dir_path).await { - Tree::from_mega_model( - mono_storage - .get_tree_by_hash(&dir_ref.ref_tree_hash) - .await? - .ok_or_else(|| { - MegaError::Other(format!("Tree {} not found", dir_ref.ref_tree_hash)) - })?, - ) - } else { - // Fallback: traverse from root to find the directory - let root_ref = mono_storage - .get_main_ref("/") + let root_ref = mono_storage + .get_main_ref("/") + .await? + .ok_or_else(|| MegaError::Other("Root ref not found".into()))?; + + let root_tree = Tree::from_mega_model( + mono_storage + .get_tree_by_hash(&root_ref.ref_tree_hash) .await? - .ok_or_else(|| MegaError::Other("Root ref not found".into()))?; - - let root_tree = Tree::from_mega_model( - mono_storage - .get_tree_by_hash(&root_ref.ref_tree_hash) - .await? - .ok_or_else(|| MegaError::Other("Root tree not found".into()))?, - ); - - let dir_item = root_tree - .tree_items - .iter() - .find(|item| item.name == root_dir) - .ok_or_else(|| MegaError::Other(format!("'{}' directory not found", root_dir)))?; - - Tree::from_mega_model( - mono_storage - .get_tree_by_hash(&dir_item.id.to_string()) - .await? - .ok_or_else(|| { - MegaError::Other(format!("Tree for '{}' not found", root_dir)) - })?, - ) - }; - - let blob_item = target_tree + .ok_or_else(|| MegaError::Other("Root tree not found".into()))?, + ); + + let blob_item = root_tree .tree_items .iter() .find(|item| item.name == ADMIN_FILE) .ok_or_else(|| { - MegaError::Other(format!("{} not found in /{}", ADMIN_FILE, root_dir)) + MegaError::Other(format!("{} not found in root directory", ADMIN_FILE)) })?; let content_bytes = self @@ -149,9 +107,12 @@ impl MonoApiService { .map_err(|e| MegaError::Other(format!("JSON parse failed: {}", e))) } - async fn get_admins_from_cache(&self, root_dir: &str) -> Result, MegaError> { + async fn get_admins_from_cache(&self) -> Result, MegaError> { let mut conn = self.git_object_cache.connection.clone(); - let key = format!("{}:admin:list:{}", self.git_object_cache.prefix, root_dir); + let key = format!( + "{}:{}", + self.git_object_cache.prefix, ADMIN_CACHE_KEY_SUFFIX + ); let data: Option = conn.get(&key).await?; match data { @@ -161,12 +122,15 @@ impl MonoApiService { } } - async fn cache_admins(&self, root_dir: &str, admins: &[String]) -> Result<(), MegaError> { + async fn cache_admins(&self, admins: &[String]) -> Result<(), MegaError> { let mut conn = self.git_object_cache.connection.clone(); let json = serde_json::to_string(admins) .map_err(|e| MegaError::Other(format!("Serialize failed: {}", e)))?; - let key = format!("{}:admin:list:{}", self.git_object_cache.prefix, root_dir); + let key = format!( + "{}:{}", + self.git_object_cache.prefix, ADMIN_CACHE_KEY_SUFFIX + ); conn.set_ex::<_, _, ()>(&key, json, ADMIN_CACHE_TTL).await?; Ok(()) } diff --git a/ceres/src/api_service/mono_api_service.rs b/ceres/src/api_service/mono_api_service.rs index c74474838..a7b76ada1 100644 --- a/ceres/src/api_service/mono_api_service.rs +++ b/ceres/src/api_service/mono_api_service.rs @@ -1242,18 +1242,13 @@ impl MonoApiService { .map_err(|e| GitError::CustomError(format!("Failed to update CL status: {}", e)))?; // Invalidate admin cache when .mega_cedar.json is modified. - // File paths from get_sorted_changed_file_list are relative to cl.path. if let Ok(files) = self.get_sorted_changed_file_list(&cl.link, None).await { - for file in &files { - let normalized_path = file.replace('\\', "/"); - if normalized_path.ends_with(crate::api_service::admin_ops::ADMIN_FILE) { - let root_dir = if cl.path == "/" { - crate::api_service::admin_ops::extract_root_dir(&normalized_path) - } else { - crate::api_service::admin_ops::extract_root_dir(&cl.path) - }; - self.invalidate_admin_cache(&root_dir).await; - } + let admin_file_modified = files.iter().any(|file| { + let normalized = file.replace('\\', "/"); + normalized.ends_with(crate::api_service::admin_ops::ADMIN_FILE) + }); + if admin_file_modified { + self.invalidate_admin_cache().await; } } diff --git a/jupiter/src/utils/converter.rs b/jupiter/src/utils/converter.rs index c4abf4414..7f7494b0c 100644 --- a/jupiter/src/utils/converter.rs +++ b/jupiter/src/utils/converter.rs @@ -417,26 +417,37 @@ pub fn init_trees( let mut root_items = Vec::new(); let mut trees = Vec::new(); let mut blobs = Vec::new(); + + // Create unique .gitkeep for each root directory to ensure different tree hashes for dir in mono_config.root_dirs.clone() { - let entity_str = - saturn::entitystore::generate_entity(&mono_config.admin, &format!("/{dir}")).unwrap(); - let blob = Blob::from_content(&entity_str); + let gitkeep_content = format!("Placeholder file for /{} directory", dir); + let gitkeep_blob = Blob::from_content(&gitkeep_content); + blobs.push(gitkeep_blob.clone()); let tree_item = TreeItem { mode: TreeItemMode::Blob, - id: blob.id, - name: String::from(".mega_cedar.json"), + id: gitkeep_blob.id, + name: String::from(".gitkeep"), }; - let tree = Tree::from_tree_items(vec![tree_item.clone()]).unwrap(); + let tree = Tree::from_tree_items(vec![tree_item]).unwrap(); root_items.push(TreeItem { mode: TreeItemMode::Tree, id: tree.id, name: dir, }); trees.push(tree); - blobs.push(blob); } + // Create global .mega_cedar.json in root directory + let entity_str = saturn::entitystore::generate_entity(&mono_config.admin, "/").unwrap(); + let cedar_blob = Blob::from_content(&entity_str); + root_items.push(TreeItem { + mode: TreeItemMode::Blob, + id: cedar_blob.id, + name: String::from(".mega_cedar.json"), + }); + blobs.push(cedar_blob); + inject_root_buck_files(&mut root_items, &mut blobs, &mono_config.root_dirs); // Ensure the `toolchains` cell has a BUCK file at repo initialization time. @@ -884,7 +895,8 @@ mod test { let mega_blobs = converter.mega_blobs.borrow().clone(); let dir_nums = mono_config.root_dirs.len(); assert_eq!(mega_trees.len(), dir_nums + 1); - assert_eq!(mega_blobs.len(), dir_nums + 3); + // Blobs: dir_nums (.gitkeep) + 1 (.mega_cedar.json) + 2 (.buckroot + .buckconfig) + 1 (toolchains/BUCK) + assert_eq!(mega_blobs.len(), dir_nums + 4); } #[test] diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index cd857f9a8..1103854df 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -1,29 +1,17 @@ //! Admin-related API endpoints. //! -//! Provides endpoints for admin permission checks with path context support: -//! - `GET /api/v1/admin/me` - Check if current user is admin (optional `path` query param) -//! - `GET /api/v1/admin/list` - List all admins (admin-only, optional `path` query param) -//! -//! # Path Context -//! The `path` query parameter determines which root directory's admin list to check. -//! If not provided, defaults to `/project`. -//! -//! Examples: -//! - `GET /api/v1/admin/me?path=/doc` - Check if user is admin for `/doc` -//! - `GET /api/v1/admin/list?path=/release` - List admins for `/release` +//! Provides endpoints for admin permission checks: +//! - `GET /api/v1/admin/me` - Check if current user is admin +//! - `GET /api/v1/admin/list` - List all admins (admin-only) //! //! # Auth Behavior //! - 401 Unauthorized: No valid session (handled by `LoginUser` extractor) //! - 403 Forbidden: Logged in but not admin (for `/list` endpoint) -use axum::{ - Json, - extract::{Query, State}, -}; -use ceres::api_service::admin_ops; +use axum::{Json, extract::State}; use common::model::CommonResult; -use serde::{Deserialize, Serialize}; -use utoipa::{IntoParams, ToSchema}; +use serde::Serialize; +use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::{ @@ -31,28 +19,14 @@ use crate::{ server::http_server::USER_TAG, }; -/// Default path when not specified in query params. -const DEFAULT_PATH: &str = "/project"; - -/// Query parameters for admin endpoints. -#[derive(Debug, Deserialize, IntoParams)] -pub struct AdminQueryParams { - /// Path context to determine which root directory's admin list to check. - /// Defaults to `/project` if not provided. - #[param(example = "/project")] - pub path: Option, -} - #[derive(Serialize, ToSchema)] pub struct IsAdminResponse { pub is_admin: bool, - pub root_dir: String, } #[derive(Serialize, ToSchema)] pub struct AdminListResponse { pub admins: Vec, - pub root_dir: String, } /// Build the admin router. @@ -67,12 +41,10 @@ pub fn routers() -> OpenApiRouter { /// GET /api/v1/admin/me /// -/// Returns whether the current user is an admin for the specified path context. -/// If no path is provided, defaults to `/project`. +/// Returns whether the current user is an admin. #[utoipa::path( get, path = "/me", - params(AdminQueryParams), responses( (status = 200, body = CommonResult), (status = 401, description = "Unauthorized"), @@ -81,62 +53,44 @@ pub fn routers() -> OpenApiRouter { )] async fn is_admin_me( user: LoginUser, - Query(params): Query, State(state): State, ) -> Result>, ApiError> { - let path = params.path.as_deref().unwrap_or(DEFAULT_PATH); - let root_dir = admin_ops::extract_root_dir(path); - - let is_admin = state - .monorepo() - .check_is_admin(&user.username, path) - .await?; + let is_admin = state.monorepo().check_is_admin(&user.username).await?; Ok(Json(CommonResult::success(Some(IsAdminResponse { is_admin, - root_dir, })))) } /// GET /api/v1/admin/list /// -/// Returns a list of all admin usernames for the specified path context. -/// Only admins for that path can access this endpoint. +/// Returns a list of all admin usernames. +/// Only admins can access this endpoint. #[utoipa::path( get, path = "/list", - params(AdminQueryParams), responses( (status = 200, body = CommonResult), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - not admin for this path"), + (status = 403, description = "Forbidden - not admin"), ), tag = USER_TAG )] async fn admin_list( user: LoginUser, - Query(params): Query, State(state): State, ) -> Result>, ApiError> { - let path = params.path.as_deref().unwrap_or(DEFAULT_PATH); - let root_dir = admin_ops::extract_root_dir(path); - - // User must be admin for this path to view the admin list - if !state - .monorepo() - .check_is_admin(&user.username, path) - .await? - { + // User must be admin to view the admin list + if !state.monorepo().check_is_admin(&user.username).await? { return Err(ApiError::with_status( http::StatusCode::FORBIDDEN, - anyhow::anyhow!("Admin access required for path: {}", path), + anyhow::anyhow!("Admin access required"), )); } - let admins = state.monorepo().get_all_admins(path).await?; + let admins = state.monorepo().get_all_admins().await?; Ok(Json(CommonResult::success(Some(AdminListResponse { admins, - root_dir, })))) } diff --git a/mono/src/api/router/user_router.rs b/mono/src/api/router/user_router.rs index 98bb7d6de..0d0c21485 100644 --- a/mono/src/api/router/user_router.rs +++ b/mono/src/api/router/user_router.rs @@ -173,18 +173,3 @@ async fn list_token( let res = data.into_iter().map(|x| x.into()).collect(); Ok(Json(CommonResult::success(Some(res)))) } - -#[cfg(test)] -mod test { - use std::path::{Path, PathBuf}; - - #[test] - fn test_parse_all_cedar_file() { - let path = PathBuf::from("/project/mega/src"); - for component in path.ancestors() { - if component != Path::new("/") { - println!("{:?}", component.join(".mega_cedar.json")); - } - } - } -} diff --git a/saturn/src/admin_resolver.rs b/saturn/src/admin_resolver.rs index 5661ed6e3..744992b15 100644 --- a/saturn/src/admin_resolver.rs +++ b/saturn/src/admin_resolver.rs @@ -5,7 +5,7 @@ //! # Assumptions //! - Only checks direct membership in `UserGroup::"admin"` //! - Admin is the most privileged group; no groups inherit from it -//! - Path is fixed to `/project/.mega_cedar.json` (monorepo only) +//! - Path is fixed to `/.mega_cedar.json` (root directory) use std::collections::HashSet;