use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Grant {
pub graph: String,
pub role: Role,
}
impl Grant {
#[must_use]
pub fn new(graph: impl Into<String>, role: Role) -> Self {
Self {
graph: graph.into(),
role,
}
}
}
#[derive(Debug, Clone)]
pub struct Identity {
user_id: String,
roles: HashSet<Role>,
grants: Vec<Grant>,
}
impl Identity {
#[must_use]
pub fn new(user_id: impl Into<String>, roles: impl IntoIterator<Item = Role>) -> Self {
Self {
user_id: user_id.into(),
roles: roles.into_iter().collect(),
grants: Vec::new(),
}
}
#[must_use]
pub fn anonymous() -> Self {
Self {
user_id: "anonymous".to_owned(),
roles: [Role::Admin].into_iter().collect(),
grants: Vec::new(),
}
}
#[must_use]
pub fn with_grants(mut self, grants: impl IntoIterator<Item = Grant>) -> Self {
self.grants = grants.into_iter().collect();
self
}
#[must_use]
pub fn user_id(&self) -> &str {
&self.user_id
}
#[must_use]
pub fn roles(&self) -> &HashSet<Role> {
&self.roles
}
#[must_use]
pub fn has_role(&self, role: Role) -> bool {
self.roles.contains(&role)
}
#[must_use]
pub fn can_read(&self) -> bool {
!self.roles.is_empty()
}
#[must_use]
pub fn can_write(&self) -> bool {
self.has_role(Role::Admin) || self.has_role(Role::ReadWrite)
}
#[must_use]
pub fn can_admin(&self) -> bool {
self.has_role(Role::Admin)
}
#[must_use]
pub fn grants(&self) -> &[Grant] {
&self.grants
}
#[must_use]
pub fn has_grants(&self) -> bool {
!self.grants.is_empty()
}
#[must_use]
pub fn can_access_graph(&self, graph: &str, required: Role) -> bool {
if self.grants.is_empty() {
return match required {
Role::ReadOnly => self.can_read(),
Role::ReadWrite => self.can_write(),
Role::Admin => self.can_admin(),
};
}
self.grants.iter().any(|g| {
g.graph.eq_ignore_ascii_case(graph)
&& match required {
Role::ReadOnly => true, Role::ReadWrite => g.role == Role::ReadWrite || g.role == Role::Admin,
Role::Admin => g.role == Role::Admin,
}
})
}
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.user_id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
Admin,
ReadWrite,
ReadOnly,
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Admin => write!(f, "Admin"),
Self::ReadWrite => write!(f, "ReadWrite"),
Self::ReadOnly => write!(f, "ReadOnly"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatementKind {
Read,
Write,
Admin,
Transaction,
}
impl StatementKind {
#[must_use]
pub fn required_role(self) -> Option<Role> {
match self {
Self::Read => Some(Role::ReadOnly),
Self::Write => Some(Role::ReadWrite),
Self::Admin => Some(Role::Admin),
Self::Transaction => None,
}
}
}
impl fmt::Display for StatementKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read => write!(f, "read"),
Self::Write => write!(f, "write"),
Self::Admin => write!(f, "admin"),
Self::Transaction => write!(f, "transaction control"),
}
}
}
pub(crate) fn check_permission(
identity: &Identity,
kind: StatementKind,
) -> std::result::Result<(), PermissionDenied> {
match kind {
StatementKind::Transaction => Ok(()),
StatementKind::Read => {
if identity.can_read() {
Ok(())
} else {
Err(PermissionDenied {
operation: kind,
required: Role::ReadOnly,
user_id: identity.user_id.clone(),
})
}
}
StatementKind::Write => {
if identity.can_write() {
Ok(())
} else {
Err(PermissionDenied {
operation: kind,
required: Role::ReadWrite,
user_id: identity.user_id.clone(),
})
}
}
StatementKind::Admin => {
if identity.can_admin() {
Ok(())
} else {
Err(PermissionDenied {
operation: kind,
required: Role::Admin,
user_id: identity.user_id.clone(),
})
}
}
}
}
#[derive(Debug, Clone)]
pub struct PermissionDenied {
pub operation: StatementKind,
pub required: Role,
pub user_id: String,
}
impl fmt::Display for PermissionDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"permission denied: {} operations require {} role (user: {})",
self.operation, self.required, self.user_id
)
}
}
impl std::error::Error for PermissionDenied {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anonymous_has_admin_role() {
let id = Identity::anonymous();
assert_eq!(id.user_id(), "anonymous");
assert!(id.has_role(Role::Admin));
assert!(id.can_read());
assert!(id.can_write());
assert!(id.can_admin());
}
#[test]
fn read_only_identity() {
let id = Identity::new("reader", [Role::ReadOnly]);
assert!(id.can_read());
assert!(!id.can_write());
assert!(!id.can_admin());
}
#[test]
fn read_write_identity() {
let id = Identity::new("writer", [Role::ReadWrite]);
assert!(id.can_read());
assert!(id.can_write());
assert!(!id.can_admin());
}
#[test]
fn admin_identity() {
let id = Identity::new("admin", [Role::Admin]);
assert!(id.can_read());
assert!(id.can_write());
assert!(id.can_admin());
}
#[test]
fn empty_roles_cannot_read() {
let id = Identity::new("nobody", std::iter::empty::<Role>());
assert!(!id.can_read());
assert!(!id.can_write());
assert!(!id.can_admin());
}
#[test]
fn role_display() {
assert_eq!(Role::Admin.to_string(), "Admin");
assert_eq!(Role::ReadWrite.to_string(), "ReadWrite");
assert_eq!(Role::ReadOnly.to_string(), "ReadOnly");
}
#[test]
fn statement_kind_required_role() {
assert_eq!(StatementKind::Read.required_role(), Some(Role::ReadOnly));
assert_eq!(StatementKind::Write.required_role(), Some(Role::ReadWrite));
assert_eq!(StatementKind::Admin.required_role(), Some(Role::Admin));
assert_eq!(StatementKind::Transaction.required_role(), None);
}
#[test]
fn check_permission_allows_transaction_for_all() {
let readonly = Identity::new("r", [Role::ReadOnly]);
assert!(check_permission(&readonly, StatementKind::Transaction).is_ok());
let nobody = Identity::new("n", std::iter::empty::<Role>());
assert!(check_permission(&nobody, StatementKind::Transaction).is_ok());
}
#[test]
fn check_permission_denies_write_for_readonly() {
let id = Identity::new("reader", [Role::ReadOnly]);
let err = check_permission(&id, StatementKind::Write).unwrap_err();
assert_eq!(err.required, Role::ReadWrite);
assert_eq!(err.operation, StatementKind::Write);
assert!(err.to_string().contains("permission denied"));
}
#[test]
fn check_permission_denies_admin_for_readwrite() {
let id = Identity::new("writer", [Role::ReadWrite]);
let err = check_permission(&id, StatementKind::Admin).unwrap_err();
assert_eq!(err.required, Role::Admin);
}
#[test]
fn identity_display() {
let id = Identity::new("app-service", [Role::ReadWrite]);
assert_eq!(id.to_string(), "app-service");
}
#[test]
fn identity_with_multiple_roles() {
let id = Identity::new("alix", [Role::ReadOnly, Role::ReadWrite]);
assert!(id.can_read());
assert!(id.can_write());
assert!(!id.can_admin());
assert!(id.has_role(Role::ReadOnly));
assert!(id.has_role(Role::ReadWrite));
assert!(!id.has_role(Role::Admin));
assert_eq!(id.roles().len(), 2);
}
#[test]
fn identity_with_all_roles() {
let id = Identity::new("gus", [Role::ReadOnly, Role::ReadWrite, Role::Admin]);
assert!(id.can_read());
assert!(id.can_write());
assert!(id.can_admin());
assert_eq!(id.roles().len(), 3);
}
#[test]
fn permission_denied_error_message_contains_user_and_role() {
let id = Identity::new("alix", [Role::ReadOnly]);
let err = check_permission(&id, StatementKind::Write).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("alix"), "error should contain user id");
assert!(
msg.contains("ReadWrite"),
"error should contain required role"
);
assert!(msg.contains("write"), "error should contain operation kind");
}
#[test]
fn permission_denied_admin_error_message() {
let id = Identity::new("gus", [Role::ReadOnly]);
let err = check_permission(&id, StatementKind::Admin).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("gus"));
assert!(msg.contains("Admin"));
assert!(msg.contains("admin"));
}
#[test]
fn check_permission_denies_read_for_no_roles() {
let id = Identity::new("nobody", std::iter::empty::<Role>());
let err = check_permission(&id, StatementKind::Read).unwrap_err();
assert_eq!(err.required, Role::ReadOnly);
assert_eq!(err.operation, StatementKind::Read);
assert!(err.to_string().contains("nobody"));
}
#[test]
fn check_permission_allows_read_for_readonly() {
let id = Identity::new("alix", [Role::ReadOnly]);
assert!(check_permission(&id, StatementKind::Read).is_ok());
}
#[test]
fn check_permission_allows_admin_for_admin() {
let id = Identity::new("gus", [Role::Admin]);
assert!(check_permission(&id, StatementKind::Admin).is_ok());
}
#[test]
fn check_permission_allows_write_for_readwrite() {
let id = Identity::new("alix", [Role::ReadWrite]);
assert!(check_permission(&id, StatementKind::Write).is_ok());
}
#[test]
fn statement_kind_display() {
assert_eq!(StatementKind::Read.to_string(), "read");
assert_eq!(StatementKind::Write.to_string(), "write");
assert_eq!(StatementKind::Admin.to_string(), "admin");
assert_eq!(
StatementKind::Transaction.to_string(),
"transaction control"
);
}
#[test]
fn permission_denied_is_std_error() {
let id = Identity::new("alix", [Role::ReadOnly]);
let err = check_permission(&id, StatementKind::Write).unwrap_err();
let _: &dyn std::error::Error = &err;
}
#[test]
fn no_grants_means_unrestricted() {
let id = Identity::new("alix", [Role::ReadWrite]);
assert!(id.can_access_graph("any_graph", Role::ReadWrite));
assert!(id.can_access_graph("other", Role::ReadOnly));
assert!(!id.has_grants());
}
#[test]
fn grant_restricts_to_listed_graphs() {
let id = Identity::new("gus", [Role::ReadWrite]).with_grants([
Grant::new("social", Role::ReadWrite),
Grant::new("analytics", Role::ReadOnly),
]);
assert!(id.has_grants());
assert!(id.can_access_graph("social", Role::ReadWrite));
assert!(id.can_access_graph("social", Role::ReadOnly));
assert!(id.can_access_graph("analytics", Role::ReadOnly));
assert!(!id.can_access_graph("analytics", Role::ReadWrite));
assert!(!id.can_access_graph("secret", Role::ReadOnly));
}
#[test]
fn grant_admin_implies_all() {
let id =
Identity::new("admin", [Role::Admin]).with_grants([Grant::new("prod", Role::Admin)]);
assert!(id.can_access_graph("prod", Role::Admin));
assert!(id.can_access_graph("prod", Role::ReadWrite));
assert!(id.can_access_graph("prod", Role::ReadOnly));
}
#[test]
fn grant_case_insensitive() {
let id = Identity::new("alix", [Role::ReadWrite])
.with_grants([Grant::new("Social", Role::ReadWrite)]);
assert!(id.can_access_graph("social", Role::ReadOnly));
assert!(id.can_access_graph("SOCIAL", Role::ReadWrite));
}
#[test]
fn grant_display() {
let g = Grant::new("social", Role::ReadWrite);
assert_eq!(g.graph, "social");
assert_eq!(g.role, Role::ReadWrite);
}
}