import export rbac2

This commit is contained in:
Priec
2026-08-15 19:57:09 +02:00
parent e15b2681fd
commit 6c303b5b6f
12 changed files with 76 additions and 45 deletions

View File

@@ -14,8 +14,14 @@ pub(crate) const READ: &str = "read";
/// per-profile question.
pub(crate) const ALL_ECB: &str = "ecb:*";
/// Mirrors `server/src/auth/rbac/roles.rs`.
pub(crate) const SUPERADMIN: &str = "superadmin";
/// The two bulk-transfer actions, mirroring
/// `server/src/auth/rbac/objects.rs`. Taking a whole table out as a file is
/// its own grant rather than something `read` implies, and loading one in is
/// its own grant rather than something `insert` implies — so a superadmin can
/// hand a role the row access without the bulk transfer, or the other way
/// round.
pub(crate) const EXPORT: &str = "export";
pub(crate) const IMPORT: &str = "import";
pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool {
permissions_permit(&snapshot.permissions, object, action)
@@ -41,12 +47,15 @@ pub(crate) fn can_read_ecb(snapshot: &AuthorizationSnapshot) -> bool {
permits(snapshot, ALL_ECB, READ)
}
/// Whether the caller is the superadmin. Bulk data transfer — CSV import and
/// export — is theirs alone: it moves whole tables at once, which is the same
/// reach as the backup they already hold, and a data role's own grants say
/// nothing about whether it should have that.
pub(crate) fn is_superadmin(snapshot: &AuthorizationSnapshot) -> bool {
snapshot.role == SUPERADMIN
/// Whether the caller holds a transfer action on any table at all — what the
/// Import and Export links are shown for. Which tables those are is decided
/// per table by [`permits_table`], since a grant may name one table, one
/// profile, or everything.
pub(crate) fn can_transfer_anything(snapshot: &AuthorizationSnapshot, action: &str) -> bool {
snapshot
.permissions
.iter()
.any(|permission| permission.action == action && permission.object.starts_with("data:"))
}
pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {

View File

@@ -175,8 +175,8 @@ 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_import: crate::authz::is_superadmin(&authorization),
can_export: crate::authz::is_superadmin(&authorization),
can_import: crate::authz::can_transfer_anything(&authorization, crate::authz::IMPORT),
can_export: crate::authz::can_transfer_anything(&authorization, crate::authz::EXPORT),
can_ecb: crate::authz::can_read_ecb(&authorization),
})
}

View File

@@ -25,10 +25,19 @@ pub(crate) struct Profile {
pub tables: Vec<String>,
}
/// The tables one transfer page may offer.
///
/// `transfer_action` is the page's own grant — `export` or `import`.
/// `row_action` is what the transfer does underneath, `read` or `insert`, and
/// it is checked as well: the backend authorizes the row calls the transfer
/// makes, so a table offered on the strength of the transfer grant alone would
/// be refused halfway through the download. A table appears here only when the
/// role holds both.
pub(crate) async fn load_catalog(
state: AppState,
headers: &HeaderMap,
required_action: &str,
transfer_action: &str,
row_action: &str,
) -> Result<Catalog, LoadError> {
let request = authenticated_request(headers, GetAuthorizationRequest {})
.map_err(|_| LoadError::Unauthenticated)?;
@@ -43,9 +52,13 @@ pub(crate) async fn load_catalog(
.into_inner();
// Hiding the link is not the check: the route is refused here too, so a
// typed URL gets the same answer as a missing button.
if !crate::authz::is_superadmin(&authorization) {
if !crate::authz::can_transfer_anything(&authorization, transfer_action) {
return Err(LoadError::Forbidden);
}
let permits = |profile: &str, table: &str| {
crate::authz::permits_table(&authorization, profile, table, transfer_action)
&& crate::authz::permits_table(&authorization, profile, table, row_action)
};
let mut definitions = state.definitions;
let tree = definitions
.get_profile_tree(authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?)
@@ -72,9 +85,7 @@ pub(crate) async fn load_catalog(
)
.into_iter()
.map(|table| table.name)
.filter(|table| {
crate::authz::permits_table(&authorization, GLOBAL_SCOPE, table, required_action)
})
.filter(|table| permits(GLOBAL_SCOPE, table))
.collect::<Vec<_>>();
let locale = Locale::from_headers(headers);
@@ -91,14 +102,7 @@ pub(crate) async fn load_catalog(
.tables
.into_iter()
.filter(|table| !table.global)
.filter(|table| {
crate::authz::permits_table(
&authorization,
&profile.name,
&table.name,
required_action,
)
})
.filter(|table| permits(&profile.name, &table.name))
.map(|table| table.name)
.collect::<Vec<_>>();
(!tables.is_empty()).then_some(Profile {
@@ -108,15 +112,15 @@ pub(crate) async fn load_catalog(
})
}));
// An insert-only role is deliberately absent from GetProfileTree because
// that listing is filtered by read permission. Exact table grants still
// carry enough information to offer their target here. A wildcard-only
// insert role has no table name to offer, so its scope stays empty and the
// backend remains the authoritative permission check.
// A role that may import but not read is deliberately absent from
// GetProfileTree, because that listing is filtered by read permission.
// Exact table grants still carry enough information to offer their target
// here. A wildcard-only grant has no table name to offer, so its scope
// stays empty and the backend remains the authoritative permission check.
for permission in authorization
.permissions
.iter()
.filter(|permission| permission.action == required_action)
.filter(|permission| permission.action == transfer_action)
{
let Some(target) = permission.object.strip_prefix("data:") else {
continue;
@@ -127,6 +131,9 @@ pub(crate) async fn load_catalog(
if profile_name == "*" || table_name == "*" {
continue;
}
if !permits(profile_name, table_name) {
continue;
}
if let Some(profile) = profiles
.iter_mut()
.find(|profile| profile.name == profile_name)

View File

@@ -8,7 +8,7 @@ pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
) -> Result<ExportPageState, LoadError> {
let catalog = load_catalog(state, headers, "read").await?;
let catalog = load_catalog(state, headers, crate::authz::EXPORT, "read").await?;
Ok(ExportPageState {
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
catalog,

View File

@@ -13,7 +13,7 @@ pub(crate) async fn load_page(
form: ImportForm,
error: Option<String>,
) -> Result<ImportPageState, LoadError> {
let catalog = load_catalog(state, headers, "insert").await?;
let catalog = load_catalog(state, headers, crate::authz::IMPORT, "insert").await?;
Ok(ImportPageState {
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
catalog,

View File

@@ -4,7 +4,14 @@ use crate::{
};
/// The columns of every grant matrix, in the order the server lists them.
pub(crate) const ACTIONS: [&str; 4] = ["read", "insert", "update", "delete"];
pub(crate) const ACTIONS: [&str; 6] = [
"read",
"insert",
"update",
"delete",
"export",
"import",
];
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
@@ -81,7 +88,7 @@ pub(crate) struct Cell {
}
impl GrantsPage {
pub(crate) fn actions(&self) -> [&'static str; 4] {
pub(crate) fn actions(&self) -> [&'static str; ACTIONS.len()] {
ACTIONS
}
@@ -250,6 +257,8 @@ mod tests {
"data:acme/*|insert",
"data:acme/*|update",
"data:acme/*|delete",
"data:acme/*|export",
"data:acme/*|import",
"ecb:acme|read",
]
);

View File

@@ -91,6 +91,10 @@ pub(crate) fn starter_grants(
grants.push((object, action));
}
}
// Bulk transfer applies to tables, not to journals, which the
// transfer pages never offer.
grants.push(("data:*", "export"));
grants.push(("data:*", "import"));
Ok(grants)
}
other => Err(tr!(
@@ -122,7 +126,9 @@ mod tests {
}
assert_eq!(starter_grants(locale, "read").unwrap().len(), 3);
assert_eq!(starter_grants(locale, "full").unwrap().len(), 9);
// Four row actions on two wildcard objects, ECB read, and the two
// transfer actions, which apply to tables alone.
assert_eq!(starter_grants(locale, "full").unwrap().len(), 11);
}
#[test]

View File

@@ -154,10 +154,10 @@ impl Nav {
// panel: managing either roles or users is enough to open it.
nav.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE)
|| crate::authz::can_manage(authorization, crate::authz::STRUCT_USER);
// Both transfer pages are the superadmin's — see
// `crate::authz::is_superadmin`.
nav.can_import = crate::authz::is_superadmin(authorization);
nav.can_export = nav.can_import;
nav.can_import =
crate::authz::can_transfer_anything(authorization, crate::authz::IMPORT);
nav.can_export =
crate::authz::can_transfer_anything(authorization, crate::authz::EXPORT);
nav.can_ecb = crate::authz::can_read_ecb(authorization);
nav
}