web permissions2
This commit is contained in:
@@ -138,8 +138,6 @@ pub(crate) async fn load_admin_page(
|
||||
can_manage_tables: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE),
|
||||
can_manage_scripts: crate::authz::can_manage(&authorization, crate::authz::STRUCT_SCRIPT),
|
||||
can_manage_validations: crate::authz::can_manage(&authorization, crate::authz::STRUCT_VALIDATION),
|
||||
can_manage_permissions: crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE)
|
||||
|| crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER),
|
||||
can_export: authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,6 @@ pub(crate) struct AdminPageState {
|
||||
pub can_manage_tables: bool,
|
||||
pub can_manage_scripts: bool,
|
||||
pub can_manage_validations: bool,
|
||||
pub can_manage_permissions: bool,
|
||||
pub can_export: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ mod tests {
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "admin",
|
||||
@@ -64,7 +65,6 @@ mod tests {
|
||||
can_manage_tables: true,
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_manage_permissions: true,
|
||||
can_export: true,
|
||||
};
|
||||
let html = render_page(&page);
|
||||
@@ -75,7 +75,6 @@ mod tests {
|
||||
"/admin/validation/new",
|
||||
"/admin/validation/sets/new",
|
||||
"/admin/export",
|
||||
"/admin/permissions",
|
||||
"/logout",
|
||||
] {
|
||||
assert!(html.contains(route), "missing admin action route {route}");
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod permissions;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{
|
||||
GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest,
|
||||
ListRolesRequest, ListUsersRequest,
|
||||
},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{LoadError, PermissionPageState, Selection};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
selection: Selection,
|
||||
) -> Result<PermissionPageState, LoadError> {
|
||||
let updated = selection.updated;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(
|
||||
authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner();
|
||||
let can_manage_roles = crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE);
|
||||
let can_manage_users = crate::authz::can_manage(&authorization, crate::authz::STRUCT_USER);
|
||||
if !can_manage_roles && !can_manage_users {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let roles = if can_manage_roles || can_manage_users {
|
||||
auth.list_roles(
|
||||
authenticated_request(headers, ListRolesRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner()
|
||||
.roles
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let users = if can_manage_users {
|
||||
auth.list_users(
|
||||
authenticated_request(headers, ListUsersRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner()
|
||||
.users
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let editable_roles = roles
|
||||
.iter()
|
||||
.filter(|role| role.kind == "data")
|
||||
.map(|role| role.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let selected_role = if selection.role.is_empty() {
|
||||
editable_roles.first().copied().unwrap_or_default().to_string()
|
||||
} else if editable_roles.contains(&selection.role.as_str()) {
|
||||
selection.role
|
||||
} else {
|
||||
return Err(LoadError::InvalidSelection(format!(
|
||||
"Role '{}' is not an editable data role.",
|
||||
selection.role
|
||||
)));
|
||||
};
|
||||
|
||||
let (direct_permissions, effective_permissions, grantable_objects) =
|
||||
if can_manage_roles && !selected_role.is_empty() {
|
||||
let permissions = auth
|
||||
.list_role_permissions(
|
||||
authenticated_request(
|
||||
headers,
|
||||
ListRolePermissionsRequest {
|
||||
role: selected_role.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner();
|
||||
let objects = auth
|
||||
.list_grantable_objects(
|
||||
authenticated_request(
|
||||
headers,
|
||||
ListGrantableObjectsRequest {
|
||||
target_role: selected_role.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner()
|
||||
.objects;
|
||||
(
|
||||
permissions.permissions,
|
||||
permissions.effective_permissions,
|
||||
objects,
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
Ok(PermissionPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
roles,
|
||||
users,
|
||||
selected_role,
|
||||
direct_permissions,
|
||||
effective_permissions,
|
||||
grantable_objects,
|
||||
can_manage_roles,
|
||||
can_manage_users,
|
||||
updated,
|
||||
})
|
||||
}
|
||||
|
||||
fn status_error(error: tonic::Status) -> LoadError {
|
||||
match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{
|
||||
AddRoleRequest, AssignUserRoleRequest, GrantPermissionRequest, RemoveRoleRequest,
|
||||
ResetUserPasswordRequest, RevokePermissionRequest,
|
||||
},
|
||||
services::{authenticated_request, reject_cross_site},
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader,
|
||||
state::{
|
||||
AddRoleForm, AssignRoleForm, LoadError, PermissionForm, ResetPasswordForm, RoleForm,
|
||||
Selection,
|
||||
},
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
match loader::load_page(state, &headers, selection).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => load_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn add_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AddRoleForm>,
|
||||
) -> Response {
|
||||
let role = form.name.trim().to_string();
|
||||
let destination = format!("/admin/permissions?role={role}&updated=true");
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.add_role(authenticated_request(&request_headers, AddRoleRequest {
|
||||
name: role,
|
||||
parent: form.parent.trim().to_string(),
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<RoleForm>,
|
||||
) -> Response {
|
||||
let destination = "/admin/permissions?updated=true";
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.remove_role(authenticated_request(&request_headers, RemoveRoleRequest {
|
||||
name: form.role,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn grant(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PermissionForm>,
|
||||
) -> Response {
|
||||
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.grant_permission(authenticated_request(&request_headers, GrantPermissionRequest {
|
||||
role: form.role,
|
||||
object: form.object,
|
||||
action: form.action,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PermissionForm>,
|
||||
) -> Response {
|
||||
let destination = format!("/admin/permissions?role={}&updated=true", form.role);
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, &destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.revoke_permission(authenticated_request(&request_headers, RevokePermissionRequest {
|
||||
role: form.role,
|
||||
object: form.object,
|
||||
action: form.action,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn assign_user_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AssignRoleForm>,
|
||||
) -> Response {
|
||||
let destination = "/admin/permissions?updated=true";
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.assign_user_role(authenticated_request(&request_headers, AssignUserRoleRequest {
|
||||
username: form.username,
|
||||
role: form.role,
|
||||
}).map_err(|_| "Missing session".to_string())?)
|
||||
.await.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_user_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ResetPasswordForm>,
|
||||
) -> Response {
|
||||
let destination = "/admin/permissions?updated=true";
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, destination, async move {
|
||||
let mut auth = state.auth;
|
||||
auth.reset_user_password(authenticated_request(
|
||||
&request_headers,
|
||||
ResetUserPasswordRequest {
|
||||
username: form.username,
|
||||
new_password: form.new_password,
|
||||
new_password_confirmation: form.new_password_confirmation,
|
||||
},
|
||||
).map_err(|_| "Missing session".to_string())?)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
async fn mutate<F>(headers: &HeaderMap, destination: &str, operation: F) -> Response
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), String>>,
|
||||
{
|
||||
if let Some(rejection) = reject_cross_site(headers) {
|
||||
return rejection;
|
||||
}
|
||||
match operation.await {
|
||||
Ok(()) => success_redirect(destination),
|
||||
Err(message) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_mutation_error(&message)),
|
||||
).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn success_redirect(destination: &str) -> Response {
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
let Ok(destination) = HeaderValue::try_from(destination) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
response.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
destination.clone(),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"hx-redirect",
|
||||
destination,
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn load_error(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("You do not have role or user management permission."))).into_response(),
|
||||
LoadError::InvalidSelection(message) => (StatusCode::BAD_REQUEST, Html(ui::render_error(&message))).into_response(),
|
||||
LoadError::Backend(message) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use axum::{Router, routing::{get, post}};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/permissions", get(logic::page))
|
||||
.route("/admin/permissions/roles", post(logic::add_role))
|
||||
.route("/admin/permissions/roles/remove", post(logic::remove_role))
|
||||
.route("/admin/permissions/grant", post(logic::grant))
|
||||
.route("/admin/permissions/revoke", post(logic::revoke))
|
||||
.route("/admin/permissions/users/role", post(logic::assign_user_role))
|
||||
.route("/admin/permissions/users/password", post(logic::reset_user_password))
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use crate::auth::{GrantableObject, Permission, Role, UserSummary};
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct AddRoleForm {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub parent: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct RoleForm {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct PermissionForm {
|
||||
pub role: String,
|
||||
pub object: String,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct AssignRoleForm {
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct ResetPasswordForm {
|
||||
pub username: String,
|
||||
pub new_password: String,
|
||||
pub new_password_confirmation: String,
|
||||
}
|
||||
|
||||
pub(crate) struct PermissionPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub roles: Vec<Role>,
|
||||
pub users: Vec<UserSummary>,
|
||||
pub selected_role: String,
|
||||
pub direct_permissions: Vec<Permission>,
|
||||
pub effective_permissions: Vec<Permission>,
|
||||
pub grantable_objects: Vec<GrantableObject>,
|
||||
pub can_manage_roles: bool,
|
||||
pub can_manage_users: bool,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl PermissionPageState {
|
||||
pub(crate) fn editable_roles(&self) -> Vec<&Role> {
|
||||
self.roles.iter().filter(|role| role.kind == "data").collect()
|
||||
}
|
||||
|
||||
pub(crate) fn assignable_roles(&self) -> Vec<&Role> {
|
||||
self.roles
|
||||
.iter()
|
||||
.filter(|role| {
|
||||
role.kind == "data" || (self.nav.role == "superadmin" && role.name == "admin")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn selected(&self, role: &str) -> bool {
|
||||
self.selected_role == role
|
||||
}
|
||||
|
||||
pub(crate) fn direct(&self, object: &str, action: &str) -> bool {
|
||||
crate::authz::is_direct_permission(&self.direct_permissions, object, action)
|
||||
}
|
||||
|
||||
pub(crate) fn effective(&self, object: &str, action: &str) -> bool {
|
||||
crate::authz::permissions_permit(&self.effective_permissions, object, action)
|
||||
}
|
||||
|
||||
pub(crate) fn selected_role_is_removable(&self) -> bool {
|
||||
self.roles
|
||||
.iter()
|
||||
.find(|role| role.name == self.selected_role)
|
||||
.is_some_and(|role| !role.built_in && role.kind == "data")
|
||||
}
|
||||
|
||||
pub(crate) fn can_reset_password(&self, user: &UserSummary) -> bool {
|
||||
match self.nav.role.as_str() {
|
||||
"superadmin" => user.role != "superadmin",
|
||||
"admin" => !matches!(user.role.as_str(), "superadmin" | "admin"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
InvalidSelection(String),
|
||||
Backend(String),
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Alert, ErrorPage, Nav, render};
|
||||
|
||||
use super::state::PermissionPageState;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/permissions/permissions.html")]
|
||||
struct PermissionPage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a PermissionPageState,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &PermissionPageState) -> String {
|
||||
render(&PermissionPage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
render(&ErrorPage {
|
||||
nav: Nav::default(),
|
||||
heading: "Permissions unavailable",
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_mutation_error(message: &str) -> String {
|
||||
render(&Alert::error("Could not update permissions", message))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
auth::{GrantableObject, Permission, Role, UserSummary},
|
||||
pages::admin::permissions::state::PermissionPageState,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn permission_page_distinguishes_direct_and_inherited_grants() {
|
||||
let page = PermissionPageState {
|
||||
nav: Nav {
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "admin",
|
||||
},
|
||||
roles: vec![Role {
|
||||
name: "sales".to_string(),
|
||||
kind: "data".to_string(),
|
||||
built_in: false,
|
||||
parent: "guest".to_string(),
|
||||
}],
|
||||
users: vec![UserSummary {
|
||||
id: "1".to_string(),
|
||||
username: "alice".to_string(),
|
||||
email: String::new(),
|
||||
role: "sales".to_string(),
|
||||
}],
|
||||
selected_role: "sales".to_string(),
|
||||
direct_permissions: vec![Permission {
|
||||
object: "data:billing/invoice".to_string(),
|
||||
action: "insert".to_string(),
|
||||
}],
|
||||
effective_permissions: vec![
|
||||
Permission {
|
||||
object: "data:billing/invoice".to_string(),
|
||||
action: "insert".to_string(),
|
||||
},
|
||||
Permission {
|
||||
object: "data:billing/invoice".to_string(),
|
||||
action: "read".to_string(),
|
||||
},
|
||||
],
|
||||
grantable_objects: vec![GrantableObject {
|
||||
object: "data:billing/invoice".to_string(),
|
||||
profile: "billing".to_string(),
|
||||
table: "invoice".to_string(),
|
||||
kind: "table".to_string(),
|
||||
allowed_actions: vec!["read".to_string(), "insert".to_string()],
|
||||
}],
|
||||
can_manage_roles: true,
|
||||
can_manage_users: true,
|
||||
updated: false,
|
||||
};
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("read · inherited"));
|
||||
assert!(html.contains("insert ✓"));
|
||||
assert!(html.contains("/admin/permissions/revoke"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user