use axum::extract::FromRef;
use slave_framework::database::{Db, Executor};
use crate::base::auth::AuthUser;
use crate::base::error::{AppError, AppResult};
use crate::base::state::AppState;
use crate::modules::app::App;
use crate::modules::team::{Access, Team, require_access};
/// Business rules for apps and their owner teams.
#[derive(Debug, Clone)]
pub struct AppService {
db: Db,
}
impl FromRef<AppState> for AppService {
fn from_ref(state: &AppState) -> Self {
Self {
db: state.db.clone(),
}
}
}
/// An app with its owner team, if any.
pub struct OwnedApp {
pub app: App,
pub team: Option<TeamSummary>,
}
/// The owner team of an app.
pub struct TeamSummary {
pub id: i64,
pub name: String,
pub slug: String,
}
impl From<&Team> for TeamSummary {
fn from(team: &Team) -> Self {
Self {
id: team.id,
name: team.name.clone(),
slug: team.slug.clone(),
}
}
}
impl AppService {
/// Every app for super admins; otherwise the apps owned by the caller's
/// teams.
pub async fn list(&self, user: &AuthUser) -> AppResult<Vec<OwnedApp>> {
let apps: Vec<App> = self
.db
.query(format!("SELECT {} FROM app ORDER BY name", App::FIELDS))
.await?
.take(0)?;
let team_ids: Vec<i64> = if user.is_super_admin() {
apps.iter().filter_map(|app| app.owner_team_id).collect()
} else {
self.db
.query("SELECT VALUE team_id FROM membership WHERE user_id = $user_id")
.bind(("user_id", user.id))
.await?
.take(0)?
};
let teams: Vec<Team> = self
.db
.query(format!(
"SELECT {} FROM team WHERE record::id(id) IN $ids",
Team::FIELDS
))
.bind(("ids", team_ids.clone()))
.await?
.take(0)?;
Ok(apps
.into_iter()
.filter(|app| {
user.is_super_admin() || app.owner_team_id.is_some_and(|id| team_ids.contains(&id))
})
.map(|app| {
let team = app
.owner_team_id
.and_then(|id| teams.iter().find(|t| t.id == id))
.map(TeamSummary::from);
OwnedApp { app, team }
})
.collect())
}
/// Gives app `name` to team `team_id`, or takes it back with `None`
/// (super admins only).
pub async fn assign(&self, user: &AuthUser, name: &str, team_id: Option<i64>) -> AppResult<()> {
user.require_super_admin()?;
let app = find_app(&self.db, name).await?;
if let Some(team_id) = team_id {
find_team(&self.db, team_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("team {team_id}")))?;
}
self.db
.query("UPDATE type::record('app', $id) SET owner_team_id = $team_id")
.bind(("id", app.id))
.bind(("team_id", team_id))
.await?
.check()?;
Ok(())
}
/// The caller's access to app `name`: their role and modules in its owner
/// team (super admins always pass).
pub async fn access(&self, user: &AuthUser, name: &str) -> AppResult<(OwnedApp, Access)> {
let (app, access) = require_app_access(&self.db, user, name, None).await?;
let team = match app.owner_team_id {
Some(id) => find_team(&self.db, id)
.await?
.as_ref()
.map(TeamSummary::from),
None => None,
};
Ok((OwnedApp { app, team }, access))
}
}
/// App `name` and the caller's access to it, failing with `Forbidden` unless
/// they belong to its owner team (or are a super admin) and, if `module` is
/// given, may use that module there.
pub async fn require_app_access(
db: &impl Executor,
user: &AuthUser,
name: &str,
module: Option<&str>,
) -> AppResult<(App, Access)> {
let app = find_app(db, name).await?;
let access = match app.owner_team_id {
Some(team_id) => require_access(db, user, team_id, module).await?,
None if user.is_super_admin() => Access {
role: crate::base::auth::SUPER_ADMIN_ROLE.to_owned(),
modules: crate::modules::team::MODULES
.iter()
.map(|m| (*m).to_owned())
.collect(),
},
None => return Err(AppError::Forbidden),
};
Ok((app, access))
}
/// Registers the apps the runtime serves (their folder names, with the slug
/// of their owner team from config/apps.toml). An app without an owner gets
/// that team once it exists; an owner set before (or by `app_assign`) stays.
/// Apps no longer served are left as they are.
pub async fn sync(db: &Db, apps: &[(&str, Option<&str>)]) -> AppResult<()> {
for (name, team) in apps {
if find_app(db, name).await.is_ok() {
if team.is_some() {
db.query(
"UPDATE app SET owner_team_id =
(SELECT VALUE record::id(id) FROM ONLY team WHERE slug = $team LIMIT 1)
WHERE name = $name AND owner_team_id = NONE",
)
.bind(("name", (*name).to_owned()))
.bind(("team", team.map(str::to_owned)))
.await?
.check()?;
}
continue;
}
let created = db
.query(
"CREATE type::record('app', sequence::nextval('app_id')) SET name = $name,
owner_team_id = (SELECT VALUE record::id(id) FROM ONLY team WHERE slug = $team LIMIT 1)",
)
.bind(("name", (*name).to_owned()))
.bind(("team", team.map(str::to_owned)))
.await?
.check();
match created {
// Another instance starting at the same time registered it.
Err(err) if slave_framework::database::is_already_exists(&err) => {}
result => {
result?;
}
}
}
Ok(())
}
async fn find_app(db: &impl Executor, name: &str) -> AppResult<App> {
let app: Option<App> = db
.query(format!(
"SELECT {} FROM app WHERE name = $name",
App::FIELDS
))
.bind(("name", name.to_owned()))
.await?
.take(0)?;
app.ok_or_else(|| AppError::NotFound(format!("app `{name}`")))
}
async fn find_team(db: &impl Executor, id: i64) -> AppResult<Option<Team>> {
let team: Option<Team> = db
.query(format!(
"SELECT {} FROM type::record('team', $id)",
Team::FIELDS
))
.bind(("id", id))
.await?
.take(0)?;
Ok(team)
}