web synchronized with the new changes
This commit is contained in:
106
web/src/authz.rs
Normal file
106
web/src/authz.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use crate::auth::{AuthorizationSnapshot, Permission};
|
||||
|
||||
pub(crate) const STRUCT_PROFILE: &str = "struct:profile";
|
||||
pub(crate) const STRUCT_TABLE: &str = "struct:table";
|
||||
pub(crate) const STRUCT_SCRIPT: &str = "struct:script";
|
||||
pub(crate) const STRUCT_VALIDATION: &str = "struct:validation";
|
||||
pub(crate) const STRUCT_ROLE: &str = "struct:role";
|
||||
pub(crate) const STRUCT_USER: &str = "struct:user";
|
||||
pub(crate) const MANAGE: &str = "manage";
|
||||
|
||||
pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool {
|
||||
permissions_permit(&snapshot.permissions, object, action)
|
||||
}
|
||||
|
||||
pub(crate) fn permissions_permit(
|
||||
permissions: &[Permission],
|
||||
object: &str,
|
||||
action: &str,
|
||||
) -> bool {
|
||||
permissions
|
||||
.iter()
|
||||
.any(|permission| permission.action == action && object_matches(&permission.object, object))
|
||||
}
|
||||
|
||||
pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool {
|
||||
permits(snapshot, area, MANAGE)
|
||||
}
|
||||
|
||||
pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
[
|
||||
STRUCT_PROFILE,
|
||||
STRUCT_TABLE,
|
||||
STRUCT_SCRIPT,
|
||||
STRUCT_VALIDATION,
|
||||
STRUCT_ROLE,
|
||||
STRUCT_USER,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|area| can_manage(snapshot, area))
|
||||
}
|
||||
|
||||
pub(crate) fn table_object(profile: &str, table: &str) -> String {
|
||||
format!("data:{profile}/{table}")
|
||||
}
|
||||
|
||||
pub(crate) fn permits_table(
|
||||
snapshot: &AuthorizationSnapshot,
|
||||
profile: &str,
|
||||
table: &str,
|
||||
action: &str,
|
||||
) -> bool {
|
||||
permits(snapshot, &table_object(profile, table), action)
|
||||
}
|
||||
|
||||
pub(crate) fn is_direct_permission(permissions: &[Permission], object: &str, action: &str) -> bool {
|
||||
permissions
|
||||
.iter()
|
||||
.any(|permission| permission.object == object && permission.action == action)
|
||||
}
|
||||
|
||||
fn object_matches(pattern: &str, object: &str) -> bool {
|
||||
match pattern.strip_suffix('*') {
|
||||
Some(prefix) => object.starts_with(prefix),
|
||||
None => pattern == object,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot(permissions: &[(&str, &str)]) -> AuthorizationSnapshot {
|
||||
AuthorizationSnapshot {
|
||||
role: "bookkeeper".to_string(),
|
||||
permissions: permissions
|
||||
.iter()
|
||||
.map(|(object, action)| Permission {
|
||||
object: (*object).to_string(),
|
||||
action: (*action).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_access_comes_from_permissions_not_role_names() {
|
||||
let authorization = snapshot(&[(STRUCT_TABLE, MANAGE)]);
|
||||
assert!(can_manage(&authorization, STRUCT_TABLE));
|
||||
assert!(can_open_admin(&authorization));
|
||||
assert!(!can_manage(&authorization, STRUCT_ROLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_wildcards_match_the_server_object_shapes() {
|
||||
let global = snapshot(&[("data:*", "read")]);
|
||||
assert!(permits_table(&global, "acme", "invoice", "read"));
|
||||
|
||||
let profile = snapshot(&[("data:acme/*", "insert")]);
|
||||
assert!(permits_table(&profile, "acme", "invoice", "insert"));
|
||||
assert!(!permits_table(&profile, "other", "invoice", "insert"));
|
||||
|
||||
let table = snapshot(&[("data:acme/invoice", "delete")]);
|
||||
assert!(permits_table(&table, "acme", "invoice", "delete"));
|
||||
assert!(!permits_table(&table, "acme", "customer", "delete"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod pages;
|
||||
mod schema;
|
||||
mod services;
|
||||
mod ui;
|
||||
mod authz;
|
||||
mod analytics {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
@@ -138,6 +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::admin::table_definition::router())
|
||||
.merge(pages::add_table::router())
|
||||
.merge(pages::add_logic::router())
|
||||
@@ -244,8 +246,17 @@ mod tests {
|
||||
assert!(!body.contains("hx-post=\"/logout\""));
|
||||
}
|
||||
|
||||
/// The register form carries every `RegisterRequest` field the TUI client
|
||||
/// asks for, and the suggestion lists for the three the client suggests.
|
||||
#[tokio::test]
|
||||
async fn fresh_installation_can_open_the_initial_password_form() {
|
||||
let (status, body) = get("/initial-password").await;
|
||||
assert!(status.is_success());
|
||||
assert!(body.contains("Claim bootstrap administrator"));
|
||||
assert!(body.contains("value=\"admin\""));
|
||||
assert!(body.contains("value=\"superadmin\""));
|
||||
}
|
||||
|
||||
/// The register form carries every user-provided `RegisterRequest` field.
|
||||
/// Role assignment belongs to the administrator permissions page.
|
||||
#[tokio::test]
|
||||
async fn the_register_form_offers_the_same_fields_as_the_client() {
|
||||
let (_, body) = get("/register").await;
|
||||
@@ -254,7 +265,6 @@ mod tests {
|
||||
"email",
|
||||
"password",
|
||||
"password_confirmation",
|
||||
"role",
|
||||
"timezone",
|
||||
"phone_country",
|
||||
] {
|
||||
@@ -263,7 +273,7 @@ mod tests {
|
||||
"the register form is missing the {field} field"
|
||||
);
|
||||
}
|
||||
assert!(body.contains("value=\"accountant\""));
|
||||
assert!(!body.contains("name=\"role\""));
|
||||
assert!(body.contains("value=\"Europe/Bratislava\""));
|
||||
assert!(body.contains("value=\"SK\""));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,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_SCRIPT) {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) async fn load_page(
|
||||
})
|
||||
.collect();
|
||||
Ok(AddLogicPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
tables,
|
||||
form,
|
||||
error,
|
||||
|
||||
@@ -66,7 +66,7 @@ fn render_loaded(result: Result<super::state::AddLogicPageState, LoadError>) ->
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_submission_error("Administrator access is required.")),
|
||||
Html(ui::render_submission_error("Script-management permission is required.")),
|
||||
)
|
||||
.into_response(),
|
||||
Err(LoadError::Backend(message)) => (
|
||||
|
||||
@@ -33,7 +33,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);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) async fn load_page(
|
||||
}
|
||||
|
||||
Ok(AddTablePageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
profiles: tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
|
||||
@@ -100,7 +100,10 @@ pub(crate) async fn create_table(
|
||||
let mut definitions = state.definitions;
|
||||
match definitions.post_table_definition(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let location = format!("/admin?profile={profile_name}");
|
||||
let location = format!(
|
||||
"/admin/table-definition?profile={profile_name}&table={}",
|
||||
page.draft.table_name
|
||||
);
|
||||
let Ok(location) = HeaderValue::try_from(location) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
@@ -153,7 +156,7 @@ fn load_error_response(error: LoadError) -> Response {
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_submission_error(
|
||||
"Administrator access is required.",
|
||||
"Table-management permission is required.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
@@ -27,7 +27,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_VALIDATION) {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
let mut definitions = state.definitions;
|
||||
@@ -43,7 +43,7 @@ pub(crate) async fn load_page(
|
||||
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
|
||||
.collect();
|
||||
Ok(ValidationPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
profiles,
|
||||
tables,
|
||||
form,
|
||||
|
||||
@@ -127,7 +127,7 @@ fn render_set_loaded(
|
||||
Html(ui::render_set_page(nav, &form, error.as_deref())).into_response()
|
||||
}
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
|
||||
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(),
|
||||
Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ fn render_loaded(result: Result<super::state::ValidationPageState, LoadError>) -
|
||||
match result {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
|
||||
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Validation-management permission is required."))).into_response(),
|
||||
Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod permissions;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
132
web/src/pages/admin/permissions/loader.rs
Normal file
132
web/src/pages/admin/permissions/loader.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
160
web/src/pages/admin/permissions/logic.rs
Normal file
160
web/src/pages/admin/permissions/logic.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
18
web/src/pages/admin/permissions/mod.rs
Normal file
18
web/src/pages/admin/permissions/mod.rs
Normal 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))
|
||||
}
|
||||
85
web/src/pages/admin/permissions/state.rs
Normal file
85
web/src/pages/admin/permissions/state.rs
Normal 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),
|
||||
}
|
||||
96
web/src/pages/admin/permissions/ui.rs
Normal file
96
web/src/pages/admin/permissions/ui.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
AnalyticsTable, ExecuteAnalyticsQueryRequest, GetAnalyticsCatalogRequest,
|
||||
GetAnalyticsCatalogResponse, analytics_value,
|
||||
},
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::common::Empty,
|
||||
services::authenticated_request,
|
||||
};
|
||||
@@ -23,12 +24,19 @@ pub(crate) enum LoadError {
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(state: AppState) -> Result<Vec<ProfileOption>, String> {
|
||||
pub(crate) async fn load_profiles(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Vec<ProfileOption>, LoadError> {
|
||||
let request = authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut definitions = state.definitions;
|
||||
let tree = definitions
|
||||
.get_profile_tree(tonic::Request::new(Empty {}))
|
||||
.get_profile_tree(request)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
Ok(tree
|
||||
.profiles
|
||||
@@ -40,6 +48,21 @@ pub(crate) async fn load_profiles(state: AppState) -> Result<Vec<ProfileOption>,
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_navigation(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> crate::ui::Nav {
|
||||
let Ok(request) = authenticated_request(headers, GetAuthorizationRequest {}) else {
|
||||
return crate::ui::Nav::new(headers, "analytics");
|
||||
};
|
||||
let mut auth = state.auth;
|
||||
match auth.get_authorization(request).await {
|
||||
Ok(response) => crate::ui::Nav::new(headers, "analytics")
|
||||
.with_authorization(response.get_ref()),
|
||||
Err(_) => crate::ui::Nav::new(headers, "analytics"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
ui::{Nav, Notice, render},
|
||||
ui::{Notice, render},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -16,14 +16,21 @@ use super::{
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn analytics_page(headers: HeaderMap) -> Html<String> {
|
||||
Html(ui::render_page(Nav::new(&headers, "analytics")))
|
||||
pub(crate) async fn analytics_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Html<String> {
|
||||
Html(ui::render_page(loader::load_navigation(state, &headers).await))
|
||||
}
|
||||
|
||||
pub(crate) async fn load_profiles(State(state): State<AppState>) -> Html<String> {
|
||||
match loader::load_profiles(state).await {
|
||||
pub(crate) async fn load_profiles(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Html<String> {
|
||||
match loader::load_profiles(state, &headers).await {
|
||||
Ok(profiles) => Html(ui::render_profile_options(&profiles)),
|
||||
Err(message) => Html(ui::render_profile_options_error(&message)),
|
||||
Err(LoadError::Unauthenticated) => Html(ui::render_profile_options_error("Sign in to list the profiles you may read.")),
|
||||
Err(LoadError::Backend(message)) => Html(ui::render_profile_options_error(&message)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
|
||||
pub(crate) struct Catalog {
|
||||
pub profiles: Vec<Profile>,
|
||||
pub authorization: crate::auth::AuthorizationSnapshot,
|
||||
}
|
||||
|
||||
pub(crate) struct Profile {
|
||||
@@ -19,6 +20,7 @@ pub(crate) struct Profile {
|
||||
pub(crate) async fn load_catalog(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
required_action: &str,
|
||||
) -> Result<Catalog, LoadError> {
|
||||
let request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
@@ -31,7 +33,10 @@ pub(crate) async fn load_catalog(
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
if authorization.role != "admin" {
|
||||
let has_required_permission = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == required_action && permission.object.starts_with("data:")
|
||||
});
|
||||
if !has_required_permission {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
let mut definitions = state.definitions;
|
||||
@@ -40,15 +45,66 @@ pub(crate) async fn load_catalog(
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
Ok(Catalog {
|
||||
profiles: tree
|
||||
let mut profiles = tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|profile| Profile {
|
||||
name: profile.name,
|
||||
tables: profile.tables.into_iter().map(|table| table.name).collect(),
|
||||
.filter_map(|profile| {
|
||||
let tables = profile
|
||||
.tables
|
||||
.into_iter()
|
||||
.filter(|table| {
|
||||
crate::authz::permits_table(
|
||||
&authorization,
|
||||
&profile.name,
|
||||
&table.name,
|
||||
required_action,
|
||||
)
|
||||
})
|
||||
.map(|table| table.name)
|
||||
.collect::<Vec<_>>();
|
||||
(!tables.is_empty()).then_some(Profile {
|
||||
name: profile.name,
|
||||
tables,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// An insert-only role is deliberately absent from GetProfileTree because
|
||||
// that listing is filtered by read permission. Exact table grants still
|
||||
// carry enough information to offer their target here. Wildcard-only
|
||||
// insert roles can type a target manually and the backend remains the
|
||||
// authoritative permission check.
|
||||
for permission in authorization
|
||||
.permissions
|
||||
.iter()
|
||||
.filter(|permission| permission.action == required_action)
|
||||
{
|
||||
let Some(target) = permission.object.strip_prefix("data:") else {
|
||||
continue;
|
||||
};
|
||||
let Some((profile_name, table_name)) = target.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if profile_name == "*" || table_name == "*" {
|
||||
continue;
|
||||
}
|
||||
if let Some(profile) = profiles
|
||||
.iter_mut()
|
||||
.find(|profile| profile.name == profile_name)
|
||||
{
|
||||
if !profile.tables.iter().any(|table| table == table_name) {
|
||||
profile.tables.push(table_name.to_string());
|
||||
}
|
||||
} else {
|
||||
profiles.push(Profile {
|
||||
name: profile_name.to_string(),
|
||||
tables: vec![table_name.to_string()],
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Catalog {
|
||||
profiles,
|
||||
authorization,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<ExportPageState, LoadError> {
|
||||
let catalog = load_catalog(state, headers, "read").await?;
|
||||
Ok(ExportPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin"),
|
||||
catalog: load_catalog(state, headers).await?,
|
||||
nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization),
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ pub(crate) async fn export_csv(
|
||||
fn load_error(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
|
||||
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Read permission is required for at least one table."))).into_response(),
|
||||
LoadError::Backend(message) => backend_error(&message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ pub(crate) async fn load_page(
|
||||
form: ImportForm,
|
||||
error: Option<String>,
|
||||
) -> Result<ImportPageState, LoadError> {
|
||||
let catalog = load_catalog(state, headers, "insert").await?;
|
||||
Ok(ImportPageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin"),
|
||||
catalog: load_catalog(state, headers).await?,
|
||||
nav: crate::ui::Nav::new(headers, "").with_authorization(&catalog.authorization),
|
||||
catalog,
|
||||
form,
|
||||
error,
|
||||
})
|
||||
|
||||
@@ -197,7 +197,7 @@ fn render_loaded(result: Result<super::state::ImportPageState, LoadError>) -> Re
|
||||
fn load_error(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
|
||||
LoadError::Forbidden => (StatusCode::FORBIDDEN, Html(ui::render_error("Insert permission is required for at least one readable table."))).into_response(),
|
||||
LoadError::Backend(message) => backend_error(&message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,72 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use tonic::Request;
|
||||
|
||||
use crate::{AppState, auth::LoginRequest, ui::Nav};
|
||||
use crate::{AppState, auth::{LoginRequest, SetInitialPasswordRequest}, services::reject_cross_site, ui::Nav};
|
||||
|
||||
use super::{state::LoginInput, ui};
|
||||
use super::{state::{InitialPasswordInput, LoginInput, LoginQuery}, ui};
|
||||
|
||||
pub(crate) async fn login_page(headers: HeaderMap) -> Html<String> {
|
||||
Html(ui::render_page(Nav::new(&headers, "login")))
|
||||
pub(crate) async fn login_page(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<LoginQuery>,
|
||||
) -> Html<String> {
|
||||
Html(ui::render_page(
|
||||
Nav::new(&headers, "login"),
|
||||
query.permissions_changed,
|
||||
query.initial_password_set,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn initial_password_page(headers: HeaderMap) -> Html<String> {
|
||||
Html(ui::render_initial_password_page(Nav::new(&headers, "login")))
|
||||
}
|
||||
|
||||
pub(crate) async fn set_initial_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(input): Form<InitialPasswordInput>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
let username = input.username.trim();
|
||||
if !matches!(username, "admin" | "superadmin") {
|
||||
return error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"Only the bootstrap admin or superadmin account can be claimed here.",
|
||||
);
|
||||
}
|
||||
let mut auth = state.auth;
|
||||
match auth
|
||||
.set_initial_password(tonic::Request::new(SetInitialPasswordRequest {
|
||||
username: username.to_string(),
|
||||
password: input.password,
|
||||
password_confirmation: input.password_confirmation,
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_static("/login?initial_password_set=1"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"hx-redirect",
|
||||
HeaderValue::from_static("/login?initial_password_set=1"),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(status) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_initial_password_error(status.message())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn login(
|
||||
@@ -49,9 +104,19 @@ pub(crate) async fn login(
|
||||
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("hx-redirect", HeaderValue::from_static("/admin"));
|
||||
let destination = if login
|
||||
.authorization
|
||||
.as_ref()
|
||||
.is_some_and(crate::authz::can_open_admin)
|
||||
{
|
||||
"/admin"
|
||||
} else {
|
||||
"/"
|
||||
};
|
||||
response.headers_mut().insert(
|
||||
"hx-redirect",
|
||||
HeaderValue::from_static(destination),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! GET /login → login.html
|
||||
//! POST /login → sets the session cookie, then hx-redirects to /admin
|
||||
//! POST /login → sets the session cookie, then redirects by authorization plane
|
||||
//!
|
||||
//! The matching POST /logout lives with the admin page, which owns the button.
|
||||
|
||||
@@ -12,5 +12,10 @@ pub(crate) mod state;
|
||||
pub(crate) mod ui;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new().route("/login", get(logic::login_page).post(logic::login))
|
||||
Router::new()
|
||||
.route("/login", get(logic::login_page).post(logic::login))
|
||||
.route(
|
||||
"/initial-password",
|
||||
get(logic::initial_password_page).post(logic::set_initial_password),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,3 +4,18 @@ pub(crate) struct LoginInput {
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
pub(crate) struct LoginQuery {
|
||||
#[serde(default)]
|
||||
pub permissions_changed: bool,
|
||||
#[serde(default)]
|
||||
pub initial_password_set: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct InitialPasswordInput {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub password_confirmation: String,
|
||||
}
|
||||
|
||||
@@ -7,13 +7,37 @@ use crate::ui::{Alert, Nav, render};
|
||||
#[template(path = "pages/login/login.html")]
|
||||
struct LoginPage {
|
||||
nav: Nav,
|
||||
permissions_changed: bool,
|
||||
initial_password_set: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(nav: Nav) -> String {
|
||||
render(&LoginPage { nav })
|
||||
pub(crate) fn render_page(
|
||||
nav: Nav,
|
||||
permissions_changed: bool,
|
||||
initial_password_set: bool,
|
||||
) -> String {
|
||||
render(&LoginPage {
|
||||
nav,
|
||||
permissions_changed,
|
||||
initial_password_set,
|
||||
})
|
||||
}
|
||||
|
||||
/// POST /login — the #login-status swap when the credentials are rejected.
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
render(&Alert::error("Could not sign in", message))
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/login/initial_password.html")]
|
||||
struct InitialPasswordPage {
|
||||
nav: Nav,
|
||||
}
|
||||
|
||||
pub(crate) fn render_initial_password_page(nav: Nav) -> String {
|
||||
render(&InitialPasswordPage { nav })
|
||||
}
|
||||
|
||||
pub(crate) fn render_initial_password_error(message: &str) -> String {
|
||||
render(&Alert::error("Could not claim the bootstrap account", message))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ pub(crate) const SESSION_COOKIE: &str = "analytics_token";
|
||||
pub(crate) struct Nav {
|
||||
pub authenticated: bool,
|
||||
pub role: String,
|
||||
pub can_admin: bool,
|
||||
pub can_import: bool,
|
||||
pub can_export: bool,
|
||||
pub active: &'static str,
|
||||
}
|
||||
|
||||
@@ -25,14 +28,25 @@ impl Nav {
|
||||
Self {
|
||||
authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(),
|
||||
role: String::new(),
|
||||
can_admin: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active,
|
||||
}
|
||||
}
|
||||
|
||||
/// The admin page is the only one that learns the caller's role, so it is
|
||||
/// the only one that can show the role badge.
|
||||
pub(crate) fn with_role(mut self, role: String) -> Self {
|
||||
self.role = role;
|
||||
pub(crate) fn with_authorization(
|
||||
mut self,
|
||||
authorization: &crate::auth::AuthorizationSnapshot,
|
||||
) -> Self {
|
||||
self.role = authorization.role.clone();
|
||||
self.can_admin = crate::authz::can_open_admin(authorization);
|
||||
self.can_import = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "insert" && permission.object.starts_with("data:")
|
||||
});
|
||||
self.can_export = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -42,6 +56,9 @@ impl Default for Nav {
|
||||
Self {
|
||||
authenticated: false,
|
||||
role: String::new(),
|
||||
can_admin: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
active: "",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user