web synchronized with the new changes

This commit is contained in:
Priec
2026-08-09 16:30:27 +02:00
parent e66b025188
commit fd551ef7fb
44 changed files with 1215 additions and 88 deletions

View File

@@ -29,7 +29,7 @@ pub(crate) async fn load_admin_page(
})?
.into_inner();
if authorization.role != "admin" {
if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) {
return Err(LoadError::Forbidden);
}
@@ -129,12 +129,20 @@ pub(crate) async fn load_admin_page(
};
Ok(AdminPageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
profiles,
selected_profile,
tables,
selected_table,
columns,
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:")
}),
})
}

View File

@@ -14,6 +14,11 @@ pub(crate) struct AdminPageState {
pub tables: Vec<TableView>,
pub selected_table: Option<String>,
pub columns: Vec<ColumnView>,
pub can_manage_tables: bool,
pub can_manage_scripts: bool,
pub can_manage_validations: bool,
pub can_manage_permissions: bool,
pub can_export: bool,
}
#[derive(Debug)]

View File

@@ -49,6 +49,9 @@ mod tests {
let nav = Nav {
authenticated: true,
role: "admin".to_string(),
can_admin: true,
can_import: false,
can_export: false,
active: "admin",
};
let page = AdminPageState {
@@ -58,6 +61,11 @@ mod tests {
tables: Vec::new(),
selected_table: None,
columns: Vec::new(),
can_manage_tables: true,
can_manage_scripts: true,
can_manage_validations: true,
can_manage_permissions: true,
can_export: true,
};
let html = render_page(&page);
for route in [
@@ -66,8 +74,8 @@ mod tests {
"/admin/logic/new",
"/admin/validation/new",
"/admin/validation/sets/new",
"/admin/import",
"/admin/export",
"/admin/permissions",
"/logout",
] {
assert!(html.contains(route), "missing admin action route {route}");

View File

@@ -1,2 +1,3 @@
pub(crate) mod admin;
pub(crate) mod permissions;
pub(crate) mod table_definition;

View File

@@ -0,0 +1,132 @@
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 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,
})
}
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()),
}
}

View File

@@ -0,0 +1,160 @@
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,
RevokePermissionRequest,
},
services::{authenticated_request, reject_cross_site},
};
use super::{
loader,
state::{AddRoleForm, AssignRoleForm, LoadError, PermissionForm, 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 request_headers = headers.clone();
mutate(&headers, async move {
let mut auth = state.auth;
auth.add_role(authenticated_request(&request_headers, AddRoleRequest {
name: form.name.trim().to_string(),
parent: form.parent.trim().to_string(),
}).map_err(|_| "Missing session".to_string())?)
.await.map_err(|error| error.message().to_string())?;
Ok("Role created. Sign in again to continue.")
}).await
}
pub(crate) async fn remove_role(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RoleForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, 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("Role removed. Sign in again to continue.")
}).await
}
pub(crate) async fn grant(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<PermissionForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, 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("Permission granted. Sign in again to continue.")
}).await
}
pub(crate) async fn revoke(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<PermissionForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, 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("Permission revoked. Sign in again to continue.")
}).await
}
pub(crate) async fn assign_user_role(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<AssignRoleForm>,
) -> Response {
let request_headers = headers.clone();
mutate(&headers, 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("User role changed. Sign in again to continue.")
}).await
}
async fn mutate<F>(headers: &HeaderMap, operation: F) -> Response
where
F: std::future::Future<Output = Result<&'static str, String>>,
{
if let Some(rejection) = reject_cross_site(headers) {
return rejection;
}
match operation.await {
Ok(_) => stale_session_response(),
Err(message) => (
StatusCode::UNPROCESSABLE_ENTITY,
Html(ui::render_mutation_error(&message)),
).into_response(),
}
}
fn stale_session_response() -> Response {
let mut response = StatusCode::SEE_OTHER.into_response();
response.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_static("analytics_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"),
);
response.headers_mut().insert(
header::LOCATION,
HeaderValue::from_static("/login?permissions_changed=1"),
);
response.headers_mut().insert(
"hx-redirect",
HeaderValue::from_static("/login?permissions_changed=1"),
);
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(),
}
}

View File

@@ -0,0 +1,18 @@
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))
}

View File

@@ -0,0 +1,85 @@
use crate::auth::{GrantableObject, Permission, Role, UserSummary};
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub role: String,
}
#[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,
}
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,
}
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) enum LoadError {
Unauthenticated,
Forbidden,
InvalidSelection(String),
Backend(String),
}

View File

@@ -0,0 +1,96 @@
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,
};
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"));
}
}

View File

@@ -13,7 +13,7 @@ use tonic::transport::Channel;
use crate::{
AppState,
auth::GetAuthorizationRequest,
auth::{GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest},
definitions::{
common::Empty,
table_definition::{
@@ -27,7 +27,7 @@ use crate::{
use super::state::{
DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState,
TableDetailView, TableSummary,
TableDetailView, TablePermissionAction, TableRolePermissions, TableSummary,
};
/// Reads the column-type vocabulary on its own.
@@ -67,7 +67,7 @@ pub(crate) async fn load_page(
_ => LoadError::Backend(error.message().to_string()),
})?
.into_inner();
if authorization.role != "admin" {
if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) {
return Err(LoadError::Forbidden);
}
@@ -220,8 +220,83 @@ pub(crate) async fn load_page(
false => Vec::new(),
};
let mut permission_object = String::new();
let mut role_permissions = Vec::new();
if inputs.selection.has_table()
&& crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE)
{
let expected_object = crate::authz::table_object(
&inputs.selection.profile,
&inputs.selection.table,
);
let roles = auth
.list_roles(
authenticated_request(headers, ListRolesRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.roles;
for role in roles.into_iter().filter(|role| role.kind == "data") {
let grantable = auth
.list_grantable_objects(
authenticated_request(
headers,
ListGrantableObjectsRequest {
target_role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.objects
.into_iter()
.find(|object| object.object == expected_object);
let Some(grantable) = grantable else {
continue;
};
permission_object = expected_object.clone();
let permissions = auth
.list_role_permissions(
authenticated_request(
headers,
ListRolePermissionsRequest {
role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
role_permissions.push(TableRolePermissions {
role: role.name,
actions: grantable
.allowed_actions
.into_iter()
.map(|action| TablePermissionAction {
direct: crate::authz::is_direct_permission(
&permissions.permissions,
&expected_object,
&action,
),
effective: crate::authz::permissions_permit(
&permissions.effective_permissions,
&expected_object,
&action,
),
action,
})
.collect(),
});
}
}
Ok(TableDefinitionPageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
profiles,
tables,
detail,
@@ -235,5 +310,7 @@ pub(crate) async fn load_page(
error: inputs.error,
sql: inputs.sql,
generated: inputs.generated,
permission_object,
role_permissions,
})
}

View File

@@ -462,7 +462,7 @@ fn load_error_response(error: LoadError) -> Response {
LoadError::Forbidden => (
StatusCode::FORBIDDEN,
Html(ui::render_load_error(
"Administrator access is required.",
"Table-management permission is required.",
)),
)
.into_response(),

View File

@@ -255,6 +255,19 @@ pub(crate) struct TableDefinitionPageState {
pub error: Option<String>,
pub sql: Option<String>,
pub generated: Vec<GeneratedTableView>,
pub permission_object: String,
pub role_permissions: Vec<TableRolePermissions>,
}
pub(crate) struct TableRolePermissions {
pub role: String,
pub actions: Vec<TablePermissionAction>,
}
pub(crate) struct TablePermissionAction {
pub action: String,
pub direct: bool,
pub effective: bool,
}
impl TableDefinitionPageState {

View File

@@ -99,7 +99,7 @@ mod tests {
use crate::{
pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
TableSummary,
TablePermissionAction, TableRolePermissions, TableSummary,
},
schema::ColumnDraft,
};
@@ -149,6 +149,8 @@ mod tests {
error: None,
sql: None,
generated: Vec::new(),
permission_object: String::new(),
role_permissions: Vec::new(),
}
}
@@ -296,6 +298,26 @@ mod tests {
assert!(html.contains("1 column added"));
}
#[test]
fn selected_table_exposes_its_data_permission_actions() {
let mut page = page();
page.permission_object = "data:billing/invoice".to_string();
page.role_permissions = vec![TableRolePermissions {
role: "bookkeeper".to_string(),
actions: vec![TablePermissionAction {
action: "read".to_string(),
direct: false,
effective: false,
}],
}];
let html = render_workspace(&page);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("Data permissions for"));
assert!(html.contains("data:billing/invoice"));
assert!(html.contains("Grant read"));
}
#[test]
fn a_load_failure_answers_with_the_dialog() {
let html = render_load_error("The backend is unreachable.");