Skip to content

Commit

Permalink
feat(api): add endpoints to get apps
Browse files Browse the repository at this point in the history
  • Loading branch information
leroyguillaume committed Jul 14, 2024
1 parent 7ce7298 commit 721ee13
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 24 deletions.
10 changes: 10 additions & 0 deletions charts/simpaas/crds/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ spec:
- deleteApp
- required:
- inviteUsers
- required:
- readApp
- required:
- updateApp
properties:
Expand All @@ -48,6 +50,14 @@ spec:
inviteUsers:
description: Allow role to invite users.
type: object
readApp:
description: Allow role to read app.
properties:
name:
default: .*
description: Pattern that matches app name.
type: string
type: object
updateApp:
description: Allow roel to update app.
properties:
Expand Down
1 change: 1 addition & 0 deletions charts/simpaas/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ api:
- createApp: {}
- deleteApp: {}
- inviteUsers: {}
- readApp: {}
- updateApp: {}

admin:
Expand Down
113 changes: 96 additions & 17 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use std::{collections::BTreeSet, net::SocketAddr, sync::Arc};
use std::{borrow::Cow, collections::BTreeSet, net::SocketAddr, sync::Arc};

use aide::{
axum::ApiRouter,
openapi::{Info, OpenApi},
OperationOutput,
};
use axum::{
extract::{MatchedPath, Path, State},
extract::{MatchedPath, Path, Query, State},
http::{header, HeaderMap, Request, StatusCode},
response::{IntoResponse, Response},
Extension, Json, Router,
Expand All @@ -21,12 +21,12 @@ use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::{debug, error, info, info_span, instrument, Instrument};
use uuid::Uuid;
use validator::Validate;
use validator::{Validate, ValidationError, ValidationErrors};

use crate::{
domain::{Action, App, AppSpec, Chart, Invitation, InvitationSpec, Service, User, UserSpec},
jwt::JwtEncoder,
kube::{KubeClient, FINALIZER},
kube::{AppFilter, KubeClient, FINALIZER},
mail::MailSender,
pwd::PasswordEncoder,
SignalListener, CARGO_PKG_NAME,
Expand Down Expand Up @@ -103,7 +103,7 @@ enum Error {
Validation(
#[from]
#[source]
validator::ValidationErrors,
ValidationErrors,
),
#[error("wrong credentials")]
WrongCredentials,
Expand Down Expand Up @@ -154,7 +154,15 @@ impl OperationOutput for Error {
}
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize, Validate)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
struct AppFilterQuery {
/// Regex to match app name.
#[serde(default = "default_filter")]
name: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
struct CreateAppRequest {
/// Chart to use to install app.
Expand All @@ -176,7 +184,7 @@ struct CreateAppRequest {
values: Map<String, Value>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize, Validate)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
struct SendInvitationRequest {
/// User roles.
Expand All @@ -201,7 +209,7 @@ struct UpdateAppRequest {
values: Map<String, Value>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize, Validate)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
struct UserPasswordCredentialsRequest {
/// Password.
Expand All @@ -212,14 +220,14 @@ struct UserPasswordCredentialsRequest {
user: String,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)]
#[serde(rename_all = "camelCase")]
struct JwtResponse {
/// JWT.
jwt: String,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)]
#[serde(rename_all = "camelCase")]
struct PreconditionFailedResponse {
/// Field that causes the failure.
Expand All @@ -228,13 +236,13 @@ struct PreconditionFailedResponse {
reason: PreconditionFailedReason,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)]
#[serde(rename_all = "camelCase")]
enum PreconditionFailedReason {
NotFound,
}

#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, JsonSchema, Serialize)]
#[serde(rename_all = "camelCase")]
struct ResourceAlreadyExistsItem {
/// Field in conflict.
Expand Down Expand Up @@ -277,7 +285,9 @@ fn create_router<
};
ApiRouter::new()
.api_route("/_health", aide::axum::routing::get(health))
.api_route("/app", aide::axum::routing::get(list_apps))
.api_route("/app", aide::axum::routing::post(create_app))
.api_route("/app/:name", aide::axum::routing::get(get_app))
.api_route("/app/:name", aide::axum::routing::put(update_app))
.api_route("/app/:name", aide::axum::routing::delete(delete_app))
.api_route(
Expand All @@ -296,7 +306,7 @@ fn create_router<
.layer(Extension(api))
}

#[instrument(skip(ctx, req), fields(user.name = req.user))]
#[instrument(skip(ctx, req), fields(auth.name = req.user))]
async fn authenticate_with_password<
J: JwtEncoder,
K: KubeClient,
Expand Down Expand Up @@ -352,7 +362,7 @@ async fn create_app<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEnco
services: req.services,
values: req.values,
};
let span = info_span!("create_app", app.name = req.name,);
let span = info_span!("create_app", app.name = req.name, auth.name = spec.owner);
async {
debug!("creating app");
let app = App {
Expand Down Expand Up @@ -385,7 +395,7 @@ async fn delete_app<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEnco
if app.spec.owner != username {
check_permission(&user, Action::DeleteApp(&name), &ctx.kube).await?;
}
let span = info_span!("delete_app", app.name = name,);
let span = info_span!("delete_app", app.name = name, auth.name = username);
async {
debug!("deleting app");
ctx.kube.delete_app(&name).await?;
Expand All @@ -401,6 +411,26 @@ async fn doc(Extension(api): Extension<OpenApi>) -> Json<OpenApi> {
Json(api)
}

async fn get_app<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEncoder>(
headers: HeaderMap,
State(ctx): State<Arc<ApiContext<J, K, M, P>>>,
Path(name): Path<String>,
) -> Result<(StatusCode, Json<AppSpec>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let app = ctx
.kube
.get_app(&name)
.await?
.ok_or(Error::ResourceNotFound)?;
if app.spec.owner != username {
check_permission(&user, Action::ReadApp(&name), &ctx.kube).await?;
}
let span = info_span!("get_app", app.name = name, auth.name = username);
async { Ok((StatusCode::OK, Json(app.spec))) }
.instrument(span)
.await
}

#[instrument]
async fn health<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEncoder>(
_: State<Arc<ApiContext<J, K, M, P>>>,
Expand Down Expand Up @@ -455,6 +485,28 @@ async fn join<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEncoder>(
.await
}

async fn list_apps<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEncoder>(
headers: HeaderMap,
State(ctx): State<Arc<ApiContext<J, K, M, P>>>,
Query(filter): Query<AppFilterQuery>,
) -> Result<(StatusCode, Json<Vec<AppSpec>>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let filter = filter.try_into()?;
let span = info_span!("list_apps", auth.name = username);
async {
let apps = ctx
.kube
.list_apps(&filter, &username, &user)
.await?
.into_iter()
.map(|app| app.spec)
.collect();
Ok((StatusCode::OK, Json(apps)))
}
.instrument(span)
.await
}

async fn send_invitation<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEncoder>(
headers: HeaderMap,
State(ctx): State<Arc<ApiContext<J, K, M, P>>>,
Expand All @@ -475,7 +527,7 @@ async fn send_invitation<J: JwtEncoder, K: KubeClient, M: MailSender, P: Passwor
};
let span = info_span!(
"send_invitation",
auth = from,
auth.name = from,
invit.to = spec.to,
invit.token = token,
);
Expand Down Expand Up @@ -520,7 +572,7 @@ async fn update_app<J: JwtEncoder, K: KubeClient, M: MailSender, P: PasswordEnco
reason: PreconditionFailedReason::NotFound,
}));
}
let span = info_span!("update_app", app.name = name,);
let span = info_span!("update_app", app.name = name, auth.name = username);
async {
debug!("updating app");
let app = App {
Expand Down Expand Up @@ -596,3 +648,30 @@ async fn ensure_domains_are_free<K: KubeClient>(name: &str, svcs: &[Service], ku
Err(Error::ResourceAlreadyExists(items))
}
}

impl TryFrom<AppFilterQuery> for AppFilter {
type Error = Error;

fn try_from(query: AppFilterQuery) -> Result<Self> {
let mut errs = ValidationErrors::new();
let name = match Regex::new(&query.name) {
Ok(name) => name,
Err(err) => {
errs.add(
"name",
ValidationError::new("regex").with_message(Cow::Owned(err.to_string())),
);
Regex::new(r".*").unwrap()
}
};
if errs.is_empty() {
Ok(Self { name })
} else {
Err(Error::Validation(errs))
}
}
}

fn default_filter() -> String {
r".*".into()
}
24 changes: 20 additions & 4 deletions src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ pub enum Action<'a> {
CreateApp,
DeleteApp(&'a str),
InviteUsers,
ReadApp(&'a str),
UpdateApp(&'a str),
}

impl Display for Action<'_> {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Self::CreateApp => write!(f, "create_app"),
Self::DeleteApp(_) => write!(f, "delete_app"),
Self::DeleteApp(pattern) => write!(f, "delete_app(`{pattern}`)"),
Self::InviteUsers => write!(f, "invite_users"),
Self::UpdateApp(_) => write!(f, "update_app"),
Self::ReadApp(pattern) => write!(f, "read_app(`{pattern}`)"),
Self::UpdateApp(pattern) => write!(f, "update_app(`{pattern}`)"),
}
}
}
Expand Down Expand Up @@ -122,6 +124,12 @@ pub enum Permission {
},
/// Allow role to invite users.
InviteUsers {},
/// Allow role to read app.
ReadApp {
/// Pattern that matches app name.
#[serde(default = "default_perm_pattern")]
name: String,
},
/// Allow roel to update app.
UpdateApp {
/// Pattern that matches app name.
Expand All @@ -142,6 +150,13 @@ impl Permission {
}
}
Self::InviteUsers {} => Ok(matches!(action, Action::InviteUsers)),
Self::ReadApp { name: pattern } => {
if let Action::ReadApp(name) = action {
Self::name_matches(name, pattern)
} else {
Ok(false)
}
}
Self::UpdateApp { name: pattern } => {
if let Action::UpdateApp(name) = action {
Self::name_matches(name, pattern)
Expand All @@ -162,9 +177,10 @@ impl Display for Permission {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Self::CreateApp {} => write!(f, "create_app"),
Self::DeleteApp { .. } => write!(f, "delete_app"),
Self::DeleteApp { name } => write!(f, "delete_app(`{name}`)"),
Self::InviteUsers {} => write!(f, "invite_users"),
Self::UpdateApp { .. } => write!(f, "update_app"),
Self::ReadApp { name } => write!(f, "read_app(`{name}`)"),
Self::UpdateApp { name } => write!(f, "update_app(`{name}`)"),
}
}
}
Expand Down
Loading

0 comments on commit 721ee13

Please sign in to comment.