use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use axum::{
Extension, Form, Json,
extract::{Path as UrlPath, Query, Request, State},
http::{HeaderMap, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect, Response},
};
use serde_json::{Value, json};
use crate::app::AppState;
use crate::control::{GroupAccess, LibraryRef};
use crate::error::AppError;
use crate::i18n::Lang;
use crate::ui::{self, LoginView};
pub fn random_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
getrandom::fill(&mut buf).expect("OS random number generator");
buf.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Access {
pub write: bool,
}
#[derive(Debug, Default, Clone)]
pub struct KeySource {
pub manifest: Option<String>,
pub file: Option<PathBuf>,
pub key: Option<String>,
pub generated: PathBuf,
}
#[derive(Debug)]
pub struct Keys {
map: HashMap<String, Access>,
login_key: String,
}
impl Keys {
pub fn new(map: HashMap<String, Access>) -> Result<Self, String> {
let login_key = map
.iter()
.filter(|(_, a)| a.write)
.map(|(k, _)| k.clone())
.min()
.ok_or("at least one read/write key must be configured")?;
Ok(Keys { map, login_key })
}
pub fn load(source: &KeySource) -> Result<(Self, Option<PathBuf>), String> {
let mut map = HashMap::new();
let mut generated = None;
if let Some(manifest) = source.manifest.as_deref().filter(|m| !m.trim().is_empty()) {
for entry in manifest.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let (role, path) = entry
.split_once(':')
.ok_or_else(|| format!("key entry is not <rw|ro>:<path>: {entry}"))?;
let write = match role {
"rw" => true,
"ro" => false,
other => return Err(format!("unknown key role '{other}' (rw or ro)")),
};
map.insert(read_token(Path::new(path))?, Access { write });
}
} else if let Some(file) = &source.file {
map.insert(read_token(file)?, Access { write: true });
} else if let Some(key) = source.key.as_deref().filter(|k| !k.trim().is_empty()) {
map.insert(key.trim().to_string(), Access { write: true });
} else {
let (token, created) = generated_token(&source.generated)?;
if created {
generated = Some(source.generated.clone());
}
map.insert(token, Access { write: true });
}
Ok((Keys::new(map)?, generated))
}
pub fn access(&self, token: &str) -> Option<Access> {
self.map.get(token).copied()
}
pub fn login_key(&self) -> &str {
&self.login_key
}
pub fn short_keys(&self) -> usize {
self.map.keys().filter(|k| k.len() < MIN_KEY_LENGTH).count()
}
}
fn read_token(path: &Path) -> Result<String, String> {
let token = std::fs::read_to_string(path)
.map_err(|e| format!("read key file {}: {e}", path.display()))?
.trim()
.to_string();
if token.is_empty() {
return Err(format!("key file {} is empty", path.display()));
}
Ok(token)
}
pub fn generated_token(path: &Path) -> Result<(String, bool), String> {
if path.exists() {
return read_token(path).map(|t| (t, false));
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
}
let token = random_hex(32);
std::fs::write(path, format!("{token}\n"))
.map_err(|e| format!("write {}: {e}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("chmod {}: {e}", path.display()))?;
}
Ok((token, true))
}
pub fn request_key(headers: &HeaderMap) -> Option<&str> {
headers
.get("zotero-api-key")
.and_then(|v| v.to_str().ok())
.or_else(|| {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
})
.map(str::trim)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target<'a> {
None,
User(&'a str),
Group(&'a str),
}
pub fn target(path: &str) -> Target<'_> {
let path = path.strip_prefix("/api").unwrap_or(path);
let mut segments = path.trim_start_matches('/').split('/');
match (segments.next(), segments.next()) {
(Some("users"), Some(id)) => Target::User(id),
(Some("groups"), Some(id)) => Target::Group(id),
_ => Target::None,
}
}
fn file_upload(method: &Method, path: &str) -> bool {
method == Method::POST && path.ends_with("/file") && path.contains("/items/")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Principal {
pub user_id: u64,
pub access: Access,
pub key_id: Option<i64>,
}
pub async fn principal(app: &AppState, key: &str) -> Result<Option<Principal>, AppError> {
if let Some(access) = app.keys.access(key) {
return Ok(Some(Principal {
user_id: app.cfg.user_id,
access,
key_id: None,
}));
}
if key.is_empty() {
return Ok(None);
}
Ok(app.control.key_owner(key).await?.map(|owner| Principal {
user_id: owner.user_id,
access: Access { write: owner.write },
key_id: Some(owner.key_id),
}))
}
pub fn decide(
principal: &Principal,
method: &Method,
path: &str,
group: Option<GroupAccess>,
) -> Result<Option<LibraryRef>, (StatusCode, &'static str)> {
let mutating = matches!(
*method,
Method::POST | Method::PUT | Method::PATCH | Method::DELETE
);
let unlink = method == Method::DELETE && path.ends_with("/keys/current");
if mutating && !principal.access.write && !unlink {
return Err((StatusCode::FORBIDDEN, "Write access denied"));
}
match target(path) {
Target::None => Ok(None),
Target::User("0") => Ok(Some(LibraryRef::User(principal.user_id))),
Target::User(id) if id == principal.user_id.to_string() => {
Ok(Some(LibraryRef::User(principal.user_id)))
}
Target::User(_) => Err((StatusCode::FORBIDDEN, "Forbidden")),
Target::Group(id) => {
let (Some(access), Ok(group_id)) = (group, id.parse::<u64>()) else {
return Err((StatusCode::FORBIDDEN, "Forbidden"));
};
if id != group_id.to_string() {
return Err((StatusCode::FORBIDDEN, "Forbidden"));
}
if mutating && !access.write {
return Err((StatusCode::FORBIDDEN, "Write access denied"));
}
if file_upload(method, path) && !access.files {
return Err((StatusCode::FORBIDDEN, "File editing denied"));
}
Ok(Some(LibraryRef::Group(group_id)))
}
}
}
pub async fn require_key(State(app): State<AppState>, mut req: Request, next: Next) -> Response {
let key = request_key(req.headers()).unwrap_or_default().to_string();
let principal = match principal(&app, &key).await {
Ok(Some(principal)) => principal,
Ok(None) => return (StatusCode::FORBIDDEN, "Invalid key").into_response(),
Err(error) => return error.into_response(),
};
let group = match target(req.uri().path()) {
Target::Group(id) => match id.parse::<u64>() {
Ok(group_id) => match app.control.group_access(group_id, principal.user_id).await {
Ok(access) => access,
Err(error) => return error.into_response(),
},
Err(_) => None,
},
_ => None,
};
let library = match decide(&principal, req.method(), req.uri().path(), group) {
Ok(Some(library)) => library,
Ok(None) => return next.run(req).await,
Err(refused) => return refused.into_response(),
};
let library = match app.libraries.get(library).await {
Ok(library) => library,
Err(error) => return error.into_response(),
};
let group_json = matches!(target(req.uri().path()), Target::Group(_))
&& req.uri().path().trim_end_matches('/').rsplit('/').nth(1) == Some("groups");
req.extensions_mut().insert(library.clone());
let who = match library.id {
LibraryRef::Group(_) => match names(&app, principal.user_id).await {
Ok((username, name)) => Some(crate::store::Actor {
id: principal.user_id,
username,
name,
}),
Err(error) => return error.into_response(),
},
LibraryRef::User(_) => None,
};
req.extensions_mut().insert(crate::zotero::Attribution(who));
req.extensions_mut().insert(principal);
if let Some(access) = group {
req.extensions_mut().insert(access);
}
let mut response = next.run(req).await;
if group_json {
return response;
}
if !library.epoch.is_empty()
&& let Ok(epoch) = axum::http::HeaderValue::from_str(&library.epoch)
{
response
.headers_mut()
.insert("x-defter-library-epoch", epoch);
}
let served = response
.headers()
.get("last-modified-version")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
if let Some(version) = served
&& let Err(error) = app.libraries.served(&library, version).await
{
tracing::warn!(%error, library = %library.id, version, "recording the version served");
}
response
}
pub struct Tokens<T> {
ttl: Duration,
map: Mutex<HashMap<String, (Instant, T)>>,
}
impl<T: Clone> Tokens<T> {
pub fn new(ttl: Duration) -> Self {
Tokens {
ttl,
map: Mutex::new(HashMap::new()),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, (Instant, T)>> {
self.map.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn insert(&self, value: T) -> (String, Vec<(String, T)>) {
let token = random_hex(16);
let mut map = self.lock();
let expired_keys: Vec<String> = map
.iter()
.filter(|(_, (created, _))| created.elapsed() >= self.ttl)
.map(|(k, _)| k.clone())
.collect();
let expired = expired_keys
.into_iter()
.filter_map(|k| map.remove(&k).map(|(_, v)| (k, v)))
.collect();
map.insert(token.clone(), (Instant::now(), value));
(token, expired)
}
pub fn get(&self, token: &str) -> Option<T> {
self.lock()
.get(token)
.filter(|(created, _)| created.elapsed() < self.ttl)
.map(|(_, v)| v.clone())
}
pub fn update(&self, token: &str, f: impl FnOnce(&mut T)) -> bool {
match self.lock().get_mut(token) {
Some((created, value)) if created.elapsed() < self.ttl => {
f(value);
true
}
_ => false,
}
}
#[cfg(test)]
pub fn backdate(&self, token: &str, age: Duration) {
if let Some((created, _)) = self.lock().get_mut(token) {
*created = Instant::now() - age;
}
}
pub fn remove(&self, token: &str) -> Option<T> {
self.lock().remove(token).map(|(_, v)| v)
}
pub fn retain(&self, keep: impl Fn(&T) -> bool) {
self.lock().retain(|_, (_, value)| keep(value));
}
}
#[derive(Debug, Clone, Default)]
pub struct LoginSession {
pub approved: Option<Approval>,
}
#[derive(Debug, Clone)]
pub struct Approval {
pub key: String,
pub user_id: u64,
}
#[derive(Debug, Clone)]
pub struct UiSession {
pub user_id: u64,
pub access: Access,
pub id_token: Option<String>,
}
pub const WRONG_KEY_DELAY: Duration = Duration::from_secs(1);
pub const UI_SESSION_COOKIE: &str = "defter_session";
pub const UI_SESSION_TTL: Duration = Duration::from_secs(24 * 3600);
pub fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(n, _)| *n == name)
.map(|(_, value)| value)
}
pub fn foreign_origin(headers: &HeaderMap, public_url: &str) -> bool {
headers
.get("origin")
.and_then(|v| v.to_str().ok())
.is_some_and(|origin| origin.trim_end_matches('/') != public_origin(public_url))
}
pub const MIN_KEY_LENGTH: usize = 32;
pub const SESSION_TTL: Duration = Duration::from_secs(600);
pub async fn names(app: &AppState, user_id: u64) -> Result<(String, String), AppError> {
let user = app
.control
.user(user_id)
.await?
.ok_or_else(|| AppError::NotFound("no such user".into()))?;
Ok((user.username.clone(), user.name().to_string()))
}
pub async fn key_current(
State(app): State<AppState>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let key = request_key(&headers).unwrap_or_default();
let principal = principal(&app, key)
.await?
.ok_or_else(|| AppError::NotFound("no such key".into()))?;
let (username, display_name) = names(&app, principal.user_id).await?;
let write = principal.access.write;
Ok(Json(json!({
"key": key,
"userID": principal.user_id,
"username": username,
"displayName": display_name,
"access": {
"user": { "library": true, "files": true, "notes": true, "write": write },
"groups": { "all": { "library": true, "write": write } }
},
}))
.into_response())
}
pub async fn key_delete(
State(app): State<AppState>,
headers: HeaderMap,
) -> Result<StatusCode, AppError> {
let key = request_key(&headers).unwrap_or_default();
if let Some(Principal {
user_id,
key_id: Some(key_id),
..
}) = principal(&app, key).await?
{
app.control.revoke_key(user_id, key_id).await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn create_session(State(app): State<AppState>) -> Response {
let (token, _) = app.sessions.insert(LoginSession::default());
(
StatusCode::CREATED,
Json(json!({
"sessionToken": token,
"loginURL": format!("{}/login?session={token}", app.cfg.public_url),
})),
)
.into_response()
}
pub async fn check_session(
State(app): State<AppState>,
UrlPath(token): UrlPath<String>,
) -> Result<Json<Value>, AppError> {
let approved = app.sessions.get(&token).and_then(|s| s.approved);
Ok(match approved {
Some(approval) => {
let (username, _) = names(&app, approval.user_id).await?;
app.sessions.remove(&token);
Json(json!({
"status": "completed",
"apiKey": approval.key,
"userID": approval.user_id,
"username": username,
}))
}
None => Json(json!({ "status": "pending" })),
})
}
pub async fn cancel_session(
State(app): State<AppState>,
UrlPath(token): UrlPath<String>,
) -> StatusCode {
app.sessions.remove(&token);
StatusCode::NO_CONTENT
}
pub async fn login_page(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
if params.get("approved").is_some_and(|v| v == "1") {
return ui::login_page(StatusCode::OK, LoginView::Approved, lang);
}
let Some(token) = params.get("session").filter(|t| !t.is_empty()) else {
return ui::login_page(StatusCode::BAD_REQUEST, LoginView::Missing, lang);
};
match app.sessions.get(token) {
None => ui::login_page(StatusCode::NOT_FOUND, LoginView::Expired, lang),
Some(session) if session.approved.is_some() => {
ui::login_page(StatusCode::OK, LoginView::Approved, lang)
}
Some(_) => ui::login_page(
StatusCode::OK,
LoginView::Pending {
token,
error: None,
signed_in: signed_in_through_oidc(&app, &headers).await.is_some(),
oidc: app.cfg.oidc.as_ref().map(|o| o.name.as_str()),
key_login: app.cfg.key_login,
},
lang,
),
}
}
async fn signed_in_through_oidc(app: &AppState, headers: &HeaderMap) -> Option<u64> {
let user_id = cookie(headers, UI_SESSION_COOKIE)
.and_then(|token| app.ui_sessions.get(token))
.filter(|session| session.id_token.is_some())
.map(|session| session.user_id)?;
match app.control.user(user_id).await {
Ok(Some(user)) if !user.disabled => Some(user_id),
Ok(_) => None,
Err(error) => {
tracing::warn!(%error, "looking up the signed-in user");
None
}
}
}
pub async fn login_authorize(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let t = lang.pages();
if foreign_origin(&headers, &app.cfg.public_url) {
return ui::login_page(
StatusCode::FORBIDDEN,
LoginView::Refused(t.approval_from_another_site),
lang,
);
}
if let Some(want) = &app.cfg.login_authorized_user {
let identity = [
"x-auth-request-email",
"x-forwarded-email",
"x-auth-request-user",
"x-forwarded-user",
]
.iter()
.find_map(|h| headers.get(*h))
.and_then(|v| v.to_str().ok());
if !identity.is_some_and(|got| got.eq_ignore_ascii_case(want)) {
return ui::login_page(
StatusCode::FORBIDDEN,
LoginView::Refused(t.not_allowed_to_approve),
lang,
);
}
}
let Some(token) = form.get("session") else {
return ui::login_page(StatusCode::BAD_REQUEST, LoginView::Missing, lang);
};
if app.sessions.get(token).is_none() {
return ui::login_page(StatusCode::NOT_FOUND, LoginView::Expired, lang);
}
if let Some(user_id) = signed_in_through_oidc(&app, &headers).await {
let name = format!(
"Zotero, {}",
crate::backup::utc_text(crate::control::now() as u64)
);
let key = match app.control.mint_key(user_id, name, true).await {
Ok(key) => key,
Err(error) => return error.into_response(),
};
let approval = Approval { key, user_id };
return if app.sessions.update(token, |s| s.approved = Some(approval)) {
Redirect::to("/login?approved=1").into_response()
} else {
ui::login_page(StatusCode::NOT_FOUND, LoginView::Expired, lang)
};
}
if !app.cfg.key_login {
return ui::login_page(
StatusCode::FORBIDDEN,
LoginView::Refused(t.sign_in_to_approve),
lang,
);
}
let key = form.get("key").map(|k| k.trim()).unwrap_or_default();
let error = match app.keys.access(key) {
Some(Access { write: true }) => None,
Some(Access { write: false }) => Some(t.key_only_reads),
None => Some(t.not_a_key),
};
if let Some(error) = error {
tracing::warn!("login approval with a wrong or read-only key");
tokio::time::sleep(WRONG_KEY_DELAY).await;
return ui::login_page(
StatusCode::FORBIDDEN,
LoginView::Pending {
token,
error: Some(error),
signed_in: false,
oidc: app.cfg.oidc.as_ref().map(|o| o.name.as_str()),
key_login: app.cfg.key_login,
},
lang,
);
}
let approval = Approval {
key: key.to_string(),
user_id: app.cfg.user_id,
};
if app.sessions.update(token, |s| s.approved = Some(approval)) {
Redirect::to("/login?approved=1").into_response()
} else {
ui::login_page(StatusCode::NOT_FOUND, LoginView::Expired, lang)
}
}
fn public_origin(public_url: &str) -> &str {
let url = public_url.trim_end_matches('/');
let after_scheme = url.find("://").map(|i| i + 3).unwrap_or(0);
match url[after_scheme..].find('/') {
Some(i) => &url[..after_scheme + i],
None => url,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_load_by_precedence() {
let dir = tempfile::tempdir().unwrap();
let rw = dir.path().join("rw");
let ro = dir.path().join("ro");
std::fs::write(&rw, "write-token\n").unwrap();
std::fs::write(&ro, "read-token").unwrap();
let source = KeySource {
manifest: Some(format!("rw:{},ro:{}", rw.display(), ro.display())),
key: Some("ignored".into()),
generated: dir.path().join("api-key"),
..Default::default()
};
let (keys, generated) = Keys::load(&source).unwrap();
assert!(generated.is_none());
assert_eq!(keys.access("write-token"), Some(Access { write: true }));
assert_eq!(keys.access("read-token"), Some(Access { write: false }));
assert_eq!(keys.access("ignored"), None);
assert_eq!(keys.login_key(), "write-token");
let only_ro = KeySource {
manifest: Some(format!("ro:{}", ro.display())),
..Default::default()
};
assert!(Keys::load(&only_ro).is_err());
}
#[test]
fn a_generated_key_is_kept() {
let dir = tempfile::tempdir().unwrap();
let source = KeySource {
generated: dir.path().join("sub/api-key"),
..Default::default()
};
let (first, generated) = Keys::load(&source).unwrap();
assert!(generated.is_some());
let (second, generated) = Keys::load(&source).unwrap();
assert!(generated.is_none());
assert_eq!(first.login_key(), second.login_key());
assert_eq!(first.login_key().len(), 64);
}
#[test]
fn tokens_expire() {
let tokens = Tokens::new(Duration::from_millis(0));
let (token, _) = tokens.insert(1);
assert_eq!(tokens.get(&token), None);
let (_, expired) = tokens.insert(2);
assert_eq!(expired, vec![(token, 1)]);
let tokens = Tokens::new(Duration::from_secs(60));
let (token, _) = tokens.insert(1);
assert!(tokens.update(&token, |v| *v = 2));
assert_eq!(tokens.remove(&token), Some(2));
assert!(!tokens.update(&token, |v| *v = 3));
}
#[test]
fn decides_who_may_do_what() {
let rw = Principal {
user_id: 7,
access: Access { write: true },
key_id: Some(1),
};
let ro = Principal {
user_id: 7,
access: Access { write: false },
key_id: None,
};
let own = Ok(Some(LibraryRef::User(7)));
let forbidden = Err((StatusCode::FORBIDDEN, "Forbidden"));
let read_only = Err((StatusCode::FORBIDDEN, "Write access denied"));
let cases = [
(rw, Method::GET, "/users/7/items", own),
(rw, Method::GET, "/api/users/0/items", own),
(rw, Method::POST, "/users/7/items", own),
(ro, Method::GET, "/users/7/items", own),
(ro, Method::POST, "/users/7/items", read_only),
(ro, Method::DELETE, "/users/0/items/AAAAAAAA", read_only),
(rw, Method::GET, "/users/8/items", forbidden),
(rw, Method::GET, "/users/07/items", forbidden),
(rw, Method::GET, "/users/07x/items", forbidden),
(rw, Method::GET, "/users//items", forbidden),
(ro, Method::GET, "/users/8/items", forbidden),
(rw, Method::GET, "/keys/current", Ok(None)),
(ro, Method::DELETE, "/keys/current", Ok(None)),
(ro, Method::DELETE, "/api/keys/current", Ok(None)),
];
for (principal, method, path, want) in cases {
assert_eq!(
decide(&principal, &method, path, None),
want,
"{method} {path}"
);
}
}
#[test]
fn decides_by_group_role_and_settings() {
use crate::control::{Group, Role};
let rw = Principal {
user_id: 7,
access: Access { write: true },
key_id: Some(1),
};
let ro = Principal {
access: Access { write: false },
..rw
};
let group = |library_editing: &str, file_editing: &str| Group {
id: 3,
name: "G".into(),
description: String::new(),
library_editing: library_editing.into(),
file_editing: file_editing.into(),
version: 1,
created_at: 0,
deleted: false,
};
let ok = Ok(Some(LibraryRef::Group(3)));
let forbidden = Err((StatusCode::FORBIDDEN, "Forbidden"));
let no_write = Err((StatusCode::FORBIDDEN, "Write access denied"));
let no_files = Err((StatusCode::FORBIDDEN, "File editing denied"));
let get = Method::GET;
let post = Method::POST;
let file = "/groups/3/items/AAAAAAAA/file";
let items = "/api/groups/3/items";
let cases = [
(
rw,
group("members", "members"),
None,
&get,
"/groups/3",
forbidden,
),
(
rw,
group("members", "members"),
None,
&get,
items,
forbidden,
),
(
rw,
group("members", "members"),
Some(Role::Member),
&get,
"/groups/3",
ok,
),
(
rw,
group("members", "members"),
Some(Role::Member),
&post,
items,
ok,
),
(
rw,
group("members", "members"),
Some(Role::Member),
&post,
file,
ok,
),
(
rw,
group("admins", "members"),
Some(Role::Member),
&get,
items,
ok,
),
(
rw,
group("admins", "members"),
Some(Role::Member),
&post,
items,
no_write,
),
(
rw,
group("admins", "members"),
Some(Role::Member),
&post,
file,
no_write,
),
(
rw,
group("admins", "members"),
Some(Role::Admin),
&post,
items,
ok,
),
(
rw,
group("admins", "admins"),
Some(Role::Owner),
&post,
file,
ok,
),
(
rw,
group("members", "admins"),
Some(Role::Member),
&post,
items,
ok,
),
(
rw,
group("members", "admins"),
Some(Role::Member),
&post,
file,
no_files,
),
(
rw,
group("members", "admins"),
Some(Role::Admin),
&post,
file,
ok,
),
(
rw,
group("members", "none"),
Some(Role::Owner),
&post,
file,
no_files,
),
(
rw,
group("members", "none"),
Some(Role::Owner),
&get,
file,
ok,
),
(
ro,
group("members", "members"),
Some(Role::Owner),
&get,
items,
ok,
),
(
ro,
group("members", "members"),
Some(Role::Owner),
&post,
items,
no_write,
),
];
for (principal, group, role, method, path, want) in cases {
let access = role.map(|r| group.access(r));
assert_eq!(
decide(&principal, method, path, access),
want,
"{method} {path} as {role:?} of {group:?}"
);
}
let member = Some(group("members", "members").access(Role::Member));
for path in ["/groups/03/items", "/groups/x/items", "/groups//items"] {
assert_eq!(decide(&rw, &get, path, member), forbidden, "{path}");
}
}
#[test]
fn paths_and_origins() {
assert_eq!(target("/api/users/1/items"), Target::User("1"));
assert_eq!(target("/users/0/items/top"), Target::User("0"));
assert_eq!(target("/users/1/groups"), Target::User("1"));
assert_eq!(target("/groups/3"), Target::Group("3"));
assert_eq!(target("/api/groups/3/items"), Target::Group("3"));
assert_eq!(target("/keys/current"), Target::None);
assert_eq!(target("/items/users/1"), Target::None);
assert_eq!(
public_origin("https://z.example.org/"),
"https://z.example.org"
);
assert_eq!(
public_origin("https://z.example.org/sub"),
"https://z.example.org"
);
assert_eq!(
public_origin("http://127.0.0.1:8189"),
"http://127.0.0.1:8189"
);
}
}