web permissions2
This commit is contained in:
@@ -39,6 +39,21 @@ pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
.any(|area| can_manage(snapshot, area))
|
||||
}
|
||||
|
||||
/// The server's authority ranking (`server/src/auth/rbac/roles.rs`), mirrored
|
||||
/// so the permission pages only offer what the server will accept: superadmin
|
||||
/// outranks admin, admin outranks every data role, and no role outranks itself.
|
||||
pub(crate) fn outranks(actor: &str, target: &str) -> bool {
|
||||
rank(actor) > rank(target)
|
||||
}
|
||||
|
||||
fn rank(role: &str) -> u8 {
|
||||
match role {
|
||||
"superadmin" => 3,
|
||||
"admin" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn table_object(profile: &str, table: &str) -> String {
|
||||
format!("data:{profile}/{table}")
|
||||
}
|
||||
@@ -90,6 +105,15 @@ mod tests {
|
||||
assert!(!can_manage(&authorization, STRUCT_ROLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_administers_a_peer_or_a_superior() {
|
||||
assert!(outranks("superadmin", "admin"));
|
||||
assert!(outranks("admin", "sales"));
|
||||
assert!(!outranks("admin", "admin"));
|
||||
assert!(!outranks("admin", "superadmin"));
|
||||
assert!(!outranks("sales", "clerk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_wildcards_match_the_server_object_shapes() {
|
||||
let global = snapshot(&[("data:*", "read")]);
|
||||
|
||||
@@ -139,7 +139,7 @@ fn router(state: AppState) -> Router {
|
||||
.merge(pages::login::router())
|
||||
.merge(pages::register::router())
|
||||
.merge(pages::admin::admin::router())
|
||||
.merge(pages::admin::permissions::router())
|
||||
.merge(pages::permissions::router())
|
||||
.merge(pages::admin::table_definition::router())
|
||||
.merge(pages::add_table::router())
|
||||
.merge(pages::add_logic::router())
|
||||
@@ -317,6 +317,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissions is a nav section of its own, so its three pages are mounted
|
||||
/// at the top level rather than under /admin — and each of them, like every
|
||||
/// other page behind a session, sends an anonymous visitor to the login
|
||||
/// page instead of calling the backend.
|
||||
#[tokio::test]
|
||||
async fn the_permission_sections_are_mounted_and_need_a_session() {
|
||||
for path in [
|
||||
"/permissions",
|
||||
"/permissions/roles",
|
||||
"/permissions/users",
|
||||
"/permissions/grants",
|
||||
] {
|
||||
let (status, _) = get(path).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
axum::http::StatusCode::SEE_OTHER,
|
||||
"{path} did not send an anonymous visitor to the login page"
|
||||
);
|
||||
}
|
||||
|
||||
// The forms answer a lost session the way every form does: with the
|
||||
// failure rendered into the page, not a 404 from an unmounted route.
|
||||
for (path, body) in [
|
||||
("/permissions/roles/create", "name=sales&access=none"),
|
||||
("/permissions/roles/remove", "role=sales"),
|
||||
("/permissions/users/role", "username=alice&role=sales"),
|
||||
(
|
||||
"/permissions/users/password",
|
||||
"username=alice&new_password=a&new_password_confirmation=a",
|
||||
),
|
||||
(
|
||||
"/permissions/grants/apply",
|
||||
"role=sales&mode=grant&pair=data%3A*%7Cread",
|
||||
),
|
||||
] {
|
||||
let response = test_router()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"{path} is not mounted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stylesheet_is_served_once_for_every_page() {
|
||||
let (status, body) = get("/static/app.css").await;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
@@ -107,10 +107,10 @@ pub(crate) async fn create_table(
|
||||
let Ok(location) = HeaderValue::try_from(location) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::LOCATION, location.clone());
|
||||
// `hx-redirect` alone, with no body and no 3xx: the form is posted
|
||||
// over XHR, and the browser would follow a `Location` itself,
|
||||
// leaving htmx to swap the whole redirected page into `#builder`.
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert("hx-redirect", location);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ pub(crate) mod admin;
|
||||
pub(crate) mod analytics;
|
||||
pub(crate) mod import_export;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod permissions;
|
||||
pub(crate) mod register;
|
||||
|
||||
139
web/src/pages/permissions/common/loader.rs
Normal file
139
web/src/pages/permissions/common/loader.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! The loads all three permission sections share: who the caller is, which
|
||||
//! roles exist, and who holds them.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthorizationSnapshot, GetAuthorizationRequest, ListRolesRequest, ListUsersRequest, Role,
|
||||
UserSummary, auth_service_client::AuthServiceClient,
|
||||
},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::LoadError;
|
||||
|
||||
/// What the signed-in role may do in this section of the site.
|
||||
pub(crate) struct Access {
|
||||
pub authorization: AuthorizationSnapshot,
|
||||
pub can_manage_roles: bool,
|
||||
pub can_manage_users: bool,
|
||||
}
|
||||
|
||||
/// Loads the caller's authorization and refuses anyone who manages neither
|
||||
/// roles nor users, which is the gate on all three sections.
|
||||
pub(crate) async fn access(
|
||||
auth: &mut AuthServiceClient<Channel>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Access, LoadError> {
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(Access {
|
||||
authorization,
|
||||
can_manage_roles,
|
||||
can_manage_users,
|
||||
})
|
||||
}
|
||||
|
||||
/// The navbar every permissions page carries. `"permissions"` is a nav entry of
|
||||
/// its own, so the section is reachable without going through the admin panel.
|
||||
pub(crate) fn nav(headers: &HeaderMap, access: &Access) -> crate::ui::Nav {
|
||||
crate::ui::Nav::new(headers, "permissions").with_authorization(&access.authorization)
|
||||
}
|
||||
|
||||
pub(crate) async fn roles(
|
||||
auth: &mut AuthServiceClient<Channel>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Vec<Role>, LoadError> {
|
||||
Ok(auth
|
||||
.list_roles(
|
||||
authenticated_request(headers, ListRolesRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner()
|
||||
.roles)
|
||||
}
|
||||
|
||||
pub(crate) async fn users(
|
||||
auth: &mut AuthServiceClient<Channel>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Vec<UserSummary>, LoadError> {
|
||||
Ok(auth
|
||||
.list_users(
|
||||
authenticated_request(headers, ListUsersRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(status_error)?
|
||||
.into_inner()
|
||||
.users)
|
||||
}
|
||||
|
||||
/// The roles whose permissions and assignments the caller may edit: everything
|
||||
/// they outrank. For an admin that is every data role; for a superadmin it also
|
||||
/// includes `admin`, which is how read access reaches a structural role. The
|
||||
/// server enforces the same ranking, so this only keeps roles the caller cannot
|
||||
/// use out of the pickers.
|
||||
pub(crate) fn manageable(roles: &[Role], actor_role: &str) -> Vec<Role> {
|
||||
roles
|
||||
.iter()
|
||||
.filter(|role| crate::authz::outranks(actor_role, &role.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) 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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn role(name: &str, kind: &str) -> Role {
|
||||
Role {
|
||||
name: name.to_string(),
|
||||
kind: kind.to_string(),
|
||||
built_in: false,
|
||||
parent: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_superadmin_sees_admin_among_the_editable_roles() {
|
||||
let roles = [
|
||||
role("superadmin", "structural"),
|
||||
role("admin", "structural"),
|
||||
role("sales", "data"),
|
||||
];
|
||||
|
||||
let names = |actor| {
|
||||
manageable(&roles, actor)
|
||||
.into_iter()
|
||||
.map(|role| role.name)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(names("superadmin"), vec!["admin", "sales"]);
|
||||
assert_eq!(names("admin"), vec!["sales"]);
|
||||
}
|
||||
}
|
||||
86
web/src/pages/permissions/common/logic.rs
Normal file
86
web/src/pages/permissions/common/logic.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! The response shapes every permissions form and page share.
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::services::reject_cross_site;
|
||||
|
||||
use super::{state::LoadError, ui};
|
||||
|
||||
/// Runs one form's worth of work and answers the way every form on the site
|
||||
/// does: a redirect on success, the failure rendered into the form's target
|
||||
/// otherwise.
|
||||
pub(crate) 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the browser to the reloaded page, the way `POST /login` does: an empty
|
||||
/// 200 carrying `hx-redirect` alone.
|
||||
///
|
||||
/// Never a 303 with a `Location`. These forms are posted by htmx over XHR, and
|
||||
/// the browser follows a 303 itself, transparently — htmx then sees a 200
|
||||
/// holding the whole redirected page and swaps *that* into the form's target,
|
||||
/// putting a second copy of the page inside the status line.
|
||||
fn success_redirect(destination: &str) -> Response {
|
||||
let Ok(destination) = HeaderValue::try_from(destination) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert("hx-redirect", destination);
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) fn load_error(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_error(
|
||||
"This account manages neither roles nor users.",
|
||||
)),
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The browser follows a 3xx on an XHR by itself, so a redirect status here
|
||||
/// would hand htmx the whole reloaded page to swap into the small status
|
||||
/// line the form points at. The answer carries the destination in a header
|
||||
/// and nothing in the body.
|
||||
#[test]
|
||||
fn a_successful_form_redirects_by_header_and_returns_no_markup_to_swap() {
|
||||
let response = success_redirect("/permissions/users?updated=true");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers().get("hx-redirect").unwrap(),
|
||||
"/permissions/users?updated=true"
|
||||
);
|
||||
assert!(response.headers().get(axum::http::header::LOCATION).is_none());
|
||||
}
|
||||
}
|
||||
7
web/src/pages/permissions/common/mod.rs
Normal file
7
web/src/pages/permissions/common/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! What the three permission sections have in common: the gate on the whole
|
||||
//! area, the role and user lists, and the response every form answers with.
|
||||
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod logic;
|
||||
pub(crate) mod state;
|
||||
pub(crate) mod ui;
|
||||
104
web/src/pages/permissions/common/state.rs
Normal file
104
web/src/pages/permissions/common/state.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
/// Why a permissions page could not be shown.
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
InvalidSelection(String),
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// The section switcher every permissions page carries.
|
||||
///
|
||||
/// The three sections answer three different questions — what roles exist, who
|
||||
/// holds them, and what a role may do — and each is reachable only if the
|
||||
/// signed-in role manages the area behind it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Tabs {
|
||||
/// `"roles"`, `"users"`, or `"grants"`.
|
||||
pub active: &'static str,
|
||||
pub can_manage_roles: bool,
|
||||
pub can_manage_users: bool,
|
||||
}
|
||||
|
||||
impl Tabs {
|
||||
pub(crate) fn new(active: &'static str, access: &super::loader::Access) -> Self {
|
||||
Self {
|
||||
active,
|
||||
can_manage_roles: access.can_manage_roles,
|
||||
can_manage_users: access.can_manage_users,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is(&self, section: &str) -> bool {
|
||||
self.active == section
|
||||
}
|
||||
}
|
||||
|
||||
/// One `object|action` pair as it travels through a form.
|
||||
///
|
||||
/// Every bulk shortcut posts a list of these, so a button that grants read on
|
||||
/// three objects and a button that grants one action on one table are the same
|
||||
/// request with a different list, and the page never has to encode "all" as a
|
||||
/// rule the server would have to re-derive.
|
||||
pub(crate) fn pair(object: &str, action: &str) -> String {
|
||||
format!("{object}|{action}")
|
||||
}
|
||||
|
||||
/// Splits the pairs a form posted, rejecting anything not shaped like one.
|
||||
pub(crate) fn parse_pairs(raw: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||
if raw.is_empty() {
|
||||
return Err("Nothing to change: the form carried no permissions.".to_string());
|
||||
}
|
||||
raw.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.split_once('|')
|
||||
.filter(|(object, action)| !object.is_empty() && !action.is_empty())
|
||||
.map(|(object, action)| (object.to_string(), action.to_string()))
|
||||
.ok_or_else(|| format!("'{value}' is not an object|action pair."))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where a mutation should send the browser afterwards.
|
||||
///
|
||||
/// Grants are edited from the permissions page and from the table-definition
|
||||
/// workspace, so the form says where it came from. Only a path on this site is
|
||||
/// accepted, which keeps the field from being turned into an open redirect.
|
||||
pub(crate) fn return_path(requested: &str, fallback: String) -> String {
|
||||
let requested = requested.trim();
|
||||
if requested.starts_with('/') && !requested.starts_with("//") {
|
||||
requested.to_string()
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pairs_survive_a_round_trip_and_malformed_ones_are_refused() {
|
||||
let encoded = vec![pair("data:acme/*", "read"), pair("journal:*", "insert")];
|
||||
assert_eq!(
|
||||
parse_pairs(&encoded).unwrap(),
|
||||
vec![
|
||||
("data:acme/*".to_string(), "read".to_string()),
|
||||
("journal:*".to_string(), "insert".to_string()),
|
||||
]
|
||||
);
|
||||
|
||||
assert!(parse_pairs(&[]).is_err());
|
||||
assert!(parse_pairs(&["data:acme/*".to_string()]).is_err());
|
||||
assert!(parse_pairs(&["|read".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_return_path_may_only_point_back_at_this_site() {
|
||||
let fallback = || "/permissions/grants".to_string();
|
||||
assert_eq!(return_path("/admin/table-definition", fallback()), "/admin/table-definition");
|
||||
assert_eq!(return_path("https://elsewhere.example", fallback()), "/permissions/grants");
|
||||
assert_eq!(return_path("//elsewhere.example", fallback()), "/permissions/grants");
|
||||
assert_eq!(return_path("", fallback()), "/permissions/grants");
|
||||
}
|
||||
}
|
||||
13
web/src/pages/permissions/common/ui.rs
Normal file
13
web/src/pages/permissions/common/ui.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::ui::{Alert, ErrorPage, Nav, render};
|
||||
|
||||
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))
|
||||
}
|
||||
252
web/src/pages/permissions/grants/loader.rs
Normal file
252
web/src/pages/permissions/grants/loader.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Builds the grant matrix: every object the selected role could be given,
|
||||
//! grouped by profile, with each action marked as held, inherited, or not
|
||||
//! available for this role.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{GrantableObject, ListGrantableObjectsRequest, ListRolePermissionsRequest, Permission},
|
||||
pages::permissions::common::{
|
||||
loader,
|
||||
state::{LoadError, Tabs},
|
||||
},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{ACTIONS, Cell, GrantsPage, ObjectGroup, ObjectRow, RoleOption, Selection};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
selection: Selection,
|
||||
) -> Result<GrantsPage, LoadError> {
|
||||
let mut auth = state.auth;
|
||||
let access = loader::access(&mut auth, headers).await?;
|
||||
if !access.can_manage_roles {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let roles = loader::roles(&mut auth, headers).await?;
|
||||
let editable = loader::manageable(&roles, &access.authorization.role);
|
||||
|
||||
let requested = selection.role.trim();
|
||||
let selected = if requested.is_empty() {
|
||||
editable.first().cloned()
|
||||
} else {
|
||||
match editable.iter().find(|role| role.name == requested) {
|
||||
Some(role) => Some(role.clone()),
|
||||
None => {
|
||||
return Err(LoadError::InvalidSelection(format!(
|
||||
"'{requested}' is not a role you may edit."
|
||||
)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (permissions, objects) = match &selected {
|
||||
Some(role) => {
|
||||
let permissions = auth
|
||||
.list_role_permissions(
|
||||
authenticated_request(
|
||||
headers,
|
||||
ListRolePermissionsRequest {
|
||||
role: role.name.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(loader::status_error)?
|
||||
.into_inner();
|
||||
let objects = auth
|
||||
.list_grantable_objects(
|
||||
authenticated_request(
|
||||
headers,
|
||||
ListGrantableObjectsRequest {
|
||||
target_role: role.name.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(loader::status_error)?
|
||||
.into_inner()
|
||||
.objects;
|
||||
(permissions, objects)
|
||||
}
|
||||
None => Default::default(),
|
||||
};
|
||||
|
||||
Ok(GrantsPage {
|
||||
nav: loader::nav(headers, &access),
|
||||
tabs: Tabs::new("grants", &access),
|
||||
roles: editable
|
||||
.into_iter()
|
||||
.map(|role| RoleOption {
|
||||
name: role.name,
|
||||
parent: role.parent,
|
||||
})
|
||||
.collect(),
|
||||
selected_role: selected
|
||||
.as_ref()
|
||||
.map(|role| role.name.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_parent: selected
|
||||
.as_ref()
|
||||
.map(|role| role.parent.clone())
|
||||
.unwrap_or_default(),
|
||||
selected_is_structural: selected
|
||||
.as_ref()
|
||||
.is_some_and(|role| role.kind == "structural"),
|
||||
groups: group_objects(
|
||||
&objects,
|
||||
&permissions.permissions,
|
||||
&permissions.effective_permissions,
|
||||
),
|
||||
updated: selection.updated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turns the server's flat object list into one panel per profile, keeping the
|
||||
/// order it sent: the wildcards that cover everything first, then each profile
|
||||
/// with its own wildcards ahead of its tables.
|
||||
fn group_objects(
|
||||
objects: &[GrantableObject],
|
||||
direct: &[Permission],
|
||||
effective: &[Permission],
|
||||
) -> Vec<ObjectGroup> {
|
||||
let mut groups: Vec<ObjectGroup> = Vec::new();
|
||||
|
||||
for object in objects {
|
||||
let global = object.profile.is_empty();
|
||||
let title = if global {
|
||||
"Everything".to_string()
|
||||
} else {
|
||||
object.profile.clone()
|
||||
};
|
||||
|
||||
let index = match groups.iter().position(|group| group.title == title) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
groups.push(ObjectGroup {
|
||||
title,
|
||||
global,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
}
|
||||
};
|
||||
|
||||
let (label, note) = describe(object);
|
||||
groups[index].rows.push(ObjectRow {
|
||||
wildcard: object.table.is_empty(),
|
||||
label,
|
||||
note,
|
||||
cells: ACTIONS
|
||||
.iter()
|
||||
.map(|action| {
|
||||
let held = crate::authz::is_direct_permission(direct, &object.object, action);
|
||||
Cell {
|
||||
action: (*action).to_string(),
|
||||
allowed: object.allowed_actions.iter().any(|allowed| allowed == action),
|
||||
direct: held,
|
||||
inherited: !held
|
||||
&& crate::authz::permissions_permit(effective, &object.object, action),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
object: object.object.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// What each object covers, said in the terms the person granting it thinks in
|
||||
/// rather than in object strings.
|
||||
fn describe(object: &GrantableObject) -> (String, String) {
|
||||
match object.kind.as_str() {
|
||||
"global_data" => (
|
||||
"All tables".to_string(),
|
||||
"Every table in every profile, including ones added later.".to_string(),
|
||||
),
|
||||
"global_journal" => (
|
||||
"All journals".to_string(),
|
||||
"Every profile's accounting journal.".to_string(),
|
||||
),
|
||||
"global_ecb" => (
|
||||
"All exchange rates".to_string(),
|
||||
"ECB rates are imported by the server, so they can only be read.".to_string(),
|
||||
),
|
||||
"profile" => (
|
||||
"All tables".to_string(),
|
||||
"Every table in this profile, including ones added later.".to_string(),
|
||||
),
|
||||
"journal" => (
|
||||
"Journal".to_string(),
|
||||
"This profile's accounting journal.".to_string(),
|
||||
),
|
||||
"ecb" => (
|
||||
"Exchange rates".to_string(),
|
||||
"Imported by the server, so read is the only grant.".to_string(),
|
||||
),
|
||||
_ => (
|
||||
object.table.clone(),
|
||||
"This table and its whole template family.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn object(object: &str, profile: &str, table: &str, kind: &str, actions: &[&str]) -> GrantableObject {
|
||||
GrantableObject {
|
||||
object: object.to_string(),
|
||||
profile: profile.to_string(),
|
||||
table: table.to_string(),
|
||||
kind: kind.to_string(),
|
||||
allowed_actions: actions.iter().map(|action| (*action).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn permission(object: &str, action: &str) -> Permission {
|
||||
Permission {
|
||||
object: object.to_string(),
|
||||
action: action.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_group_by_profile_and_a_wildcard_grant_shows_as_inherited_on_its_tables() {
|
||||
let objects = [
|
||||
object("data:*", "", "", "global_data", &["read", "insert"]),
|
||||
object("data:acme/*", "acme", "", "profile", &["read", "insert"]),
|
||||
object("data:acme/invoices", "acme", "invoices", "table", &["read", "insert"]),
|
||||
];
|
||||
let direct = [permission("data:acme/*", "read")];
|
||||
let effective = [permission("data:acme/*", "read")];
|
||||
|
||||
let groups = group_objects(&objects, &direct, &effective);
|
||||
assert_eq!(groups.len(), 2);
|
||||
assert!(groups[0].global);
|
||||
assert_eq!(groups[1].title, "acme");
|
||||
assert_eq!(groups[1].rows.len(), 2);
|
||||
|
||||
let profile_row = &groups[1].rows[0];
|
||||
assert!(profile_row.wildcard);
|
||||
assert!(profile_row.cells[0].direct);
|
||||
|
||||
// The table is covered by the profile wildcard, so read reads as
|
||||
// inherited rather than as something to grant again.
|
||||
let table_row = &groups[1].rows[1];
|
||||
assert!(!table_row.wildcard);
|
||||
assert!(!table_row.cells[0].direct);
|
||||
assert!(table_row.cells[0].inherited);
|
||||
|
||||
// The server offered no delete for this role, so the column is closed.
|
||||
assert!(!table_row.cells[3].allowed);
|
||||
}
|
||||
}
|
||||
103
web/src/pages/permissions/grants/logic.rs
Normal file
103
web/src/pages/permissions/grants/logic.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{GrantPermissionRequest, RevokePermissionRequest},
|
||||
pages::permissions::common::{
|
||||
logic::{load_error, mutate},
|
||||
state::{parse_pairs, return_path},
|
||||
},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader,
|
||||
state::{ApplyForm, 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies one form's worth of grants or revokes.
|
||||
///
|
||||
/// The pairs arrive already resolved by the page — a shortcut is a longer list,
|
||||
/// not a different rule — so each one is sent as its own call and the first
|
||||
/// refusal stops the run and is reported. Revoking is the exception: a pair the
|
||||
/// role does not hold directly is not a failure, because "remove all of this"
|
||||
/// is posted from a list that includes what a shortcut would have granted.
|
||||
pub(crate) async fn apply(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ApplyForm>,
|
||||
) -> Response {
|
||||
let role = form.role.trim().to_string();
|
||||
let destination = return_path(
|
||||
&form.return_to,
|
||||
format!("/permissions/grants?role={role}&updated=true"),
|
||||
);
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, &destination, async move {
|
||||
let pairs = parse_pairs(&form.pair)?;
|
||||
let granting = match form.mode.as_str() {
|
||||
"grant" => true,
|
||||
"revoke" => false,
|
||||
other => return Err(format!("'{other}' is neither grant nor revoke.")),
|
||||
};
|
||||
|
||||
let mut auth = state.auth;
|
||||
for (object, action) in pairs {
|
||||
if granting {
|
||||
auth.grant_permission(
|
||||
authenticated_request(
|
||||
&request_headers,
|
||||
GrantPermissionRequest {
|
||||
role: role.clone(),
|
||||
object: object.clone(),
|
||||
action: action.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "Missing session".to_string())?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!("Granting {action} on {object} failed: {}", error.message())
|
||||
})?;
|
||||
} else if let Err(error) = auth
|
||||
.revoke_permission(
|
||||
authenticated_request(
|
||||
&request_headers,
|
||||
RevokePermissionRequest {
|
||||
role: role.clone(),
|
||||
object: object.clone(),
|
||||
action: action.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "Missing session".to_string())?,
|
||||
)
|
||||
.await
|
||||
&& error.code() != tonic::Code::NotFound
|
||||
{
|
||||
return Err(format!(
|
||||
"Revoking {action} on {object} failed: {}",
|
||||
error.message()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
20
web/src/pages/permissions/grants/mod.rs
Normal file
20
web/src/pages/permissions/grants/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! What a role may do: the grant matrix, and the shortcuts that fill it in
|
||||
//! whole rows, whole profiles, or everything at once.
|
||||
|
||||
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("/permissions/grants", get(logic::page))
|
||||
.route("/permissions/grants/apply", post(logic::apply))
|
||||
}
|
||||
271
web/src/pages/permissions/grants/state.rs
Normal file
271
web/src/pages/permissions/grants/state.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use crate::{
|
||||
pages::permissions::common::state::{Tabs, pair},
|
||||
ui::Nav,
|
||||
};
|
||||
|
||||
/// The columns of every grant matrix, in the order the server lists them.
|
||||
pub(crate) const ACTIONS: [&str; 4] = ["read", "insert", "update", "delete"];
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
/// One grant or revoke, however many pairs it covers.
|
||||
///
|
||||
/// A single cell and a "full access to this profile" shortcut post the same
|
||||
/// form; only the list of pairs differs. `return_to` is set by the
|
||||
/// table-definition workspace, which edits grants without leaving its own page.
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct ApplyForm {
|
||||
pub role: String,
|
||||
/// `grant` or `revoke`.
|
||||
pub mode: String,
|
||||
#[serde(default)]
|
||||
pub pair: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub return_to: String,
|
||||
}
|
||||
|
||||
pub(crate) struct GrantsPage {
|
||||
pub nav: Nav,
|
||||
pub tabs: Tabs,
|
||||
pub roles: Vec<RoleOption>,
|
||||
pub selected_role: String,
|
||||
/// The selected role's parent, whose grants it inherits.
|
||||
pub selected_parent: String,
|
||||
/// `structural` roles may only ever be granted read, which the matrix shows
|
||||
/// by leaving the write columns unavailable; this says why.
|
||||
pub selected_is_structural: bool,
|
||||
pub groups: Vec<ObjectGroup>,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct RoleOption {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
}
|
||||
|
||||
/// The objects of one profile, or the wildcards that cover every profile.
|
||||
pub(crate) struct ObjectGroup {
|
||||
pub title: String,
|
||||
/// The `data:*` / `journal:*` / `ecb:*` group, which is the one whose
|
||||
/// shortcuts mean "everything, including what is created later".
|
||||
pub global: bool,
|
||||
pub rows: Vec<ObjectRow>,
|
||||
}
|
||||
|
||||
pub(crate) struct ObjectRow {
|
||||
pub object: String,
|
||||
pub label: String,
|
||||
pub note: String,
|
||||
/// Whether this object covers a whole profile (or everything) rather than
|
||||
/// one table. Group shortcuts use only these, so "full access to this
|
||||
/// profile" is one wildcard grant instead of one grant per table — and it
|
||||
/// keeps covering tables added later.
|
||||
pub wildcard: bool,
|
||||
pub cells: Vec<Cell>,
|
||||
}
|
||||
|
||||
pub(crate) struct Cell {
|
||||
pub action: String,
|
||||
/// Whether this action may be granted on this object for this role at all.
|
||||
pub allowed: bool,
|
||||
/// Held by the role itself, so revoking it here works.
|
||||
pub direct: bool,
|
||||
/// Already covered, by the parent role or by a wider object.
|
||||
pub inherited: bool,
|
||||
}
|
||||
|
||||
impl GrantsPage {
|
||||
pub(crate) fn actions(&self) -> [&'static str; 4] {
|
||||
ACTIONS
|
||||
}
|
||||
|
||||
pub(crate) fn selected(&self, role: &str) -> bool {
|
||||
self.selected_role == role
|
||||
}
|
||||
|
||||
/// Read on every wildcard object: the "let them look at everything" grant.
|
||||
pub(crate) fn everything_read_pairs(&self) -> Vec<String> {
|
||||
self.global_group()
|
||||
.map(|group| group.read_pairs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every action the role may hold on every wildcard object.
|
||||
pub(crate) fn everything_pairs(&self) -> Vec<String> {
|
||||
self.global_group()
|
||||
.map(|group| group.all_pairs())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every grant the role holds directly, which is what "start over" revokes.
|
||||
pub(crate) fn direct_pairs(&self) -> Vec<String> {
|
||||
self.groups
|
||||
.iter()
|
||||
.flat_map(|group| group.direct_pairs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_direct(&self) -> bool {
|
||||
self.groups.iter().any(ObjectGroup::has_direct)
|
||||
}
|
||||
|
||||
fn global_group(&self) -> Option<&ObjectGroup> {
|
||||
self.groups.iter().find(|group| group.global)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectGroup {
|
||||
/// Every action allowed on this group's wildcard objects.
|
||||
pub(crate) fn all_pairs(&self) -> Vec<String> {
|
||||
self.wildcards()
|
||||
.flat_map(|row| row.all_pairs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read on this group's wildcard objects.
|
||||
pub(crate) fn read_pairs(&self) -> Vec<String> {
|
||||
self.wildcards()
|
||||
.filter_map(|row| row.pair_for("read"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything held directly anywhere in this group, tables included, so
|
||||
/// clearing a profile really clears it.
|
||||
pub(crate) fn direct_pairs(&self) -> Vec<String> {
|
||||
self.rows.iter().flat_map(|row| row.direct_pairs()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_direct(&self) -> bool {
|
||||
self.rows.iter().any(ObjectRow::has_direct)
|
||||
}
|
||||
|
||||
/// Whether this group has anything for its shortcuts to grant. The server
|
||||
/// gives every profile its wildcards, so this is only ever false for a
|
||||
/// group of bare tables — and a button that would post nothing is not shown
|
||||
/// rather than left to fail on submit.
|
||||
pub(crate) fn has_wildcards(&self) -> bool {
|
||||
self.wildcards().next().is_some()
|
||||
}
|
||||
|
||||
fn wildcards(&self) -> impl Iterator<Item = &ObjectRow> {
|
||||
self.rows.iter().filter(|row| row.wildcard)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectRow {
|
||||
pub(crate) fn all_pairs(&self) -> Vec<String> {
|
||||
self.cells
|
||||
.iter()
|
||||
.filter(|cell| cell.allowed)
|
||||
.map(|cell| pair(&self.object, &cell.action))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn direct_pairs(&self) -> Vec<String> {
|
||||
self.cells
|
||||
.iter()
|
||||
.filter(|cell| cell.direct)
|
||||
.map(|cell| pair(&self.object, &cell.action))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_direct(&self) -> bool {
|
||||
self.cells.iter().any(|cell| cell.direct)
|
||||
}
|
||||
|
||||
/// One cell's pair, as the same list shape every other button posts.
|
||||
pub(crate) fn one_pair(&self, action: &str) -> Vec<String> {
|
||||
vec![pair(&self.object, action)]
|
||||
}
|
||||
|
||||
fn pair_for(&self, action: &str) -> Option<String> {
|
||||
self.cells
|
||||
.iter()
|
||||
.find(|cell| cell.action == action && cell.allowed)
|
||||
.map(|cell| pair(&self.object, &cell.action))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cells(allowed: &[&str], direct: &[&str]) -> Vec<Cell> {
|
||||
ACTIONS
|
||||
.iter()
|
||||
.map(|action| Cell {
|
||||
action: (*action).to_string(),
|
||||
allowed: allowed.contains(action),
|
||||
direct: direct.contains(action),
|
||||
inherited: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group() -> ObjectGroup {
|
||||
ObjectGroup {
|
||||
title: "acme".to_string(),
|
||||
global: false,
|
||||
rows: vec![
|
||||
ObjectRow {
|
||||
object: "data:acme/*".to_string(),
|
||||
label: "All tables".to_string(),
|
||||
note: String::new(),
|
||||
wildcard: true,
|
||||
cells: cells(&ACTIONS, &["read"]),
|
||||
},
|
||||
ObjectRow {
|
||||
object: "ecb:acme".to_string(),
|
||||
label: "ECB rates".to_string(),
|
||||
note: String::new(),
|
||||
wildcard: true,
|
||||
cells: cells(&["read"], &[]),
|
||||
},
|
||||
ObjectRow {
|
||||
object: "data:acme/invoices".to_string(),
|
||||
label: "invoices".to_string(),
|
||||
note: String::new(),
|
||||
wildcard: false,
|
||||
cells: cells(&ACTIONS, &["delete"]),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_shortcut_grants_the_wildcards_and_never_an_action_the_server_refuses() {
|
||||
let group = group();
|
||||
// The table row is covered by data:acme/*, so it is not granted again,
|
||||
// and ECB rates only ever offer read.
|
||||
assert_eq!(
|
||||
group.all_pairs(),
|
||||
vec![
|
||||
"data:acme/*|read",
|
||||
"data:acme/*|insert",
|
||||
"data:acme/*|update",
|
||||
"data:acme/*|delete",
|
||||
"ecb:acme|read",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
group.read_pairs(),
|
||||
vec!["data:acme/*|read", "ecb:acme|read"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_profile_covers_grants_made_on_single_tables() {
|
||||
let group = group();
|
||||
assert!(group.has_direct());
|
||||
assert_eq!(
|
||||
group.direct_pairs(),
|
||||
vec!["data:acme/*|read", "data:acme/invoices|delete"]
|
||||
);
|
||||
}
|
||||
}
|
||||
127
web/src/pages/permissions/grants/ui.rs
Normal file
127
web/src/pages/permissions/grants/ui.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Nav, render};
|
||||
|
||||
use super::state::GrantsPage;
|
||||
|
||||
/// GET /permissions/grants
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/permissions/grants/grants.html")]
|
||||
struct GrantsTemplate<'a> {
|
||||
nav: Nav,
|
||||
page: &'a GrantsPage,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &GrantsPage) -> String {
|
||||
render(&GrantsTemplate {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::permissions::{
|
||||
common::state::Tabs,
|
||||
grants::state::{ACTIONS, Cell, GrantsPage, ObjectGroup, ObjectRow, RoleOption},
|
||||
};
|
||||
|
||||
fn cells(allowed: &[&str], direct: &[&str], inherited: &[&str]) -> Vec<Cell> {
|
||||
ACTIONS
|
||||
.iter()
|
||||
.map(|action| Cell {
|
||||
action: (*action).to_string(),
|
||||
allowed: allowed.contains(action),
|
||||
direct: direct.contains(action),
|
||||
inherited: inherited.contains(action),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn page() -> GrantsPage {
|
||||
GrantsPage {
|
||||
nav: Nav {
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
active: "grants",
|
||||
can_manage_roles: true,
|
||||
can_manage_users: true,
|
||||
},
|
||||
roles: vec![RoleOption {
|
||||
name: "sales".to_string(),
|
||||
parent: "guest".to_string(),
|
||||
}],
|
||||
selected_role: "sales".to_string(),
|
||||
selected_parent: "guest".to_string(),
|
||||
selected_is_structural: false,
|
||||
groups: vec![
|
||||
ObjectGroup {
|
||||
title: "Everything".to_string(),
|
||||
global: true,
|
||||
rows: vec![ObjectRow {
|
||||
object: "data:*".to_string(),
|
||||
label: "All tables".to_string(),
|
||||
note: "Every table in every profile.".to_string(),
|
||||
wildcard: true,
|
||||
cells: cells(&ACTIONS, &[], &[]),
|
||||
}],
|
||||
},
|
||||
ObjectGroup {
|
||||
title: "billing".to_string(),
|
||||
global: false,
|
||||
rows: vec![ObjectRow {
|
||||
object: "data:billing/invoice".to_string(),
|
||||
label: "invoice".to_string(),
|
||||
note: String::new(),
|
||||
wildcard: false,
|
||||
cells: cells(&ACTIONS, &["insert"], &["read"]),
|
||||
}],
|
||||
},
|
||||
],
|
||||
updated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_matrix_separates_a_held_grant_from_an_inherited_one() {
|
||||
let html = render_page(&page());
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
// A held grant is a revoke button; an inherited one is not editable
|
||||
// here, because it belongs to the parent role.
|
||||
assert!(html.contains(r#"value="data:billing/invoice|insert""#));
|
||||
assert!(html.contains("inherited"));
|
||||
assert!(html.contains("/permissions/grants/apply"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcuts_post_whole_lists_of_pairs() {
|
||||
let html = render_page(&page());
|
||||
// "Full access to everything" is the wildcard row's four actions.
|
||||
for action in ACTIONS {
|
||||
assert!(
|
||||
html.contains(&format!(r#"value="data:*|{action}""#)),
|
||||
"missing data:*|{action}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_site_without_roles_says_so_instead_of_rendering_an_empty_matrix() {
|
||||
let mut page = page();
|
||||
page.roles.clear();
|
||||
page.selected_role.clear();
|
||||
page.groups.clear();
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("/permissions/roles"));
|
||||
}
|
||||
}
|
||||
43
web/src/pages/permissions/mod.rs
Normal file
43
web/src/pages/permissions/mod.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Roles and permissions, as three questions asked separately:
|
||||
//!
|
||||
//! * `/permissions/roles` — which roles exist, and what they inherit.
|
||||
//! * `/permissions/users` — who holds which role.
|
||||
//! * `/permissions/grants` — what a role may do.
|
||||
//!
|
||||
//! It is a nav section of its own rather than a page inside the admin panel,
|
||||
//! because managing users is not the same job as designing tables, and the two
|
||||
//! are held by different people.
|
||||
|
||||
pub(crate) mod common;
|
||||
mod grants;
|
||||
mod roles;
|
||||
mod users;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/permissions", get(entry))
|
||||
.merge(roles::router())
|
||||
.merge(users::router())
|
||||
.merge(grants::router())
|
||||
}
|
||||
|
||||
/// The navbar link. It lands on the first section the caller may open, so an
|
||||
/// account that only manages users never sees a forbidden page on the way in.
|
||||
async fn entry(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let mut auth = state.auth;
|
||||
match common::loader::access(&mut auth, &headers).await {
|
||||
Ok(access) if access.can_manage_roles => Redirect::to("/permissions/roles").into_response(),
|
||||
Ok(_) => Redirect::to("/permissions/users").into_response(),
|
||||
Err(error) => common::logic::load_error(error),
|
||||
}
|
||||
}
|
||||
62
web/src/pages/permissions/roles/loader.rs
Normal file
62
web/src/pages/permissions/roles/loader.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
pages::permissions::common::{
|
||||
loader,
|
||||
state::{LoadError, Tabs},
|
||||
},
|
||||
};
|
||||
|
||||
use super::state::{RoleRow, RolesPage, Selection};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
selection: Selection,
|
||||
) -> Result<RolesPage, LoadError> {
|
||||
let mut auth = state.auth;
|
||||
let access = loader::access(&mut auth, headers).await?;
|
||||
if !access.can_manage_roles {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let roles = loader::roles(&mut auth, headers).await?;
|
||||
// The user list is only there to say how many people hold each role, so a
|
||||
// caller who does not manage users simply does not get that column.
|
||||
let users = if access.can_manage_users {
|
||||
Some(loader::users(&mut auth, headers).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let editable = loader::manageable(&roles, &access.authorization.role);
|
||||
let rows = roles
|
||||
.iter()
|
||||
.map(|role| RoleRow {
|
||||
name: role.name.clone(),
|
||||
kind: role.kind.clone(),
|
||||
parent: role.parent.clone(),
|
||||
built_in: role.built_in,
|
||||
users: users.as_ref().map(|users| {
|
||||
users
|
||||
.iter()
|
||||
.filter(|user| user.role == role.name)
|
||||
.count()
|
||||
}),
|
||||
editable: editable.iter().any(|candidate| candidate.name == role.name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(RolesPage {
|
||||
nav: loader::nav(headers, &access),
|
||||
tabs: Tabs::new("roles", &access),
|
||||
roles: rows,
|
||||
parents: editable
|
||||
.iter()
|
||||
.filter(|role| role.kind == "data")
|
||||
.map(|role| role.name.clone())
|
||||
.collect(),
|
||||
updated: selection.updated,
|
||||
})
|
||||
}
|
||||
102
web/src/pages/permissions/roles/logic.rs
Normal file
102
web/src/pages/permissions/roles/logic.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{AddRoleRequest, GrantPermissionRequest, RemoveRoleRequest},
|
||||
pages::permissions::common::logic::{load_error, mutate},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader,
|
||||
state::{CreateRoleForm, RemoveRoleForm, Selection, starter_grants},
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a role and, if a starter access level was chosen, hands it the
|
||||
/// grants that go with it. The grants are separate calls, so a role whose
|
||||
/// starter access fails halfway still exists — the failure names what is
|
||||
/// missing and the access tab shows exactly what landed.
|
||||
pub(crate) async fn create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateRoleForm>,
|
||||
) -> Response {
|
||||
let role = form.name.trim().to_string();
|
||||
let destination = format!("/permissions/grants?role={role}&updated=true");
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, &destination, async move {
|
||||
let grants = starter_grants(form.access.trim())?;
|
||||
let mut auth = state.auth;
|
||||
auth.add_role(
|
||||
authenticated_request(
|
||||
&request_headers,
|
||||
AddRoleRequest {
|
||||
name: role.clone(),
|
||||
parent: form.parent.trim().to_string(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "Missing session".to_string())?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
|
||||
for (object, action) in grants {
|
||||
auth.grant_permission(
|
||||
authenticated_request(
|
||||
&request_headers,
|
||||
GrantPermissionRequest {
|
||||
role: role.clone(),
|
||||
object: object.to_string(),
|
||||
action: action.to_string(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "Missing session".to_string())?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Role '{role}' was created, but granting {action} on {object} failed: {}",
|
||||
error.message()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn remove(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<RemoveRoleForm>,
|
||||
) -> Response {
|
||||
let request_headers = headers.clone();
|
||||
mutate(&headers, "/permissions/roles?updated=true", 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
|
||||
}
|
||||
21
web/src/pages/permissions/roles/mod.rs
Normal file
21
web/src/pages/permissions/roles/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Which roles exist: creating them, the tree they inherit through, and
|
||||
//! removing the ones nobody holds.
|
||||
|
||||
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("/permissions/roles", get(logic::page))
|
||||
.route("/permissions/roles/create", post(logic::create))
|
||||
.route("/permissions/roles/remove", post(logic::remove))
|
||||
}
|
||||
133
web/src/pages/permissions/roles/state.rs
Normal file
133
web/src/pages/permissions/roles/state.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use crate::{pages::permissions::common::state::Tabs, ui::Nav};
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct CreateRoleForm {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub parent: String,
|
||||
/// Which starter access the new role gets: `none`, `read`, or `full`.
|
||||
#[serde(default)]
|
||||
pub access: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct RemoveRoleForm {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
pub(crate) struct RolesPage {
|
||||
pub nav: Nav,
|
||||
pub tabs: Tabs,
|
||||
pub roles: Vec<RoleRow>,
|
||||
/// Roles a new role may inherit from.
|
||||
pub parents: Vec<String>,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct RoleRow {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub parent: String,
|
||||
pub built_in: bool,
|
||||
/// How many users hold the role. `None` when the caller does not manage
|
||||
/// users and so never saw the list.
|
||||
pub users: Option<usize>,
|
||||
/// Whether the caller may open this role's access, which is also what makes
|
||||
/// it removable.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
impl RoleRow {
|
||||
pub(crate) fn removable(&self) -> bool {
|
||||
self.editable && !self.built_in && self.users.is_none_or(|count| count == 0)
|
||||
}
|
||||
|
||||
/// Why the delete button is missing, so an undeletable role says so instead
|
||||
/// of showing nothing.
|
||||
pub(crate) fn keeps_reason(&self) -> &'static str {
|
||||
if !self.editable {
|
||||
"outranks you"
|
||||
} else if self.built_in {
|
||||
"built in"
|
||||
} else if self.users.is_some_and(|count| count > 0) {
|
||||
"still assigned"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The grants a starter-access choice hands the new role.
|
||||
///
|
||||
/// These are wildcard objects on purpose: they keep covering profiles and
|
||||
/// tables added later, which is what "everything" has to mean for a role
|
||||
/// created before the data exists. ECB rates are written by the server, so they
|
||||
/// are readable and nothing more.
|
||||
pub(crate) fn starter_grants(access: &str) -> Result<Vec<(&'static str, &'static str)>, String> {
|
||||
match access {
|
||||
"" | "none" => Ok(Vec::new()),
|
||||
"read" => Ok(vec![
|
||||
("data:*", "read"),
|
||||
("journal:*", "read"),
|
||||
("ecb:*", "read"),
|
||||
]),
|
||||
"full" => {
|
||||
let mut grants = vec![("ecb:*", "read")];
|
||||
for object in ["data:*", "journal:*"] {
|
||||
for action in ["read", "insert", "update", "delete"] {
|
||||
grants.push((object, action));
|
||||
}
|
||||
}
|
||||
Ok(grants)
|
||||
}
|
||||
other => Err(format!("'{other}' is not a starter access level.")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn starter_access_never_hands_out_a_write_on_ecb_rates() {
|
||||
assert!(starter_grants("none").unwrap().is_empty());
|
||||
assert!(starter_grants("nonsense").is_err());
|
||||
|
||||
for level in ["read", "full"] {
|
||||
let grants = starter_grants(level).unwrap();
|
||||
assert!(
|
||||
grants
|
||||
.iter()
|
||||
.all(|(object, action)| *object != "ecb:*" || *action == "read"),
|
||||
"{level} granted a write on ECB rates"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(starter_grants("read").unwrap().len(), 3);
|
||||
assert_eq!(starter_grants("full").unwrap().len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_is_only_removable_once_nobody_holds_it() {
|
||||
let row = |built_in, users, editable| RoleRow {
|
||||
name: "sales".to_string(),
|
||||
kind: "data".to_string(),
|
||||
parent: String::new(),
|
||||
built_in,
|
||||
users,
|
||||
editable,
|
||||
};
|
||||
|
||||
assert!(row(false, Some(0), true).removable());
|
||||
assert!(!row(false, Some(2), true).removable());
|
||||
assert!(!row(true, Some(0), true).removable());
|
||||
assert!(!row(false, Some(0), false).removable());
|
||||
assert_eq!(row(false, Some(2), true).keeps_reason(), "still assigned");
|
||||
}
|
||||
}
|
||||
78
web/src/pages/permissions/roles/ui.rs
Normal file
78
web/src/pages/permissions/roles/ui.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Nav, render};
|
||||
|
||||
use super::state::RolesPage;
|
||||
|
||||
/// GET /permissions/roles
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/permissions/roles/roles.html")]
|
||||
struct RolesTemplate<'a> {
|
||||
nav: Nav,
|
||||
page: &'a RolesPage,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &RolesPage) -> String {
|
||||
render(&RolesTemplate {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::permissions::{
|
||||
common::state::Tabs,
|
||||
roles::state::{RoleRow, RolesPage},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_role_list_offers_creation_and_holds_back_deletion_of_a_role_in_use() {
|
||||
let page = RolesPage {
|
||||
nav: Nav {
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
active: "roles",
|
||||
can_manage_roles: true,
|
||||
can_manage_users: true,
|
||||
},
|
||||
roles: vec![
|
||||
RoleRow {
|
||||
name: "sales".to_string(),
|
||||
kind: "data".to_string(),
|
||||
parent: "guest".to_string(),
|
||||
built_in: false,
|
||||
users: Some(2),
|
||||
editable: true,
|
||||
},
|
||||
RoleRow {
|
||||
name: "temp".to_string(),
|
||||
kind: "data".to_string(),
|
||||
parent: String::new(),
|
||||
built_in: false,
|
||||
users: Some(0),
|
||||
editable: true,
|
||||
},
|
||||
],
|
||||
parents: vec!["sales".to_string()],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("/permissions/roles/create"));
|
||||
// The unused role can go; the one two people hold says why it cannot.
|
||||
assert!(html.contains(r#"value="temp""#));
|
||||
assert!(html.contains("still assigned"));
|
||||
// Every role links straight to the tab that edits what it may do.
|
||||
assert!(html.contains("/permissions/grants?role=sales"));
|
||||
}
|
||||
}
|
||||
46
web/src/pages/permissions/users/loader.rs
Normal file
46
web/src/pages/permissions/users/loader.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
pages::permissions::common::{
|
||||
loader,
|
||||
state::{LoadError, Tabs},
|
||||
},
|
||||
};
|
||||
|
||||
use super::state::{Selection, UserRow, UsersPage};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
selection: Selection,
|
||||
) -> Result<UsersPage, LoadError> {
|
||||
let mut auth = state.auth;
|
||||
let access = loader::access(&mut auth, headers).await?;
|
||||
if !access.can_manage_users {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let actor = access.authorization.role.clone();
|
||||
let roles = loader::roles(&mut auth, headers).await?;
|
||||
let users = loader::users(&mut auth, headers).await?;
|
||||
|
||||
Ok(UsersPage {
|
||||
nav: loader::nav(headers, &access),
|
||||
tabs: Tabs::new("users", &access),
|
||||
users: users
|
||||
.into_iter()
|
||||
.map(|user| UserRow {
|
||||
editable: crate::authz::outranks(&actor, &user.role),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
})
|
||||
.collect(),
|
||||
roles: loader::manageable(&roles, &actor)
|
||||
.into_iter()
|
||||
.map(|role| role.name)
|
||||
.collect(),
|
||||
updated: selection.updated,
|
||||
})
|
||||
}
|
||||
83
web/src/pages/permissions/users/logic.rs
Normal file
83
web/src/pages/permissions/users/logic.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{AssignUserRoleRequest, ResetUserPasswordRequest},
|
||||
pages::permissions::common::logic::{load_error, mutate},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader,
|
||||
state::{AssignRoleForm, ResetPasswordForm, Selection},
|
||||
ui,
|
||||
};
|
||||
|
||||
const DESTINATION: &str = "/permissions/users?updated=true";
|
||||
|
||||
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 assign_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AssignRoleForm>,
|
||||
) -> Response {
|
||||
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_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ResetPasswordForm>,
|
||||
) -> Response {
|
||||
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
|
||||
}
|
||||
21
web/src/pages/permissions/users/mod.rs
Normal file
21
web/src/pages/permissions/users/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Who holds which role: assigning a role to a user, and resetting a password
|
||||
//! for a user the caller outranks.
|
||||
|
||||
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("/permissions/users", get(logic::page))
|
||||
.route("/permissions/users/role", post(logic::assign_role))
|
||||
.route("/permissions/users/password", post(logic::reset_password))
|
||||
}
|
||||
44
web/src/pages/permissions/users/state.rs
Normal file
44
web/src/pages/permissions/users/state.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::{pages::permissions::common::state::Tabs, ui::Nav};
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct Selection {
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
#[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 UsersPage {
|
||||
pub nav: Nav,
|
||||
pub tabs: Tabs,
|
||||
pub users: Vec<UserRow>,
|
||||
/// Roles the caller may hand out.
|
||||
pub roles: Vec<String>,
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct UserRow {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
/// Whether the caller outranks this user, which is what the server checks
|
||||
/// before either changing their role or resetting their password.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
impl UsersPage {
|
||||
pub(crate) fn editable_users(&self) -> usize {
|
||||
self.users.iter().filter(|user| user.editable).count()
|
||||
}
|
||||
}
|
||||
71
web/src/pages/permissions/users/ui.rs
Normal file
71
web/src/pages/permissions/users/ui.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{Nav, render};
|
||||
|
||||
use super::state::UsersPage;
|
||||
|
||||
/// GET /permissions/users
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/permissions/users/users.html")]
|
||||
struct UsersTemplate<'a> {
|
||||
nav: Nav,
|
||||
page: &'a UsersPage,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &UsersPage) -> String {
|
||||
render(&UsersTemplate {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::permissions::{
|
||||
common::state::Tabs,
|
||||
users::state::{UserRow, UsersPage},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn a_user_the_caller_does_not_outrank_gets_no_controls() {
|
||||
let page = UsersPage {
|
||||
nav: Nav {
|
||||
authenticated: true,
|
||||
role: "admin".to_string(),
|
||||
can_admin: true,
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
active: "users",
|
||||
can_manage_roles: true,
|
||||
can_manage_users: true,
|
||||
},
|
||||
users: vec![
|
||||
UserRow {
|
||||
username: "alice".to_string(),
|
||||
email: "alice@example.com".to_string(),
|
||||
role: "sales".to_string(),
|
||||
editable: true,
|
||||
},
|
||||
UserRow {
|
||||
username: "root".to_string(),
|
||||
email: String::new(),
|
||||
role: "superadmin".to_string(),
|
||||
editable: false,
|
||||
},
|
||||
],
|
||||
roles: vec!["sales".to_string(), "guest".to_string()],
|
||||
updated: false,
|
||||
};
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert_eq!(html.matches("/permissions/users/role").count(), 1);
|
||||
assert_eq!(html.matches("/permissions/users/password").count(), 1);
|
||||
assert!(html.contains("outranks you"));
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,22 @@ pub(crate) struct Nav {
|
||||
pub authenticated: bool,
|
||||
pub role: String,
|
||||
pub can_admin: bool,
|
||||
pub can_permissions: bool,
|
||||
pub can_import: bool,
|
||||
pub can_export: bool,
|
||||
pub active: &'static str,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
/// `active` is the nav link to highlight: `"admin"`, `"analytics"`,
|
||||
/// `"login"`, or `""` for pages that are not themselves nav entries.
|
||||
/// `active` is the nav link to highlight: `"admin"`, `"permissions"`,
|
||||
/// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav
|
||||
/// entries.
|
||||
pub(crate) fn new(headers: &HeaderMap, active: &'static str) -> Self {
|
||||
Self {
|
||||
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
||||
role: String::new(),
|
||||
can_admin: false,
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active,
|
||||
@@ -41,6 +44,10 @@ impl Nav {
|
||||
) -> Self {
|
||||
self.role = authorization.role.clone();
|
||||
self.can_admin = crate::authz::can_open_admin(authorization);
|
||||
// Permissions is its own nav section, so it is not gated on the admin
|
||||
// panel: managing either roles or users is enough to open it.
|
||||
self.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE)
|
||||
|| crate::authz::can_manage(authorization, crate::authz::STRUCT_USER);
|
||||
self.can_import = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "insert" && permission.object.starts_with("data:")
|
||||
});
|
||||
@@ -57,6 +64,7 @@ impl Default for Nav {
|
||||
authenticated: false,
|
||||
role: String::new(),
|
||||
can_admin: false,
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "",
|
||||
|
||||
Reference in New Issue
Block a user