Updating auth

This commit is contained in:
2026-04-04 08:28:43 -04:00
parent 35d07e8df1
commit f17e5061cd
78 changed files with 5266 additions and 1380 deletions

View File

@@ -2,7 +2,7 @@ pub mod model;
use crate::{
AppState,
auth::{OptionalAuth, Session, csprng, middleware::check_bearer_auth},
auth::{Session, SessionAuthorization, middleware::check_cookie_from_header_str},
error::{Error, Result},
};
use axum::{
@@ -10,81 +10,98 @@ use axum::{
Router,
extract::{
Path,
Query,
State,
WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{delete, get, post, put},
};
use futures_util::{SinkExt, StreamExt};
use model::{
AccessRequestWithUser,
ClientMessage,
CreateAccessRequestPayload,
CreateMapPayload,
GridCell,
GridMap,
GridToken,
MapPermission,
ListedMap,
MapRole,
MapState,
PermissionWithUser,
ResolveAccessRequestPayload,
ServerMessage,
UpdateMapPayload,
UpdatePermissionPayload,
};
use serde::Deserialize;
use siren_core::utils::csprng;
use std::sync::Arc;
use tokio::sync::broadcast;
use uuid::Uuid;
pub fn get_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/maps", get(list_maps))
.route("/maps", post(create_map))
.route("/maps/{id}", get(get_map))
.route("/maps/{id}", put(update_map))
.route("/maps/{id}", delete(delete_map))
.route("/maps/{id}/permissions", get(list_permissions))
.route("/maps/{id}/permissions", put(update_permission))
.route("/maps/{id}/favorite", post(favorite_map))
.route("/maps/{id}/favorite", delete(unfavorite_map))
.route("/maps/{id}/access-requests", post(create_access_request))
.route("/maps/{id}/access-requests", get(list_access_requests))
.route(
"/maps/{id}/access-requests/{request_id}",
put(resolve_access_request),
)
.route("/maps/{id}/ws", get(ws_handler))
}
// ---------------------------------------------------------------------------
// Permission helpers
// Access helpers
// ---------------------------------------------------------------------------
/// Fetch the role of `user_id` on `map_id`, or `None` if no record exists.
async fn get_user_role(map_id: &str, user_id: i64) -> crate::error::Result<Option<MapRole>> {
async fn get_user_role(map_id: &str, user_id: Uuid) -> Result<Option<MapRole>> {
let pool = siren_core::data::pool();
let perm: Option<MapPermission> = sqlx::query_as(
"SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1 AND user_id = $2",
)
.bind(map_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(perm.map(|p| p.role))
let role: Option<String> =
sqlx::query_scalar("SELECT role FROM map_permissions WHERE map_id = $1 AND user_id = $2")
.bind(map_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(role.and_then(|r| match r.as_str() {
"owner" => Some(MapRole::Owner),
"editor" => Some(MapRole::Editor),
"viewer" => Some(MapRole::Viewer),
_ => None,
}))
}
/// Returns whether the caller can view the map:
/// - Public maps: always true.
/// - Private maps: true only if the user has any role.
/// Returns whether the caller can view the map.
async fn can_view(map: &GridMap, session: &Option<Session>) -> bool {
if map.is_public {
if map.public_access == "public_view" || map.public_access == "public_edit" {
return true;
}
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, user_id)
get_user_role(&map.id, s.user_id)
.await
.ok()
.flatten()
.is_some()
}
/// Returns whether the caller can edit the map (editor or owner role).
/// Returns whether the caller can edit the map (editor or owner role, or public_edit).
async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
if map.public_access == "public_edit" {
return true;
}
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, user_id)
get_user_role(&map.id, s.user_id)
.await
.ok()
.flatten()
@@ -95,8 +112,7 @@ async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
/// Returns whether the caller is the owner.
async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, user_id)
get_user_role(&map.id, s.user_id)
.await
.ok()
.flatten()
@@ -105,60 +121,68 @@ async fn is_owner(map: &GridMap, session: &Option<Session>) -> bool {
}
// ---------------------------------------------------------------------------
// REST handlers
// Map CRUD
// ---------------------------------------------------------------------------
pub async fn list_maps(OptionalAuth(session): OptionalAuth) -> Result<Json<Vec<GridMap>>> {
pub async fn list_maps(
SessionAuthorization(session): SessionAuthorization,
) -> Result<Json<Vec<ListedMap>>> {
let pool = siren_core::data::pool();
let maps: Vec<GridMap> = match &session {
let maps: Vec<ListedMap> = match &session {
Some(s) => {
let user_id = s.user_id as i64;
sqlx::query_as(
"SELECT DISTINCT gm.*
"SELECT
gm.id, gm.name, gm.public_access, gm.owner_id,
u.username AS owner_username,
gm.colors, gm.created_at, gm.updated_at,
mp.role AS user_role,
(mf.user_id IS NOT NULL) AS is_favorited
FROM grid_maps gm
JOIN users u ON u.id = gm.owner_id
LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1
WHERE gm.is_public = TRUE OR mp.user_id IS NOT NULL
ORDER BY gm.created_at DESC",
LEFT JOIN map_favorites mf ON mf.map_id = gm.id AND mf.user_id = $1
WHERE mp.user_id IS NOT NULL OR mf.user_id IS NOT NULL
ORDER BY gm.updated_at DESC",
)
.bind(user_id)
.bind(s.user_id)
.fetch_all(pool)
.await?
}
None => {
sqlx::query_as("SELECT * FROM grid_maps WHERE is_public = TRUE ORDER BY created_at DESC")
.fetch_all(pool)
.await?
}
None => vec![],
};
Ok(Json(maps))
}
pub async fn create_map(
OptionalAuth(session): OptionalAuth,
SessionAuthorization(session): SessionAuthorization,
Json(payload): Json<CreateMapPayload>,
) -> Result<(StatusCode, Json<GridMap>)> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let user_id = session.user_id as i64;
let public_access = payload.public_access.as_str();
if !matches!(public_access, "private" | "public_view" | "public_edit") {
return Err(Error::new(422, "Invalid public_access value".into()));
}
let map_id = csprng(32);
let pool = siren_core::data::pool();
let map: GridMap = sqlx::query_as(
"INSERT INTO grid_maps (id, name, is_public, owner_id)
"INSERT INTO grid_maps (id, name, public_access, owner_id)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(&map_id)
.bind(&payload.name)
.bind(payload.is_public)
.bind(user_id)
.bind(&payload.public_access)
.bind(session.user_id)
.fetch_one(pool)
.await?;
// Auto-assign the creator as owner in map_permissions
sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')")
.bind(&map_id)
.bind(user_id)
.bind(session.user_id)
.execute(pool)
.await?;
@@ -166,7 +190,7 @@ pub async fn create_map(
}
pub async fn get_map(
OptionalAuth(session): OptionalAuth,
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<Json<MapState>> {
let pool = siren_core::data::pool();
@@ -195,8 +219,51 @@ pub async fn get_map(
Ok(Json(MapState { map, cells, tokens }))
}
pub async fn update_map(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
Json(payload): Json<UpdateMapPayload>,
) -> Result<Json<GridMap>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
.bind(&id)
.fetch_optional(pool)
.await?;
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
if !is_owner(&map, &session).await {
return Err(StatusCode::FORBIDDEN.into());
}
if let Some(ref pa) = payload.public_access {
if !matches!(pa.as_str(), "private" | "public_view" | "public_edit") {
return Err(Error::new(422, "Invalid public_access value".into()));
}
}
let new_name = payload.name.as_deref().unwrap_or(&map.name);
let new_pa = payload
.public_access
.as_deref()
.unwrap_or(&map.public_access);
let updated: GridMap = sqlx::query_as(
"UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW()
WHERE id = $3 RETURNING *",
)
.bind(new_name)
.bind(new_pa)
.bind(&id)
.fetch_one(pool)
.await?;
Ok(Json(updated))
}
pub async fn delete_map(
OptionalAuth(session): OptionalAuth,
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<StatusCode> {
let pool = siren_core::data::pool();
@@ -225,9 +292,9 @@ pub async fn delete_map(
// ---------------------------------------------------------------------------
pub async fn list_permissions(
OptionalAuth(session): OptionalAuth,
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<Json<Vec<MapPermission>>> {
) -> Result<Json<Vec<PermissionWithUser>>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
@@ -241,17 +308,22 @@ pub async fn list_permissions(
return Err(StatusCode::FORBIDDEN.into());
}
let perms: Vec<MapPermission> =
sqlx::query_as("SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1")
.bind(&id)
.fetch_all(pool)
.await?;
let perms: Vec<PermissionWithUser> = sqlx::query_as(
"SELECT mp.map_id, mp.user_id, u.username, mp.role
FROM map_permissions mp
JOIN users u ON u.id = mp.user_id
WHERE mp.map_id = $1
ORDER BY mp.role, u.username",
)
.bind(&id)
.fetch_all(pool)
.await?;
Ok(Json(perms))
}
pub async fn update_permission(
OptionalAuth(session): OptionalAuth,
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
Json(payload): Json<UpdatePermissionPayload>,
) -> Result<StatusCode> {
@@ -268,10 +340,23 @@ pub async fn update_permission(
return Err(StatusCode::FORBIDDEN.into());
}
// Prevent the owner from removing their own owner record
let caller_id = session.as_ref().map(|s| s.user_id as i64).unwrap_or(0);
if payload.user_id == caller_id && payload.role.as_ref().map(|r| r.is_owner()) == Some(false) {
return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY));
// Resolve username → user_id
let target_id: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
.bind(&payload.username)
.fetch_optional(pool)
.await?;
let target_id = target_id.ok_or_else(|| Error::not_found("User not found".into()))?;
// Prevent the owner from stripping their own owner record
if let Some(ref s) = session {
if target_id == s.user_id {
if let Some(ref role) = payload.role {
if !role.is_owner() {
return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY));
}
}
}
}
match payload.role {
@@ -282,7 +367,7 @@ pub async fn update_permission(
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
)
.bind(&id)
.bind(payload.user_id)
.bind(target_id)
.bind(role)
.execute(pool)
.await?;
@@ -290,7 +375,7 @@ pub async fn update_permission(
None => {
sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2")
.bind(&id)
.bind(payload.user_id)
.bind(target_id)
.execute(pool)
.await?;
}
@@ -299,26 +384,215 @@ pub async fn update_permission(
Ok(StatusCode::NO_CONTENT)
}
pub async fn favorite_map(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<StatusCode> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let pool = siren_core::data::pool();
// Verify the map exists and is viewable
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
.bind(&id)
.fetch_optional(pool)
.await?;
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
if !can_view(&map, &Some(session.clone())).await {
return Err(StatusCode::FORBIDDEN.into());
}
sqlx::query(
"INSERT INTO map_favorites (user_id, map_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING",
)
.bind(session.user_id)
.bind(&id)
.execute(pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn unfavorite_map(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<StatusCode> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let pool = siren_core::data::pool();
sqlx::query("DELETE FROM map_favorites WHERE user_id = $1 AND map_id = $2")
.bind(session.user_id)
.bind(&id)
.execute(pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// Access Requests
// ---------------------------------------------------------------------------
pub async fn create_access_request(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
Json(payload): Json<CreateAccessRequestPayload>,
) -> Result<StatusCode> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
// Only editor and viewer roles can be requested
if matches!(payload.role, MapRole::Owner) {
return Err(Error::new(422, "Cannot request owner role".into()));
}
let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
.bind(&id)
.fetch_optional(pool)
.await?;
map.ok_or_else(|| Error::not_found("Map not found".into()))?;
// Check if user already has a direct permission
let existing_role = get_user_role(&id, session.user_id).await?;
if existing_role.is_some() {
return Err(Error::new(
409,
"You already have access to this map".into(),
));
}
// Upsert the request (update role if they change their mind)
sqlx::query(
"INSERT INTO map_access_requests (map_id, user_id, requested_role, status, updated_at)
VALUES ($1, $2, $3, 'pending', NOW())
ON CONFLICT (map_id, user_id)
DO UPDATE SET requested_role = EXCLUDED.requested_role,
status = 'pending',
updated_at = NOW()",
)
.bind(&id)
.bind(session.user_id)
.bind(&payload.role)
.execute(pool)
.await?;
Ok(StatusCode::CREATED)
}
pub async fn list_access_requests(
SessionAuthorization(session): SessionAuthorization,
Path(id): Path<String>,
) -> Result<Json<Vec<AccessRequestWithUser>>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
.bind(&id)
.fetch_optional(pool)
.await?;
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
if !is_owner(&map, &session).await {
return Err(StatusCode::FORBIDDEN.into());
}
let requests: Vec<AccessRequestWithUser> = sqlx::query_as(
"SELECT mar.id, mar.map_id, mar.user_id, u.username,
mar.requested_role, mar.status, mar.created_at, mar.updated_at
FROM map_access_requests mar
JOIN users u ON u.id = mar.user_id
WHERE mar.map_id = $1 AND mar.status = 'pending'
ORDER BY mar.created_at ASC",
)
.bind(&id)
.fetch_all(pool)
.await?;
Ok(Json(requests))
}
pub async fn resolve_access_request(
SessionAuthorization(session): SessionAuthorization,
Path((map_id, request_id)): Path<(String, Uuid)>,
Json(payload): Json<ResolveAccessRequestPayload>,
) -> Result<StatusCode> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1")
.bind(&map_id)
.fetch_optional(pool)
.await?;
let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?;
if !is_owner(&map, &session).await {
return Err(StatusCode::FORBIDDEN.into());
}
if !matches!(payload.action.as_str(), "approve" | "deny") {
return Err(Error::new(422, "action must be 'approve' or 'deny'".into()));
}
// Fetch the request
let req: Option<(Uuid, String)> = sqlx::query_as(
"SELECT user_id, requested_role FROM map_access_requests WHERE id = $1 AND map_id = $2",
)
.bind(request_id)
.bind(&map_id)
.fetch_optional(pool)
.await?;
let (user_id, role) = req.ok_or_else(|| Error::not_found("Access request not found".into()))?;
if payload.action == "approve" {
// Grant the requested role
sqlx::query(
"INSERT INTO map_permissions (map_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role",
)
.bind(&map_id)
.bind(user_id)
.bind(&role)
.execute(pool)
.await?;
}
// Mark request as resolved
let new_status = if payload.action == "approve" {
"approved"
} else {
"denied"
};
sqlx::query("UPDATE map_access_requests SET status = $1, updated_at = NOW() WHERE id = $2")
.bind(new_status)
.bind(request_id)
.execute(pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// WebSocket handler
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct WsQuery {
/// Optional Bearer token passed as a query parameter for WS auth.
token: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Path(map_id): Path<String>,
Query(query): Query<WsQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
// Resolve the session from query param (WS can't easily send headers)
let session: Option<Session> = match query.token {
Some(ref tok) => check_bearer_auth(tok).await.ok(),
None => None,
let session: Option<Session> = {
let ip = crate::auth::middleware::extract_ip(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("unknown");
if let Some(cookie_header) = headers.get("cookie").and_then(|h| h.to_str().ok()) {
check_cookie_from_header_str(cookie_header, &ip, user_agent).await
} else {
None
}
};
ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session))
@@ -330,20 +604,17 @@ async fn handle_socket(
map_id: String,
session: Option<Session>,
) {
// Load the map and verify the caller can view it
let map_state = match fetch_map_state(&map_id).await {
Ok(ms) => ms,
Err(_) => return, // map doesn't exist
Err(_) => return,
};
if !can_view(&map_state.map, &session).await {
// Refuse the connection silently (upgrade already happened; just close)
return;
}
let editor = can_edit(&map_state.map, &session).await;
// Get or create a broadcast channel for this map
let tx = state
.map_rooms
.entry(map_id.clone())
@@ -356,7 +627,6 @@ async fn handle_socket(
let (mut ws_tx, mut ws_rx) = socket.split();
// Send the current full map state to the newly connected client
let init_msg = ServerMessage::State {
cells: map_state.cells,
tokens: map_state.tokens,
@@ -366,7 +636,6 @@ async fn handle_socket(
let _ = ws_tx.send(Message::Text(json.into())).await;
}
// Task 1: forward broadcast messages to this socket
let mut send_task = tokio::spawn(async move {
while let Ok(json) = rx.recv().await {
if ws_tx.send(Message::Text(json.into())).await.is_err() {
@@ -375,7 +644,6 @@ async fn handle_socket(
}
});
// Task 2: receive messages from this client, persist, and broadcast
let tx_clone = tx.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_rx.next().await {
@@ -430,7 +698,6 @@ async fn handle_client_message(
}
};
// All mutating messages require editor or owner role
if !can_edit {
let err = ServerMessage::Error {
message: "You do not have permission to edit this map.".into(),