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
152 changes: 58 additions & 94 deletions ceres/src/api_service/admin_ops.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<bool, MegaError> {
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<bool, MegaError> {
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<Vec<String>, 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<Vec<String>, MegaError> {
self.get_effective_admins().await
}

/// Get admins from cache or storage.
async fn get_effective_admins(&self, root_dir: &str) -> Result<Vec<String>, 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<Vec<String>, 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<saturn::entitystore::EntityStore, MegaError> {
let dir_path = format!("/{}", root_dir);
/// Load EntityStore from `/.mega_cedar.json`.
async fn load_admin_entity_store(&self) -> Result<saturn::entitystore::EntityStore, MegaError> {
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(|| {
Comment on lines +89 to 93
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard against missing root admin file

This lookup now only searches the root tree for /.mega_cedar.json, so repositories created before this change (which only wrote .mega_cedar.json into each root directory) will fail admin checks with a “not found in root directory” error until a migration adds the new root file. That means /api/v1/admin/me and /api/v1/admin/list will start returning 500s after upgrade for existing repos. Consider a backward-compatible fallback (e.g., probe legacy locations when root lookup fails) or ensure a migration creates the root file before enabling this code path.

Useful? React with 👍 / 👎.

MegaError::Other(format!("{} not found in /{}", ADMIN_FILE, root_dir))
MegaError::Other(format!("{} not found in root directory", ADMIN_FILE))
})?;

let content_bytes = self
Expand All @@ -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<Vec<String>, MegaError> {
async fn get_admins_from_cache(&self) -> Result<Vec<String>, 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<String> = conn.get(&key).await?;

match data {
Expand All @@ -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(())
}
Expand Down
17 changes: 6 additions & 11 deletions ceres/src/api_service/mono_api_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache invalidation logic uses ends_with to check for .mega_cedar.json modifications. However, this will match any file ending with .mega_cedar.json regardless of its location in the tree. Since the admin file is now specifically at the root (/.mega_cedar.json), consider checking for the exact path or ensuring the normalized path equals .mega_cedar.json to avoid false positives from files like foo/.mega_cedar.json in subdirectories.

Suggested change
normalized.ends_with(crate::api_service::admin_ops::ADMIN_FILE)
let admin_file_path = crate::api_service::admin_ops::ADMIN_FILE;
normalized == admin_file_path || normalized == format!("/{}", admin_file_path)

Copilot uses AI. Check for mistakes.
});
if admin_file_modified {
self.invalidate_admin_cache().await;
}
}

Expand Down
28 changes: 20 additions & 8 deletions jupiter/src/utils/converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describes the tree item as .gitkeep, but the actual blob content format "Placeholder file for /{} directory" is specific to each root directory. While this is intentional to ensure different tree hashes (as noted in the comment on line 421), consider making the comment more explicit about why unique content is needed, mentioning that identical content would result in identical tree hashes which could cause issues.

Suggested change
// Create unique .gitkeep for each root directory to ensure different tree hashes
// Create a .gitkeep for each root directory with directory-specific content.
// This ensures that each root gets a distinct blob ID and thus a distinct tree hash.
// If all .gitkeep files had identical content, their blobs (and the trees containing
// them) would be identical, which could cause different roots to share the same
// tree hash and lead to confusing or incorrect behavior when working with Git trees.

Copilot uses AI. Check for mistakes.
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.
Expand Down Expand Up @@ -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]
Expand Down
Loading