feat(kanban): reauthorize queued replay

req: sync/019

req: security/004

req: auth/005

req: operations/002
This commit is contained in:
slhx agent
2026-07-13 18:18:52 +02:00
parent a17de85b4f
commit 6de4dcb73b
4 changed files with 366 additions and 17 deletions
+152 -8
View File
@@ -1,5 +1,5 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::routing::{get, post};
@@ -31,6 +31,49 @@ struct AppState {
board: Mutex<BoardState>,
sync: Mutex<SyncState>,
sync_store: Option<SyncStore>,
sync_sessions: SyncSessionTokens,
}
#[derive(Default)]
struct SyncSessionTokens {
alice_alpha_editor: Option<String>,
bob_alpha_viewer: Option<String>,
carol_beta_editor: Option<String>,
}
impl SyncSessionTokens {
fn from_env() -> Self {
fn token(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
Self {
alice_alpha_editor: token("HEMX_KANBAN_SESSION_ALICE_ALPHA_EDITOR"),
bob_alpha_viewer: token("HEMX_KANBAN_SESSION_BOB_ALPHA_VIEWER"),
carol_beta_editor: token("HEMX_KANBAN_SESSION_CAROL_BETA_EDITOR"),
}
}
fn is_configured(&self) -> bool {
self.alice_alpha_editor.is_some()
|| self.bob_alpha_viewer.is_some()
|| self.carol_beta_editor.is_some()
}
fn matches(expected: &Option<String>, candidate: &str) -> bool {
let Some(expected) = expected else {
return false;
};
if expected.len() != candidate.len() {
return false;
}
expected
.bytes()
.zip(candidate.bytes())
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
}
#[derive(Clone)]
@@ -122,6 +165,8 @@ struct SnapshotCard {
#[derive(Debug)]
enum SyncRejection {
BadRequest(&'static str),
Unauthorized(&'static str),
Forbidden(&'static str),
Conflict(&'static str),
Transient,
Storage,
@@ -129,13 +174,27 @@ enum SyncRejection {
impl IntoResponse for SyncRejection {
fn into_response(self) -> axum::response::Response {
let (status, error) = match self {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Transient => (StatusCode::SERVICE_UNAVAILABLE, "transient sync failure"),
Self::Storage => (StatusCode::INTERNAL_SERVER_ERROR, "sync storage failed"),
let (status, kind, error) = match self {
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, "invalid-command", error),
Self::Unauthorized(error) => (StatusCode::UNAUTHORIZED, "authorization-denial", error),
Self::Forbidden(error) => (StatusCode::FORBIDDEN, "authorization-denial", error),
Self::Conflict(error) => (StatusCode::CONFLICT, "command-conflict", error),
Self::Transient => (
StatusCode::SERVICE_UNAVAILABLE,
"transport-failure",
"transient sync failure",
),
Self::Storage => (
StatusCode::INTERNAL_SERVER_ERROR,
"storage-failure",
"sync storage failed",
),
};
(status, Json(serde_json::json!({ "error": error }))).into_response()
(
status,
Json(serde_json::json!({ "kind": kind, "error": error })),
)
.into_response()
}
}
@@ -361,6 +420,7 @@ async fn main() {
board: Mutex::new(board),
sync: Mutex::new(sync),
sync_store,
sync_sessions: SyncSessionTokens::from_env(),
});
let app = Router::new()
@@ -465,9 +525,91 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
// req: sync/001 req: sync/008 req: sync/012
#[derive(Clone, Copy)]
struct CurrentSyncPrincipal {
principal: &'static str,
tenant: &'static str,
can_replay: bool,
}
fn current_sync_principal(
headers: &HeaderMap,
sessions: &SyncSessionTokens,
) -> Result<CurrentSyncPrincipal, SyncRejection> {
let session = headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|cookie| {
cookie
.trim()
.strip_prefix("hemx_kanban_session=")
.map(str::trim)
})
});
let Some(session) = session else {
if sessions.is_configured() {
return Err(SyncRejection::Unauthorized(
"current sync session is required",
));
}
return Ok(CurrentSyncPrincipal {
principal: "demo",
tenant: "demo",
can_replay: true,
});
};
if SyncSessionTokens::matches(&sessions.alice_alpha_editor, session) {
return Ok(CurrentSyncPrincipal {
principal: "alice",
tenant: "alpha",
can_replay: true,
});
}
if SyncSessionTokens::matches(&sessions.bob_alpha_viewer, session) {
return Ok(CurrentSyncPrincipal {
principal: "bob",
tenant: "alpha",
can_replay: false,
});
}
if SyncSessionTokens::matches(&sessions.carol_beta_editor, session) {
return Ok(CurrentSyncPrincipal {
principal: "carol",
tenant: "beta",
can_replay: true,
});
}
Err(SyncRejection::Unauthorized(
"current sync session is invalid",
))
}
fn authorize_sync_replay(
principal: CurrentSyncPrincipal,
card_id: u64,
) -> Result<(), SyncRejection> {
if principal.principal == "demo" && principal.tenant == "demo" {
return Ok(());
}
if !principal.can_replay {
return Err(SyncRejection::Forbidden(
"current principal cannot replay commands",
));
}
let card_tenant = if card_id == 2 { "beta" } else { "alpha" };
if principal.tenant != card_tenant {
return Err(SyncRejection::Forbidden(
"current tenant cannot access command target",
));
}
Ok(())
}
// req: sync/001 req: sync/008 req: sync/012 req: sync/019 req: security/004
async fn sync_command(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Json<SyncAcknowledgement>, SyncRejection> {
let command_id = CommandId::parse(params.get("command_id"))?;
@@ -477,6 +619,8 @@ async fn sync_command(
.filter(|value| *value > 0)
.ok_or(SyncRejection::BadRequest("invalid card_id"))?;
let canonical_column = CanonicalColumn::parse(params.get("column"))?;
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
authorize_sync_replay(principal, card_id)?;
let mut sync = state.sync.lock().unwrap();
if let Some(existing) = sync.acknowledgements.get(&command_id) {