Added OIDC users group filter
Build and Publish / Build and Publish Docker Image (push) Successful in 2m41s

This commit is contained in:
Ultradesu
2026-05-25 16:26:45 +03:00
parent 709f319bc5
commit cae77e9401
11 changed files with 300 additions and 12 deletions
+13 -1
View File
@@ -129,6 +129,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
config.oidc_admin_groups.clone(),
defaults.oidc_admin_groups.clone()
),
entry!(
oidc_user_groups,
config.oidc_user_groups.clone(),
defaults.oidc_user_groups.clone()
),
entry!(
swagger_enabled,
config.swagger_enabled.to_string(),
@@ -248,6 +253,8 @@ struct SettingsTemplate {
oidc_client_secret_source: &'static str,
oidc_admin_groups: String,
oidc_admin_groups_source: &'static str,
oidc_user_groups: String,
oidc_user_groups_source: &'static str,
swagger_enabled: bool,
swagger_enabled_source: &'static str,
agent_enabled: bool,
@@ -298,6 +305,8 @@ pub async fn settings_handler(
oidc_client_secret_source: sources.oidc_client_secret.code(),
oidc_admin_groups: config.oidc_admin_groups,
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
oidc_user_groups: config.oidc_user_groups,
oidc_user_groups_source: sources.oidc_user_groups.code(),
swagger_enabled: config.swagger_enabled,
swagger_enabled_source: sources.swagger_enabled.code(),
agent_enabled: config.agent_enabled,
@@ -331,6 +340,7 @@ pub struct OidcSettingsForm {
oidc_client_id: Option<String>,
oidc_client_secret: Option<String>,
oidc_admin_groups: Option<String>,
oidc_user_groups: Option<String>,
swagger_enabled: Option<String>,
agent_enabled: Option<String>,
agent_inbox_dir: Option<String>,
@@ -378,6 +388,7 @@ pub async fn settings_submit(
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
@@ -386,7 +397,7 @@ pub async fn settings_submit(
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
let fields: [(&str, &str); 17] = [
let fields: [(&str, &str); 18] = [
("auth_password_enabled", pw_enabled),
("auth_sso_enabled", sso_enabled),
("oidc_button_text", &oidc_button_text),
@@ -394,6 +405,7 @@ pub async fn settings_submit(
("oidc_client_id", &oidc_client_id),
("oidc_client_secret", &oidc_client_secret),
("oidc_admin_groups", &oidc_admin_groups),
("oidc_user_groups", &oidc_user_groups),
("swagger_enabled", swagger),
("agent_enabled", agent_en),
("agent_inbox_dir", &agent_inbox_dir),
+7
View File
@@ -122,6 +122,7 @@ pub struct ConfigSources {
pub auth_sso_enabled: ConfigSource,
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub oidc_user_groups: ConfigSource,
pub swagger_enabled: ConfigSource,
pub agent_enabled: ConfigSource,
pub agent_inbox_dir: ConfigSource,
@@ -146,6 +147,7 @@ impl Default for ConfigSources {
auth_sso_enabled: ConfigSource::Default,
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
oidc_user_groups: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
agent_enabled: ConfigSource::Default,
agent_inbox_dir: ConfigSource::Default,
@@ -238,6 +240,8 @@ pub struct AppConfig {
pub oidc_button_text: String,
/// Comma-separated list of OIDC group names that grant admin role.
pub oidc_admin_groups: String,
/// Comma-separated list of OIDC group names that are allowed to use the service.
pub oidc_user_groups: String,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
/// Whether the AI agent background loop is enabled.
@@ -272,6 +276,7 @@ impl Default for AppConfig {
auth_sso_enabled: false,
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
oidc_user_groups: String::new(),
swagger_enabled: false,
agent_enabled: false,
agent_inbox_dir: String::new(),
@@ -297,6 +302,7 @@ impl_env_overrides!(
auth_sso_enabled,
oidc_button_text,
oidc_admin_groups,
oidc_user_groups,
swagger_enabled,
agent_enabled,
agent_inbox_dir,
@@ -372,6 +378,7 @@ impl AppConfig {
apply_db_field!(auth_sso_enabled);
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(oidc_user_groups);
apply_db_field!(swagger_enabled);
apply_db_field!(agent_enabled);
apply_db_field!(agent_inbox_dir);
+3
View File
@@ -70,6 +70,8 @@ translations! {
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
settings_oidc_user_groups: "User groups" , "Группы пользователей";
settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь.";
// User management
nav_users: "Users" , "Пользователи";
@@ -97,6 +99,7 @@ translations! {
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
// Artist management
nav_artists: "Artists" , "Артисты";
+1
View File
@@ -281,6 +281,7 @@ impl Project for FuruProject {
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
"\n",
" API:\n",
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
+36 -1
View File
@@ -384,10 +384,24 @@ pub async fn oidc_callback_handler(
.unwrap_or_default();
tracing::info!(
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}",
config.oidc_admin_groups,
config.oidc_user_groups,
);
if !is_allowed_by_groups(
&groups,
&config.oidc_user_groups,
&config.oidc_admin_groups,
) {
tracing::warn!(
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups,
config.oidc_admin_groups,
);
return redirect_login_with_error(i18n.t.login_access_denied);
}
// User provisioning logic.
let user = match provision_user(
&db,
@@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
auth::Role::User.code()
}
fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> {
groups
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
}
fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool {
groups.iter().any(|g| allowed.contains(g.as_str()))
}
fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool {
let user_set = parse_group_set(user_groups);
if user_set.is_empty() {
return true;
}
let admin_set = parse_group_set(admin_groups);
has_any_group(groups, &user_set) || has_any_group(groups, &admin_set)
}
async fn provision_user(
db: &Database,
issuer: &str,
+106
View File
@@ -71,6 +71,7 @@ struct ArtistDetail {
total_track_count: i64,
total_play_count: i64,
releases: Vec<ReleaseCard>,
featured_tracks: Vec<ArtistAppearanceTrack>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -92,6 +93,19 @@ struct TrackItem {
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistAppearanceTrack {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
artists: Vec<ArtistRef>,
featured_artists: Vec<ArtistRef>,
cover_url: Option<String>,
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseDetail {
id: i64,
@@ -357,6 +371,17 @@ struct PlaylistTrackRow {
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct AppearanceTrackRow {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct SearchArtistRow {
id: i64,
@@ -633,6 +658,86 @@ async fn artist_detail_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let featured_rows = sqlx::query_as::<_, AppearanceTrackRow>(
r#"SELECT DISTINCT t.id,
t.title::text AS title,
r.id AS release_id,
r.title::text AS release_title,
t.duration_seconds,
t.cover_file_id,
r.cover_file_id AS release_cover_file_id
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_id
WHERE ta.artist_id = $1
AND ta.role = 'featuring'
AND t.is_hidden = false
AND r.is_hidden = false
ORDER BY r.title::text, t.title::text"#,
)
.bind(artist_id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let featured_track_ids: Vec<i64> = featured_rows.iter().map(|t| t.id).collect();
let featured_track_artists = if featured_track_ids.is_empty() {
Vec::new()
} else {
sqlx::query_as::<_, TrackArtistRow>(
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = ANY($1)
ORDER BY ta.track_id, ta.position"#,
)
.bind(&featured_track_ids)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
};
let mut featured_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
std::collections::HashMap::new();
let mut featured_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
std::collections::HashMap::new();
for ta in &featured_track_artists {
let artist_ref = ArtistRef {
id: ta.artist_id,
name: ta.artist_name.clone(),
};
if ta.role == "featuring" {
featured_feat_artists
.entry(ta.track_id)
.or_default()
.push(artist_ref);
} else {
featured_main_artists
.entry(ta.track_id)
.or_default()
.push(artist_ref);
}
}
let featured_tracks: Vec<ArtistAppearanceTrack> = featured_rows
.into_iter()
.map(|t| {
let tid = t.id;
ArtistAppearanceTrack {
id: t.id,
title: t.title,
release_id: t.release_id,
release_title: t.release_title,
duration_seconds: t.duration_seconds,
artists: featured_main_artists.remove(&tid).unwrap_or_default(),
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
stream_url: format!("/api/player/stream/{tid}"),
}
})
.collect();
Json(ArtistDetail {
id: artist.id,
name: artist.name,
@@ -640,6 +745,7 @@ async fn artist_detail_handler(
total_track_count,
total_play_count,
releases: release_cards,
featured_tracks,
})
.into_response()
}