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
72 changes: 52 additions & 20 deletions ceres/src/application/api_service/mono/admin/permissions.rs
Original file line number Diff line number Diff line change
@@ -1,50 +1,82 @@
//! Global admin permission operations.
//!
//! 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 (`/`).
//! Effective admins are the **union** of:
//! - `monorepo.admin` in mega config (always applied; used at monorepo init and at runtime)
//! - users in the root `/.mega_cedar.json` admin group
//!
//! # Design
//! - 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
//! - Config admins are checked on every request (not baked into Redis), so they
//! remain valid even when Cedar/Redis is stale or missing
//! - Cedar-derived admins are Redis-cached (TTL 10 minutes) to avoid re-parsing
//! `.mega_cedar.json`

use std::collections::BTreeSet;

use common::errors::MegaError;
use git_internal::internal::object::tree::Tree;
use jupiter::{redis::AsyncCommands, utils::converter::FromMegaModel};

use crate::application::api_service::mono::context::AdminApplicationService;

/// Cache TTL for admin list (10 minutes).
/// Cache TTL for Cedar admin list (10 minutes).
pub const ADMIN_CACHE_TTL: u64 = 600;

/// The Cedar entity file name in root directory.
pub const ADMIN_FILE: &str = ".mega_cedar.json";

/// Redis cache key suffix for admin list.
/// Redis cache key suffix for Cedar admin list (config admins are merged at read time).
const ADMIN_CACHE_KEY_SUFFIX: &str = "admin:list";

impl AdminApplicationService {
/// Check if a user is an admin.
/// Check if a user is an admin (config `monorepo.admin` or Cedar).
pub async fn check_is_admin(&self, username: &str) -> Result<bool, MegaError> {
let username = username.trim();
if username.is_empty() {
return Ok(false);
}
let admins = self.get_effective_admins().await?;
Ok(admins.contains(&username.to_string()))
Ok(admins.iter().any(|a| a == username))
}

/// Retrieve all admin usernames.
/// Retrieve all effective admin identities (config ∪ Cedar), sorted uniquely.
pub async fn get_all_admins(&self) -> Result<Vec<String>, MegaError> {
self.get_effective_admins().await
}

/// Get admins from cache or storage.
/// 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.
/// GitHub logins (or Cedar euids) listed under `[monorepo] admin` in config.
fn config_admins(&self) -> Vec<String> {
self.ctx
.storage()
.config()
.monorepo
.admin
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}

/// Merge Cedar admins with config admins (sorted, unique).
fn merge_with_config_admins(&self, cedar_admins: Vec<String>) -> Vec<String> {
let mut set: BTreeSet<String> = cedar_admins.into_iter().collect();
for admin in self.config_admins() {
set.insert(admin);
}
set.into_iter().collect()
}

/// Get effective admins: Redis/Cedar list ∪ `monorepo.admin`.
///
/// If `.mega_cedar.json` (or root refs) are missing, returns an empty list
/// so callers can fall through to other authz paths (e.g. user approval)
/// instead of hard-failing the request.
/// Config admins are always merged after cache/file load so a stale Redis
/// Cedar list cannot drop configured admins. If `.mega_cedar.json` is
/// missing, config admins alone still apply.
async fn get_effective_admins(&self) -> Result<Vec<String>, MegaError> {
let cedar_admins = self.get_cedar_admins().await?;
Ok(self.merge_with_config_admins(cedar_admins))
}

/// Cedar-only admin list (cached). Does not include config admins.
async fn get_cedar_admins(&self) -> Result<Vec<String>, MegaError> {
if let Ok(admins) = self.get_admins_from_cache().await {
return Ok(admins);
}
Expand All @@ -54,7 +86,7 @@ impl AdminApplicationService {
Err(e) if is_admin_config_unavailable(&e) => {
tracing::warn!(
error = %e,
"Admin config unavailable; treating as empty admin list"
"Admin Cedar config unavailable; using monorepo.admin from config only"
);
return Ok(Vec::new());
}
Expand All @@ -70,7 +102,7 @@ impl AdminApplicationService {
Ok(admins)
}

/// Invalidate the admin list cache.
/// Invalidate the Cedar 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.ctx.git_object_cache().connection.clone();
Expand Down
2 changes: 1 addition & 1 deletion config/config-workflow.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ test_user_token = "mega"
## Mega treats files under this directory as import repo and other directories as monorepo
import_dir = "/third-party"

# Set System Admin in directory init, replace the admin's github username here
# System admin GitHub logins (init + always treated as admin at runtime)
admin = "admin"

# Set serveral root dirs in directory init
Expand Down
3 changes: 2 additions & 1 deletion config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ sqlx_logging = false
## Mega treats files under this directory as import repo and other directories as monorepo
import_dir = "/third-party"

# Set System Admin(s) in directory init, these users will be added to the admin group
# System admin GitHub logins. Used when initializing /.mega_cedar.json and
# always treated as admins at runtime (union with Cedar admin group).
# Supports multiple admins: admin = ["user1", "user2", "user3"]
admin = ["benjamin-747"]

Expand Down
4 changes: 2 additions & 2 deletions jupiter/src/storage/cl_reviewer_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl ClReviewerStorage {
MegaError::Other(format!("fail to find reviewer {}", campsite_user_id))
})?
.ok_or_else(|| {
MegaError::Other(format!("reviewer {} not found", campsite_user_id))
MegaError::NotFound(format!("reviewer {} not found", campsite_user_id))
})?
.into_active_model();

Expand Down Expand Up @@ -201,7 +201,7 @@ impl ClReviewerStorage {
tracing::error!("{}", e);
MegaError::Other(format!("fail to find reviewer {}", campsite_user_id))
})?
.ok_or_else(|| MegaError::Other(format!("reviewer {} not found", campsite_user_id)))?
.ok_or_else(|| MegaError::NotFound(format!("reviewer {} not found", campsite_user_id)))?
.into_active_model();

rev.approved = Set(approved);
Expand Down
61 changes: 56 additions & 5 deletions jupiter/src/storage/data_backfill_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,72 @@ impl DataBackfillStorage {
let i = esc(id);
let login_sql = github.map(esc).unwrap_or_default();

// Tables where campsite_user_id is the sole PK: drop the handle
// row when the target id already exists, then rename remaining.
for table in [
"cla_sign_status",
"user_approval_status",
"user_notification_settings",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve notification preferences before deleting settings

When a handle and its campsite id both already have user_notification_settings rows, this collision path deletes the handle settings row. The preferences table has an ON DELETE CASCADE FK to settings (jupiter-migrate/src/migration/m20260224_230000_create_notification_center.rs:159-160), so the delete removes all of the handle's per-event notification overrides before the later rename/merge can move them. Users with pre-backfill notification overrides lose those preferences during identity backfill; merge/update preferences before deleting the settings row, or exclude settings from this delete path.

Useful? React with 👍 / 👎.

"user_notification_preferences",
] {
affected += exec_unprepared(
&txn,
&format!(
r#"
DELETE FROM {table}
WHERE campsite_user_id = '{h}'
AND EXISTS (
SELECT 1 FROM {table} AS keep
WHERE keep.campsite_user_id = '{i}'
)
"#
),
)
.await?;
affected += exec_unprepared(
&txn,
&format!(
"UPDATE {table} SET campsite_user_id = '{i}' WHERE campsite_user_id = '{h}'"
),
)
.await?;
}

// Composite PK (item_id, campsite_user_id).
affected += exec_unprepared(
&txn,
&format!(
r#"
DELETE FROM item_assignees AS old_row
WHERE old_row.campsite_user_id = '{h}'
AND EXISTS (
SELECT 1 FROM item_assignees AS new_row
WHERE new_row.item_id = old_row.item_id
AND new_row.item_type = old_row.item_type
AND new_row.campsite_user_id = '{i}'
)
"#
),
)
.await?;
affected += exec_unprepared(
&txn,
&format!(
"UPDATE item_assignees SET campsite_user_id = '{i}' WHERE campsite_user_id = '{h}'"
),
)
.await?;

for table in [
"mega_cl",
"mega_issue",
"mega_conversation",
"reactions",
"item_assignees",
"mega_code_review_comment",
"access_token",
"ssh_keys",
"cla_sign_status",
"user_notification_settings",
"user_notification_preferences",
"email_jobs",
"mega_group_member",
"user_approval_status",
] {
affected += exec_unprepared(
&txn,
Expand Down
2 changes: 1 addition & 1 deletion moon/apps/web/components/AdminGroups/AddMembersDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
if (groupId === null) return null

return (
<div className='bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4'>
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4'>
<div className='bg-primary border-primary flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl'>
{/* Fixed header */}
<div className='shrink-0 border-b border-gray-200 px-6 py-4 dark:border-gray-700'>
Expand Down Expand Up @@ -177,7 +177,7 @@
onClick={(e) => e.stopPropagation()}
className='mr-3 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
/>
<img

Check warning on line 180 in moon/apps/web/components/AdminGroups/AddMembersDialog.tsx

View workflow job for this annotation

GitHub Actions / test-web-ui

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={member.user.avatar_urls?.sm || ''}
alt={member.user.display_name}
className='mr-3 h-8 w-8 shrink-0 rounded-full border border-gray-200 dark:border-gray-600'
Expand Down Expand Up @@ -228,7 +228,7 @@
<div className='mr-3 flex h-4 w-4 items-center justify-center'>
<div className='h-2 w-2 rounded-full bg-green-500'></div>
</div>
<img

Check warning on line 231 in moon/apps/web/components/AdminGroups/AddMembersDialog.tsx

View workflow job for this annotation

GitHub Actions / test-web-ui

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={member.user.avatar_urls?.sm || ''}
alt={member.user.display_name}
className='mr-3 h-8 w-8 shrink-0 rounded-full border border-gray-200 dark:border-gray-600'
Expand Down
2 changes: 1 addition & 1 deletion moon/apps/web/components/AdminGroups/CreateGroupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const CreateGroupDialog = ({ isOpen, onClose }: CreateGroupDialogProps) =
if (!isOpen) return null

return (
<div className='bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black'>
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='bg-primary border-primary w-full max-w-md rounded-lg border p-6 shadow-lg'>
<h2 className='text-primary mb-4 text-xl font-bold'>Create New Group</h2>

Expand Down
2 changes: 1 addition & 1 deletion moon/apps/web/components/AdminGroups/DeleteGroupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const DeleteGroupDialog = ({ groupId, onClose }: DeleteGroupDialogProps)
if (groupId === null) return null

return (
<div className='bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black'>
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='bg-primary border-primary w-full max-w-md rounded-lg border p-6 shadow-lg'>
<h2 className='text-primary mb-4 text-xl font-bold'>Delete Group</h2>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const GroupMembersDialog = ({ groupId, groupName, onClose }: GroupMembers
if (groupId === null) return null

return (
<div className='bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black p-4'>
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4'>
<div className='bg-primary border-primary flex max-h-[90vh] w-full max-w-2xl flex-col rounded-lg border shadow-xl'>
{/* Fixed header */}
<div className='shrink-0 border-b border-gray-200 px-6 py-4 dark:border-gray-700'>
Expand Down
Loading