use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::auth::Account;
use crate::error::{ApiError, ValidationDetails};
use crate::ids::UserId;
use crate::page::{Cursor, MAX_CURSOR_BYTES};
use crate::text;
use crate::time::UnixMillis;
pub const ADMIN_ROLE: &str = "admin";
pub const ROLE_MAX_BYTES: usize = 64;
pub const BAN_REASON_MAX_CHARS: usize = 255;
pub const USER_SEARCH_MAX_CHARS: usize = 254;
pub fn is_valid_role(role: &str) -> bool {
role.len() <= ROLE_MAX_BYTES
&& role.bytes().next().is_some_and(|b| b.is_ascii_lowercase())
&& role.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-'))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BanInfo {
pub banned_at: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<UnixMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl BanInfo {
pub fn new(banned_at: UnixMillis) -> Self {
Self { banned_at, until: None, reason: None }
}
pub fn with_until(mut self, until: UnixMillis) -> Self {
self.until = Some(until);
self
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn is_active(&self, now: UnixMillis) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AdminUser {
pub account: Account,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ban: Option<BanInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<UnixMillis>,
#[serde(default)]
pub active_sessions: u32,
}
impl AdminUser {
pub fn new(account: Account) -> Self {
Self { account, ban: None, last_seen_at: None, active_sessions: 0 }
}
pub fn with_ban(mut self, ban: BanInfo) -> Self {
self.ban = Some(ban);
self
}
pub fn with_last_seen_at(mut self, at: UnixMillis) -> Self {
self.last_seen_at = Some(at);
self
}
pub fn with_active_sessions(mut self, sessions: u32) -> Self {
self.active_sessions = sessions;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UserListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl UserListQuery {
pub fn new() -> Self {
Self::default()
}
pub fn with_search(mut self, q: impl Into<String>) -> Self {
self.q = Some(q.into());
self
}
pub fn with_cursor(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(q) = &self.q {
if q.chars().count() > USER_SEARCH_MAX_CHARS {
details.add("q", format!("is longer than {USER_SEARCH_MAX_CHARS} characters"));
}
if q.chars().any(char::is_control) {
details.add("q", "contains control characters");
}
}
check_cursor(self.cursor.as_ref(), &mut details);
details.into_result()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BanRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<UnixMillis>,
}
impl BanRequest {
pub fn new() -> Self {
Self::default()
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
pub fn with_until(mut self, until: UnixMillis) -> Self {
self.until = Some(until);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(reason) = &self.reason {
if reason.chars().count() > BAN_REASON_MAX_CHARS {
details.add("reason", format!("is longer than {BAN_REASON_MAX_CHARS} characters"));
}
if let Some(problem) = text::message_problem(reason) {
details.add("reason", problem);
}
}
details.into_result()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuditEntry {
pub id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<UserId>,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
pub created_at: UnixMillis,
}
impl AuditEntry {
pub fn new(id: i64, action: impl Into<String>, created_at: UnixMillis) -> Self {
Self { id, actor: None, action: action.into(), target_type: None, target_id: None, ip: None, request_id: None, data: None, created_at }
}
pub fn with_actor(mut self, actor: UserId) -> Self {
self.actor = Some(actor);
self
}
pub fn with_target(mut self, target_type: impl Into<String>, target_id: impl Into<String>) -> Self {
self.target_type = Some(target_type.into());
self.target_id = Some(target_id.into());
self
}
pub fn with_ip(mut self, ip: impl Into<String>) -> Self {
self.ip = Some(ip.into());
self
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn with_data(mut self, data: Value) -> Self {
self.data = Some(data);
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuditQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<UserId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl AuditQuery {
pub fn new() -> Self {
Self::default()
}
pub fn with_user(mut self, user: UserId) -> Self {
self.user = Some(user);
self
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn with_cursor(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if let Some(action) = &self.action {
let ok =
(1..=64).contains(&action.len()) && action.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-'));
if !ok {
details.add("action", "is not an action name ([a-z0-9_.-], at most 64 bytes)");
}
}
check_cursor(self.cursor.as_ref(), &mut details);
details.into_result()
}
}
fn check_cursor(cursor: Option<&Cursor>, details: &mut ValidationDetails) {
if cursor.is_some_and(|c| c.as_str().len() > MAX_CURSOR_BYTES) {
details.add("cursor", format!("is longer than {MAX_CURSOR_BYTES} bytes"));
}
}
mod calls {
use super::*;
use crate::envelope::Ack;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::page::{Page, PageRequest};
use crate::routes::{self, HttpMethod, Route};
use crate::storage::{is_valid_name, DeleteObject, ObjectAck, ObjectVersion, StorageObject, StorageObjectInfo, WriteAccess};
payload_call!(UserListQuery, Get, routes::admin::USERS, true, Query, Page<AdminUser>);
payload_call!(AuditQuery, Get, routes::admin::AUDIT, true, Query, Page<AuditEntry>);
const NOT_A_NAME: &str = "is not a valid storage name";
macro_rules! user_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The account.
pub user: UserId,
}
impl $name {
pub const fn new(user: UserId) -> Self {
Self { user }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?))
}
}
};
}
user_call!(
GetUser,
Get,
routes::admin::USER,
AdminUser
);
user_call!(
UnbanUser,
Post,
routes::admin::UNBAN,
Ack
);
user_call!(
RevokeSessions,
Delete,
routes::admin::SESSIONS,
Ack
);
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct BanUser {
pub user: UserId,
pub ban: BanRequest,
}
impl BanUser {
pub fn new(user: UserId, ban: BanRequest) -> Self {
Self { user, ban }
}
}
impl HttpCall for BanUser {
type Payload = BanRequest;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Post, routes::admin::BAN, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &BanRequest {
&self.ban
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user)
}
fn from_parts(params: &PathParams, ban: BanRequest) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?, ban))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnlinkUserIdentity {
pub user: UserId,
pub provider: String,
}
impl UnlinkUserIdentity {
pub fn new(user: UserId, provider: impl Into<String>) -> Self {
Self { user, provider: provider.into() }
}
}
impl HttpCall for UnlinkUserIdentity {
type Payload = NoPayload;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Delete, routes::admin::IDENTITY, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user).with("provider", &self.provider)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?, params.checked("provider", crate::auth::is_valid_provider, "is not a provider name")?))
}
}
macro_rules! role_call {
($(#[$meta:meta])* $name:ident, $method:ident) => {
$(#[$meta])*
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The account.
pub user: UserId,
pub role: String,
}
impl $name {
pub fn new(user: UserId, role: impl Into<String>) -> Self {
Self { user, role: role.into() }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::$method, routes::admin::ROLE, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user).with("role", &self.role)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?, params.checked("role", is_valid_role, "is not a role name ([a-z][a-z0-9_.-]*, at most 64 bytes)")?))
}
}
};
}
role_call!(
GrantRole,
Put
);
role_call!(
RevokeRole,
Delete
);
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListUserObjects {
pub user: UserId,
pub collection: String,
pub page: PageRequest,
}
impl ListUserObjects {
pub fn new(user: UserId, collection: impl Into<String>) -> Self {
Self { user, collection: collection.into(), page: PageRequest::first() }
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for ListUserObjects {
type Payload = PageRequest;
type Response = Page<StorageObjectInfo>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::admin::USER_STORAGE, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user).with("collection", &self.collection)
}
fn from_parts(params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?, params.checked("collection", is_valid_name, NOT_A_NAME)?).with_page(page))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetUserObject {
pub user: UserId,
pub collection: String,
pub key: String,
}
impl GetUserObject {
pub fn new(user: UserId, collection: impl Into<String>, key: impl Into<String>) -> Self {
Self { user, collection: collection.into(), key: key.into() }
}
}
fn object_params(user: UserId, collection: &str, key: &str) -> PathParams {
PathParams::new().with("user", user).with("collection", collection).with("key", key)
}
fn object_names(params: &PathParams) -> Result<(UserId, String, String), ApiError> {
Ok((params.id("user")?, params.checked("collection", is_valid_name, NOT_A_NAME)?, params.checked("key", is_valid_name, NOT_A_NAME)?))
}
impl HttpCall for GetUserObject {
type Payload = NoPayload;
type Response = StorageObject;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::admin::USER_OBJECT, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
object_params(self.user, &self.collection, &self.key)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
let (user, collection, key) = object_names(params)?;
Ok(Self::new(user, collection, key))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AdminPutObject {
pub value: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub if_version: Option<ObjectVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write: Option<WriteAccess>,
}
impl AdminPutObject {
pub fn new(value: Value) -> Self {
Self { value, if_version: None, write: None }
}
pub fn if_version(mut self, version: ObjectVersion) -> Self {
self.if_version = Some(version);
self
}
pub fn with_write(mut self, write: WriteAccess) -> Self {
self.write = Some(write);
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct WriteUserObject {
pub user: UserId,
pub collection: String,
pub key: String,
pub put: AdminPutObject,
}
impl WriteUserObject {
pub fn new(user: UserId, collection: impl Into<String>, key: impl Into<String>, put: AdminPutObject) -> Self {
Self { user, collection: collection.into(), key: key.into(), put }
}
}
impl HttpCall for WriteUserObject {
type Payload = AdminPutObject;
type Response = ObjectAck;
const ROUTE: Route = Route::new(HttpMethod::Put, routes::admin::USER_OBJECT, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &AdminPutObject {
&self.put
}
fn path_params(&self) -> PathParams {
object_params(self.user, &self.collection, &self.key)
}
fn from_parts(params: &PathParams, put: AdminPutObject) -> Result<Self, ApiError> {
let (user, collection, key) = object_names(params)?;
Ok(Self::new(user, collection, key, put))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct RemoveUserObject {
pub user: UserId,
pub collection: String,
pub key: String,
pub delete: DeleteObject,
}
impl RemoveUserObject {
pub fn new(user: UserId, collection: impl Into<String>, key: impl Into<String>) -> Self {
Self { user, collection: collection.into(), key: key.into(), delete: DeleteObject::new() }
}
pub fn if_version(mut self, version: ObjectVersion) -> Self {
self.delete = self.delete.if_version(version);
self
}
}
impl HttpCall for RemoveUserObject {
type Payload = DeleteObject;
type Response = Ack;
const ROUTE: Route = Route::new(HttpMethod::Delete, routes::admin::USER_OBJECT, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &DeleteObject {
&self.delete
}
fn path_params(&self) -> PathParams {
object_params(self.user, &self.collection, &self.key)
}
fn from_parts(params: &PathParams, delete: DeleteObject) -> Result<Self, ApiError> {
let (user, collection, key) = object_names(params)?;
Ok(Self { user, collection, key, delete })
}
}
}
pub use calls::{
AdminPutObject, BanUser, GetUser, GetUserObject, GrantRole, ListUserObjects, RemoveUserObject, RevokeRole, RevokeSessions, UnbanUser, UnlinkUserIdentity,
WriteUserObject,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles() {
for ok in ["admin", "moderator", "beta.tester", "a", "x-1_2"] {
assert!(is_valid_role(ok), "{ok}");
}
for bad in ["", "Admin", "1admin", "ad min", "admin!", &"a".repeat(65)] {
assert!(!is_valid_role(bad), "{bad}");
}
assert!(is_valid_role(ADMIN_ROLE));
}
#[test]
fn validation() {
assert!(BanRequest::new().with_reason("cheating").validate().is_ok());
assert!(BanRequest::new().with_reason("x".repeat(256)).validate().is_err());
assert!(BanRequest::new().with_reason("a\u{202E}b").validate().is_err());
assert!(UserListQuery::new().with_search("ada").validate().is_ok());
assert!(UserListQuery::new().with_search("a\0").validate().is_err());
assert!(AuditQuery::new().with_action("admin.").validate().is_ok());
assert!(AuditQuery::new().with_action("Admin").validate().is_err());
assert!(AuditQuery::new().with_cursor(Cursor::new("c".repeat(513))).validate().is_err());
}
#[test]
fn ban_activity() {
let ban = BanInfo::new(UnixMillis(10));
assert!(ban.is_active(UnixMillis(1_000)));
let ban = ban.with_until(UnixMillis(100)).with_reason("x");
assert!(ban.is_active(UnixMillis(99)));
assert!(!ban.is_active(UnixMillis(100)));
}
}