web permissions2
This commit is contained in:
2
client
2
client
Submodule client updated: 914d48cf39...1e3c9de431
@@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
|
||||
- **Role, user and permission administration** — `/admin/permissions` consumes
|
||||
- **Role, user and permission administration** — `/permissions` consumes
|
||||
`ListRoles`, `AddRole`, `RemoveRole`, `ListUsers`, `AssignUserRole`,
|
||||
`ListRolePermissions`, `ListGrantableObjects`, `GrantPermission` and
|
||||
`RevokePermission`. Direct and inherited grants are distinguished, and the
|
||||
@@ -37,6 +37,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Permissions moved out of the admin panel into their own nav section, split
|
||||
in three** — `/admin/permissions` is gone; the pages are `/permissions/roles`
|
||||
(which roles exist), `/permissions/users` (who holds them) and
|
||||
`/permissions/grants` (what a role may do). No new gRPC endpoint is consumed:
|
||||
the same `GrantPermission` and `RevokePermission` calls now come from one
|
||||
`POST /permissions/grants/apply`, which issues one call per `object|action`
|
||||
pair so a shortcut — a whole row, a whole profile, everything — is a longer
|
||||
list rather than a different request. Shortcuts grant the wildcard objects
|
||||
`ListGrantableObjects` already returns (`data:*`, `data:<profile>/*`,
|
||||
`journal:*`, `ecb:*`), so they keep covering profiles and tables created
|
||||
later, and `AddRole` may be followed by a starter set of those grants in the
|
||||
same submission. Revoking tolerates the `NOT_FOUND` a pair the role does not
|
||||
hold directly answers with, which is what makes "remove all of this"
|
||||
idempotent.
|
||||
- **Web authorization follows permission objects, not role names** — structural
|
||||
pages check their `struct:<area>/manage` permission from `GetAuthorization`,
|
||||
so inherited `superadmin` authorization works and data roles are no longer
|
||||
|
||||
@@ -18,7 +18,9 @@ cargo run -p server -- server
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:3000/login> to log in. The admin panel is at
|
||||
<http://127.0.0.1:3000/admin> and analytics remains at <http://127.0.0.1:3000>.
|
||||
<http://127.0.0.1:3000/admin>, roles and permissions are at
|
||||
<http://127.0.0.1:3000/permissions>, and analytics remains at
|
||||
<http://127.0.0.1:3000>.
|
||||
The access token is kept in an HTTP-only cookie. The default gRPC endpoint is
|
||||
`http://[::1]:50051`. Both addresses can be changed:
|
||||
|
||||
@@ -70,6 +72,15 @@ src/ templates/
|
||||
… export.html
|
||||
login/ login/
|
||||
mod logic state ui login.html
|
||||
permissions/ permissions/
|
||||
common/ tabs.html the section switcher
|
||||
loader logic state ui
|
||||
roles/ roles/
|
||||
mod loader logic state ui roles.html
|
||||
users/ users/
|
||||
mod loader logic state ui users.html
|
||||
grants/ grants/
|
||||
mod loader logic state ui grants.html
|
||||
|
||||
static/app.css the only stylesheet, at /static/app.css
|
||||
```
|
||||
@@ -101,6 +112,10 @@ endpoint in a comment on line 1.
|
||||
| `GET /admin/validation/sets/new` | `pages/add_validation/` | `set.html` |
|
||||
| `GET /admin/import` | `pages/import_export/import/` | `import.html` |
|
||||
| `GET /admin/export` | `pages/import_export/export/` | `export.html` |
|
||||
| `GET /permissions` | `pages/permissions/` | redirect to the first open section |
|
||||
| `GET /permissions/roles` | `pages/permissions/roles/` | `roles.html` |
|
||||
| `GET /permissions/users` | `pages/permissions/users/` | `users.html` |
|
||||
| `GET /permissions/grants` | `pages/permissions/grants/` | `grants.html` |
|
||||
|
||||
Every form `POST` answers with `ui/alert_fragment.html`, swapped into the page's
|
||||
`#submission-status`. Analytics errors use the lighter `ui/notice.html`.
|
||||
@@ -127,6 +142,36 @@ files include a table-name header row before the column-name row. Browser files
|
||||
are read locally and submitted to Axum; the backend is accessed only through the
|
||||
existing `TablesData` gRPC service.
|
||||
|
||||
## Permissions
|
||||
|
||||
Permissions are a nav section of their own, not a page inside the admin panel:
|
||||
managing people is a different job from designing tables, and the two are held
|
||||
by different accounts. The section is three pages, one per decision, with a tab
|
||||
bar between them:
|
||||
|
||||
- **`/permissions/roles`** — which roles exist, what they inherit from, how many
|
||||
people hold each, and removing the ones nobody holds. Creating a role can hand
|
||||
it starter access (`Read everything` or `Read and write everything`) in the
|
||||
same submission.
|
||||
- **`/permissions/users`** — who holds which role, and password resets. Only
|
||||
users whose role the caller outranks carry controls.
|
||||
- **`/permissions/grants`** — what a role may do, as one matrix per profile:
|
||||
objects down the side, `read / insert / update / delete` across the top. A
|
||||
cell is a grant held directly (click to revoke), an inherited one (changed on
|
||||
the parent role), or an empty one (click to grant).
|
||||
|
||||
Every button on the grants page — one cell, one row's `All`, a profile's
|
||||
`Full access`, the page's `Full access to everything` — posts the same form to
|
||||
`POST /permissions/grants/apply`, and differs only in the list of
|
||||
`object|action` pairs it carries. The lists are built in Rust, so the server is
|
||||
never asked to work out what "everything" meant, and the shortcuts grant the
|
||||
wildcard objects (`data:*`, `data:<profile>/*`, `journal:*`, `ecb:*`), which
|
||||
keeps them covering profiles and tables created later. `ecb:*` is read-only
|
||||
everywhere, because the server writes it.
|
||||
|
||||
The table-definition workspace still edits one table's grants in place; it posts
|
||||
to the same endpoint and returns to itself.
|
||||
|
||||
## Analytics
|
||||
|
||||
The first SQL result column is used for category labels. Bar and line charts use
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -178,6 +178,45 @@
|
||||
.danger-panel h2 { color: #a12b2b; }
|
||||
.form-actions button.danger-submit { background: #a12b2b; }
|
||||
|
||||
/* ---------- Permissions (pages/permissions) ---------- */
|
||||
|
||||
/* The three sections — roles, people, access — are separate pages, and this
|
||||
is the switcher between them, rendered by pages/permissions/tabs.html. */
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
|
||||
.tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; }
|
||||
.tab small { color: #7c8796; font-size: 11px; }
|
||||
.tab:hover { background: #f2f6fc; }
|
||||
.tab.selected { border-color: #a9c7f6; background: #eaf2ff; }
|
||||
.tab.selected span { font-weight: 700; color: #1d4ed8; }
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.link-action { color: #2563eb; text-decoration: none; }
|
||||
.notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; }
|
||||
|
||||
/* The shortcut buttons — read everything, full access, clear — sit above the
|
||||
matrix they fill in, so they are wider than the cell buttons and read as
|
||||
sentences rather than as symbols. */
|
||||
.shortcut-row { margin: 12px 0 4px; }
|
||||
.shortcut-row button.secondary { margin-top: 0; padding: 8px 14px; }
|
||||
|
||||
/* Objects down the side, actions across the top. Each cell is its own form,
|
||||
because a grant is one call to the server either way. */
|
||||
.grant-matrix td, .grant-matrix th { white-space: normal; }
|
||||
.grant-matrix th { text-transform: capitalize; }
|
||||
.grant-matrix .object-label { display: block; font-weight: 600; color: #24324a; }
|
||||
.grant-matrix td small { display: block; margin: 2px 0; color: #7c8796; }
|
||||
.grant-matrix td code { font-size: 11px; color: #5b6678; }
|
||||
.grant-cell { width: 82px; text-align: center; vertical-align: middle; }
|
||||
.grant-cell .actions { justify-content: center; gap: 4px; }
|
||||
.grant-cell form { margin: 0 auto; }
|
||||
.grant-cell button { min-width: 34px; padding: 4px 9px; border-radius: 5px; cursor: pointer; }
|
||||
button.cell-on { border: 1px solid #a9d3b4; color: #21643a; background: #eefaf1; }
|
||||
button.cell-on:hover { border-color: #e0b7b3; color: #a12b2b; background: #fdf3f3; }
|
||||
button.cell-off { border: 1px dashed #cdd5e0; color: #8a94a4; background: white; }
|
||||
button.cell-off:hover { border-style: solid; border-color: #a9c7f6; color: #1d4ed8; background: #eef3fe; }
|
||||
.cell-closed { color: #c3cad4; }
|
||||
.cell-inherited { color: #1d4ed8; }
|
||||
|
||||
/* ---------- Narrow screens ---------- */
|
||||
|
||||
@media (max-width: 850px) {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
|
||||
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
|
||||
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</a>{% endif %}
|
||||
{% if page.can_manage_permissions %}<a href="/admin/permissions">Permissions</a>{% endif %}
|
||||
{% if page.can_export %}<a href="/admin/export">Export</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Roles and permissions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Authorization</p>
|
||||
<h1>Roles and permissions</h1>
|
||||
<p>Manage data roles, their direct grants, inheritance, and user assignments.</p>
|
||||
</div>
|
||||
<div class="actions"><a href="/admin">← Admin panel</a></div>
|
||||
</section>
|
||||
|
||||
{% if page.updated %}<p class="notice">Administration updated. The current session remains valid and all subsequent requests use the new state.</p>{% endif %}
|
||||
|
||||
{% if page.can_manage_roles %}
|
||||
<section class="panel">
|
||||
<h2>Data roles</h2>
|
||||
<form method="get" action="/admin/permissions" class="form-grid">
|
||||
<label>Role
|
||||
<select name="role" onchange="this.form.submit()">
|
||||
{% for role in page.editable_roles() %}
|
||||
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → {{ role.parent }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
<form hx-post="/admin/permissions/roles" hx-target="#permission-status" class="form-grid">
|
||||
<label>New role<input name="name" required maxlength="50" placeholder="sales"></label>
|
||||
<label>Inherits from
|
||||
<select name="parent"><option value="">No parent</option>{% for role in page.editable_roles() %}<option value="{{ role.name }}">{{ role.name }}</option>{% endfor %}</select>
|
||||
</label>
|
||||
<button type="submit">Create role</button>
|
||||
</form>
|
||||
{% if page.selected_role_is_removable() %}
|
||||
<form hx-post="/admin/permissions/roles/remove" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ page.selected_role }}">
|
||||
<button type="submit" class="danger">Remove {{ page.selected_role }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Grants for {{ page.selected_role }}</h2>
|
||||
<p class="hint">Direct grants can be revoked here. “Inherited” permissions come from the role’s parent.</p>
|
||||
<div class="table-scroll">
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Object</th><th>Scope</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for object in page.grantable_objects %}
|
||||
<tr>
|
||||
<td><code>{{ object.object }}</code></td>
|
||||
<td>{{ object.kind }}{% if !object.profile.is_empty() %} · {{ object.profile }}{% endif %}{% if !object.table.is_empty() %} / {{ object.table }}{% endif %}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{% for action in object.allowed_actions %}
|
||||
{% if page.direct(object.object.as_str(), action.as_str()) %}
|
||||
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
|
||||
<button type="submit" class="danger">{{ action }} ✓</button>
|
||||
</form>
|
||||
{% else if page.effective(object.object.as_str(), action.as_str()) %}
|
||||
<span class="tag">{{ action }} · inherited</span>
|
||||
{% else %}
|
||||
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ page.selected_role }}"><input type="hidden" name="object" value="{{ object.object }}"><input type="hidden" name="action" value="{{ action }}">
|
||||
<button type="submit" class="secondary">Grant {{ action }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if page.can_manage_users %}
|
||||
<section class="panel">
|
||||
<h2>Users</h2>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Current role</th><th>Assign role</th><th>Reset password</th></tr></thead>
|
||||
<tbody>{% for user in page.users %}<tr>
|
||||
<td>{{ user.username }}</td><td>{{ user.email }}</td><td>{{ user.role }}</td>
|
||||
<td><form hx-post="/admin/permissions/users/role" hx-target="#permission-status" class="actions">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
<select name="role">{% for role in page.assignable_roles() %}<option value="{{ role.name }}" {% if user.role == role.name %}selected{% endif %}>{{ role.name }}</option>{% endfor %}</select>
|
||||
<button type="submit">Assign</button>
|
||||
</form></td>
|
||||
<td>{% if page.can_reset_password(user) %}<form hx-post="/admin/permissions/users/password" hx-target="#permission-status" class="actions">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
|
||||
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm password" required>
|
||||
<button type="submit">Reset</button>
|
||||
</form>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div id="permission-status" aria-live="polite"></div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -154,22 +154,26 @@
|
||||
{% if !page.permission_object.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">These grants cover this table family and take effect on subsequent requests without replacing the current session.</p>
|
||||
<p class="hint">
|
||||
These grants cover this table family and take effect on subsequent requests without replacing the
|
||||
current session. Wider access — a whole profile, or every table at once — is on the
|
||||
<a href="/permissions/grants">Permissions page</a>.
|
||||
</p>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for role in page.role_permissions %}<tr>
|
||||
<td>{{ role.role }}</td>
|
||||
<td><div class="actions">{% for permission in role.actions %}
|
||||
{% if permission.direct %}
|
||||
<form hx-post="/admin/permissions/revoke" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
|
||||
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
|
||||
<button type="submit" class="danger">{{ permission.action }} ✓</button>
|
||||
</form>
|
||||
{% else if permission.effective %}
|
||||
<span class="tag">{{ permission.action }} · inherited</span>
|
||||
{% else %}
|
||||
<form hx-post="/admin/permissions/grant" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="object" value="{{ page.permission_object }}"><input type="hidden" name="action" value="{{ permission.action }}">
|
||||
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
|
||||
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
140
web/templates/pages/permissions/grants/grants.html
Normal file
140
web/templates/pages/permissions/grants/grants.html
Normal file
@@ -0,0 +1,140 @@
|
||||
{#
|
||||
GET /permissions/grants — crate::pages::permissions::grants::ui::GrantsTemplate
|
||||
|
||||
One matrix per profile: objects down the side, actions across the top. Every
|
||||
button on the page — a single cell, a whole row, a whole profile, everything —
|
||||
posts the same form to /permissions/grants/apply and differs only in the list
|
||||
of object|action pairs it carries. The lists are built in Rust
|
||||
(grants::state), so the page never asks the server to work out what "all"
|
||||
meant.
|
||||
#}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Access{% endblock %}
|
||||
|
||||
{% macro apply(role, mode, pairs, label, style, hint) %}
|
||||
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role }}">
|
||||
<input type="hidden" name="mode" value="{{ mode }}">
|
||||
{% for pair in pairs %}<input type="hidden" name="pair" value="{{ pair }}">{% endfor %}
|
||||
<button type="submit" class="{{ style }}" title="{{ hint }}">{{ label }}</button>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Permissions</p>
|
||||
<h1>Access</h1>
|
||||
<p>What a role may do with the data. Everyone holding the role gets exactly this.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "pages/permissions/tabs.html" %}
|
||||
|
||||
{% if page.updated %}<p class="notice">Access updated. It applies to the next request every holder of the role makes.</p>{% endif %}
|
||||
|
||||
{% if page.roles.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>No role to edit</h2>
|
||||
<p class="hint">There is no role here you outrank. <a href="/permissions/roles">Create one</a> first.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Role</h2>
|
||||
<form method="get" action="/permissions/grants" class="form-grid">
|
||||
<label>Editing
|
||||
<select name="role" onchange="this.form.submit()">
|
||||
{% for role in page.roles %}
|
||||
<option value="{{ role.name }}" {% if page.selected(role.name.as_str()) %}selected{% endif %}>{{ role.name }}{% if !role.parent.is_empty() %} → inherits {{ role.parent }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
<p class="hint">
|
||||
{% if !page.selected_parent.is_empty() %}
|
||||
<strong>{{ page.selected_role }}</strong> inherits everything <strong>{{ page.selected_parent }}</strong> has. Inherited access shows below but is changed on the parent.
|
||||
{% else %}
|
||||
<strong>{{ page.selected_role }}</strong> inherits from nothing, so what you see below is all it has.
|
||||
{% endif %}
|
||||
{% if page.selected_is_structural %}
|
||||
This role designs the system and may never write row data, so only the read column is open.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<h3 class="panel-subhead">Shortcuts</h3>
|
||||
<p class="hint">These grant the wildcard objects, so they keep covering profiles and tables created later.</p>
|
||||
<div class="actions shortcut-row">
|
||||
{% call apply(page.selected_role, "grant", page.everything_read_pairs(), "Read everything", "secondary", "Read on every profile, journal and exchange rate") %}{% endcall %}
|
||||
{% call apply(page.selected_role, "grant", page.everything_pairs(), "Full access to everything", "secondary", "Every action the role may hold, on every profile") %}{% endcall %}
|
||||
{% if page.has_direct() %}
|
||||
{% call apply(page.selected_role, "revoke", page.direct_pairs(), "Remove all access", "danger", "Revoke every grant this role holds directly") %}{% endcall %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for group in page.groups %}
|
||||
<section class="panel">
|
||||
<h2>{% if group.global %}Everything, everywhere{% else %}Profile: {{ group.title }}{% endif %}<span class="count">{{ group.rows.len() }}</span></h2>
|
||||
<div class="actions shortcut-row">
|
||||
{% if group.has_wildcards() %}
|
||||
{% call apply(page.selected_role, "grant", group.read_pairs(), "Read only", "secondary", "Read on everything in here") %}{% endcall %}
|
||||
{% call apply(page.selected_role, "grant", group.all_pairs(), "Full access", "secondary", "Every action the role may hold, on everything in here") %}{% endcall %}
|
||||
{% endif %}
|
||||
{% if group.has_direct() %}
|
||||
{% call apply(page.selected_role, "revoke", group.direct_pairs(), "Clear", "danger", "Revoke everything this role holds directly in here") %}{% endcall %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="builder-table grant-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Object</th>
|
||||
{% for action in page.actions() %}<th>{{ action }}</th>{% endfor %}
|
||||
<th>Row</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in group.rows %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="object-label">{{ row.label }}</span>
|
||||
{% if !row.note.is_empty() %}<small>{{ row.note }}</small>{% endif %}
|
||||
<code>{{ row.object }}</code>
|
||||
</td>
|
||||
{% for cell in row.cells %}
|
||||
<td class="grant-cell">
|
||||
{% if !cell.allowed %}
|
||||
<span class="cell-closed" title="Not available for this role">·</span>
|
||||
{% else if cell.direct %}
|
||||
{% call apply(page.selected_role, "revoke", row.one_pair(cell.action.as_str()), "✓", "cell-on", "Held directly — click to revoke") %}{% endcall %}
|
||||
{% else if cell.inherited %}
|
||||
<span class="cell-inherited" title="inherited: covered by the parent role or a wider grant">↑</span>
|
||||
{% else %}
|
||||
{% call apply(page.selected_role, "grant", row.one_pair(cell.action.as_str()), "+", "cell-off", "Click to grant") %}{% endcall %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="grant-cell">
|
||||
<div class="actions">
|
||||
{% call apply(page.selected_role, "grant", row.all_pairs(), "All", "secondary", "Grant every action available on this object") %}{% endcall %}
|
||||
{% if row.has_direct() %}
|
||||
{% call apply(page.selected_role, "revoke", row.direct_pairs(), "None", "danger", "Revoke this object's direct grants") %}{% endcall %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div id="permission-status" aria-live="polite"></div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
84
web/templates/pages/permissions/roles/roles.html
Normal file
84
web/templates/pages/permissions/roles/roles.html
Normal file
@@ -0,0 +1,84 @@
|
||||
{# GET /permissions/roles — crate::pages::permissions::roles::ui::RolesTemplate #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Roles{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Permissions</p>
|
||||
<h1>Roles</h1>
|
||||
<p>A role is a named set of permissions. People are given roles; roles are given access.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "pages/permissions/tabs.html" %}
|
||||
|
||||
{% if page.updated %}<p class="notice">Roles updated. Signed-in sessions stay valid and pick up the change on their next request.</p>{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>New role</h2>
|
||||
<p class="hint">
|
||||
Inheriting from another role starts the new one with everything that role has, and keeps it in step
|
||||
as that role changes. Starter access is a shortcut for the two common cases; anything narrower is a
|
||||
few clicks on the Access tab.
|
||||
</p>
|
||||
<form hx-post="/permissions/roles/create" hx-target="#permission-status" class="form-grid">
|
||||
<label>Name<input name="name" required maxlength="50" placeholder="sales"></label>
|
||||
<label>Inherits from
|
||||
<select name="parent">
|
||||
<option value="">Nothing — starts empty</option>
|
||||
{% for parent in page.parents %}<option value="{{ parent }}">{{ parent }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Starter access
|
||||
<select name="access">
|
||||
<option value="none">None — grant it on the Access tab</option>
|
||||
<option value="read">Read everything</option>
|
||||
<option value="full">Read and write everything</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="form-actions"><button type="submit">Create role</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Existing roles<span class="count">{{ page.roles.len() }}</span></h2>
|
||||
<div class="table-scroll">
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Role</th><th>Kind</th><th>Inherits from</th><th>People</th><th>Access</th><th>Remove</th></tr></thead>
|
||||
<tbody>
|
||||
{% for role in page.roles %}
|
||||
<tr>
|
||||
<td>{{ role.name }}{% if role.built_in %} <span class="tag">built in</span>{% endif %}</td>
|
||||
<td>{{ role.kind }}</td>
|
||||
<td>{% if role.parent.is_empty() %}<span class="hint">—</span>{% else %}{{ role.parent }}{% endif %}</td>
|
||||
<td>{% match role.users %}{% when Some with (count) %}{{ count }}{% when None %}<span class="hint">—</span>{% endmatch %}</td>
|
||||
<td>
|
||||
{% if role.editable %}
|
||||
<a class="link-action" href="/permissions/grants?role={{ role.name }}">Edit access</a>
|
||||
{% else %}
|
||||
<span class="hint">outranks you</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if role.removable() %}
|
||||
<form hx-post="/permissions/roles/remove" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.name }}">
|
||||
<button type="submit" class="danger">Remove</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="hint">{{ role.keeps_reason() }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="permission-status" aria-live="polite"></div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
26
web/templates/pages/permissions/tabs.html
Normal file
26
web/templates/pages/permissions/tabs.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{#
|
||||
The section switcher shared by the three permission pages. Every one of them
|
||||
carries a `page.tabs` (crate::pages::permissions::common::state::Tabs), which
|
||||
says which section is open and which the signed-in role may open at all.
|
||||
|
||||
The three are deliberately separate pages: making a role, handing it to
|
||||
someone, and deciding what it may do are three different decisions, and doing
|
||||
them on one screen was what made the old page hard to read.
|
||||
#}
|
||||
<nav class="tabs" aria-label="permission sections">
|
||||
{% if page.tabs.can_manage_roles %}
|
||||
<a href="/permissions/roles" class="tab {% if page.tabs.is("roles") %}selected{% endif %}" {% if page.tabs.is("roles") %}aria-current="page"{% endif %}>
|
||||
<span>Roles</span><small>Create roles and their inheritance</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page.tabs.can_manage_users %}
|
||||
<a href="/permissions/users" class="tab {% if page.tabs.is("users") %}selected{% endif %}" {% if page.tabs.is("users") %}aria-current="page"{% endif %}>
|
||||
<span>People</span><small>Give a user a role</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page.tabs.can_manage_roles %}
|
||||
<a href="/permissions/grants" class="tab {% if page.tabs.is("grants") %}selected{% endif %}" {% if page.tabs.is("grants") %}aria-current="page"{% endif %}>
|
||||
<span>Access</span><small>What a role may do</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
64
web/templates/pages/permissions/users/users.html
Normal file
64
web/templates/pages/permissions/users/users.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{# GET /permissions/users — crate::pages::permissions::users::ui::UsersTemplate #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}People{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Permissions</p>
|
||||
<h1>People</h1>
|
||||
<p>Give a user a role. What that role may do is decided once, on the Access tab.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "pages/permissions/tabs.html" %}
|
||||
|
||||
{% if page.updated %}<p class="notice">Updated. The person's next request uses the new role; they do not have to sign in again.</p>{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Users<span class="count">{{ page.users.len() }}</span></h2>
|
||||
<p class="hint">
|
||||
You can only change someone whose role you outrank — {{ page.editable_users() }} of {{ page.users.len() }} here.
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Change role</th><th>Reset password</th></tr></thead>
|
||||
<tbody>
|
||||
{% for user in page.users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{% if user.email.is_empty() %}<span class="hint">—</span>{% else %}{{ user.email }}{% endif %}</td>
|
||||
<td>{{ user.role }}</td>
|
||||
{% if user.editable %}
|
||||
<td>
|
||||
<form hx-post="/permissions/users/role" hx-target="#permission-status" class="actions">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
<select name="role">
|
||||
{% for role in page.roles %}<option value="{{ role }}" {% if user.role == role.as_str() %}selected{% endif %}>{{ role }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Assign</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/permissions/users/password" hx-target="#permission-status" class="actions">
|
||||
<input type="hidden" name="username" value="{{ user.username }}">
|
||||
<input name="new_password" type="password" autocomplete="new-password" placeholder="New password" required>
|
||||
<input name="new_password_confirmation" type="password" autocomplete="new-password" placeholder="Confirm" required>
|
||||
<button type="submit">Reset</button>
|
||||
</form>
|
||||
</td>
|
||||
{% else %}
|
||||
<td colspan="2"><span class="hint">outranks you</span></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="permission-status" aria-live="polite"></div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@
|
||||
<!-- Desktop Menu -->
|
||||
<ul class="hidden items-center gap-4 md:flex">
|
||||
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
|
||||
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
|
||||
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %}
|
||||
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %}
|
||||
@@ -46,6 +47,7 @@
|
||||
<!-- Mobile Menu -->
|
||||
<ul x-cloak x-show="mobileMenuIsOpen" x-transition:enter="transition motion-reduce:transition-none ease-out duration-300" x-transition:enter-start="-translate-y-full" x-transition:enter-end="translate-y-0" x-transition:leave="transition motion-reduce:transition-none ease-out duration-300" x-transition:leave-start="translate-y-0" x-transition:leave-end="-translate-y-full" id="mobileMenu" class="fixed max-h-svh overflow-y-auto inset-x-0 top-0 z-10 flex flex-col divide-y divide-outline rounded-b-radius border-b border-outline bg-surface-alt px-6 pb-6 pt-20 dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt md:hidden">
|
||||
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
|
||||
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
|
||||
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %}
|
||||
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user