feat(kanban): authorize canonical sync reads
req: sync/007 req: auth/005 req: security/004
This commit is contained in:
+84
-17
@@ -145,6 +145,8 @@ struct SyncAcknowledgement {
|
||||
card_id: u64,
|
||||
canonical_column: CanonicalColumn,
|
||||
status: &'static str,
|
||||
#[serde(skip_serializing)]
|
||||
tenant: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -213,6 +215,8 @@ struct PersistedAcknowledgement {
|
||||
server_sequence: u64,
|
||||
card_id: u64,
|
||||
canonical_column: CanonicalColumn,
|
||||
#[serde(default)]
|
||||
tenant: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncStore {
|
||||
@@ -227,7 +231,7 @@ impl SyncStore {
|
||||
fs::read(&self.0).map_err(|error| format!("read {}: {error}", self.0.display()))?;
|
||||
let persisted: PersistedSync = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("decode {}: {error}", self.0.display()))?;
|
||||
if persisted.schema_version != 1 || persisted.next_sequence == 0 {
|
||||
if !matches!(persisted.schema_version, 1 | 2) || persisted.next_sequence == 0 {
|
||||
return Err(format!("unsupported sync store {}", self.0.display()));
|
||||
}
|
||||
let mut acknowledgements = BTreeMap::new();
|
||||
@@ -237,12 +241,23 @@ impl SyncStore {
|
||||
if stored.server_sequence == 0 || stored.card_id == 0 {
|
||||
return Err(format!("invalid acknowledgement in {}", self.0.display()));
|
||||
}
|
||||
let tenant = if persisted.schema_version == 1 {
|
||||
"demo".to_owned()
|
||||
} else {
|
||||
stored
|
||||
.tenant
|
||||
.filter(|tenant| matches!(tenant.as_str(), "demo" | "alpha" | "beta"))
|
||||
.ok_or_else(|| {
|
||||
format!("invalid acknowledgement tenant in {}", self.0.display())
|
||||
})?
|
||||
};
|
||||
let acknowledgement = SyncAcknowledgement {
|
||||
command_id: stored.command_id,
|
||||
server_sequence: stored.server_sequence,
|
||||
card_id: stored.card_id,
|
||||
canonical_column: stored.canonical_column,
|
||||
status: "accepted",
|
||||
tenant,
|
||||
};
|
||||
if acknowledgements
|
||||
.insert(command_id, acknowledgement)
|
||||
@@ -271,8 +286,8 @@ impl SyncStore {
|
||||
acknowledgements.push(PersistedAcknowledgement::from(acknowledgement));
|
||||
acknowledgements.sort_by_key(|item| item.server_sequence);
|
||||
let persisted = PersistedSync {
|
||||
schema_version: 1,
|
||||
next_sequence: acknowledgement.server_sequence + 1,
|
||||
schema_version: 2,
|
||||
next_sequence: sync.next_sequence + 1,
|
||||
acknowledgements,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&persisted)
|
||||
@@ -309,6 +324,7 @@ impl From<&SyncAcknowledgement> for PersistedAcknowledgement {
|
||||
server_sequence: value.server_sequence,
|
||||
card_id: value.card_id,
|
||||
canonical_column: value.canonical_column,
|
||||
tenant: Some(value.tenant.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -603,6 +619,19 @@ async fn sync_context(
|
||||
}))
|
||||
}
|
||||
|
||||
fn visible_card(principal: CurrentSyncPrincipal, card_id: u64) -> bool {
|
||||
principal.tenant == "demo"
|
||||
|| (principal.tenant == "beta" && card_id == 2)
|
||||
|| (principal.tenant == "alpha" && card_id != 2)
|
||||
}
|
||||
|
||||
fn visible_acknowledgement(
|
||||
principal: CurrentSyncPrincipal,
|
||||
acknowledgement: &SyncAcknowledgement,
|
||||
) -> bool {
|
||||
principal.tenant == "demo" || acknowledgement.tenant == principal.tenant
|
||||
}
|
||||
|
||||
fn authorize_sync_replay(
|
||||
principal: CurrentSyncPrincipal,
|
||||
card_id: u64,
|
||||
@@ -642,6 +671,11 @@ async fn sync_command(
|
||||
|
||||
let mut sync = state.sync.lock().unwrap();
|
||||
if let Some(existing) = sync.acknowledgements.get(&command_id) {
|
||||
if !visible_acknowledgement(principal, existing) {
|
||||
return Err(SyncRejection::Forbidden(
|
||||
"current tenant cannot access command acknowledgement",
|
||||
));
|
||||
}
|
||||
if existing.card_id != card_id || existing.canonical_column != canonical_column {
|
||||
return Err(SyncRejection::Conflict(
|
||||
"command_id was already used for a different payload",
|
||||
@@ -667,12 +701,21 @@ async fn sync_command(
|
||||
.iter()
|
||||
.position(|card| card.id == card_id)
|
||||
.ok_or(SyncRejection::BadRequest("unknown card_id"))?;
|
||||
let server_sequence = sync
|
||||
.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement))
|
||||
.map(|acknowledgement| acknowledgement.server_sequence)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
+ 1;
|
||||
let acknowledgement = SyncAcknowledgement {
|
||||
command_id: command_id.0.clone(),
|
||||
server_sequence: sync.next_sequence,
|
||||
server_sequence,
|
||||
card_id,
|
||||
canonical_column,
|
||||
status: "accepted",
|
||||
tenant: principal.tenant.to_owned(),
|
||||
};
|
||||
if let Some(store) = &state.sync_store {
|
||||
store.persist(&sync, &acknowledgement).map_err(|error| {
|
||||
@@ -687,23 +730,35 @@ async fn sync_command(
|
||||
Ok(Json(acknowledgement))
|
||||
}
|
||||
|
||||
// req: sync/007 req: sync/020
|
||||
async fn sync_snapshot(State(state): State<Arc<AppState>>) -> Json<SyncSnapshot> {
|
||||
// req: sync/007 req: sync/020 req: auth/005 req: security/004
|
||||
async fn sync_snapshot(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SyncSnapshot>, SyncRejection> {
|
||||
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
|
||||
let board = state.board.lock().unwrap();
|
||||
let sync = state.sync.lock().unwrap();
|
||||
let cards = board
|
||||
.cards
|
||||
.iter()
|
||||
.filter(|card| visible_card(principal, card.id))
|
||||
.map(|card| SnapshotCard {
|
||||
id: card.id,
|
||||
column: canonical_column(card.column),
|
||||
})
|
||||
.collect();
|
||||
Json(SyncSnapshot {
|
||||
let server_sequence = sync
|
||||
.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement))
|
||||
.map(|acknowledgement| acknowledgement.server_sequence)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
Ok(Json(SyncSnapshot {
|
||||
schema_version: 1,
|
||||
server_sequence: sync.next_sequence.saturating_sub(1),
|
||||
server_sequence,
|
||||
cards,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn canonical_column(column: usize) -> CanonicalColumn {
|
||||
@@ -714,11 +769,13 @@ fn canonical_column(column: usize) -> CanonicalColumn {
|
||||
}
|
||||
}
|
||||
|
||||
// req: sync/005 req: sync/006 req: sync/007 req: sync/013
|
||||
// req: sync/005 req: sync/006 req: sync/007 req: sync/013 req: auth/005 req: security/004
|
||||
async fn sync_acknowledgements(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, SyncRejection> {
|
||||
let principal = current_sync_principal(&headers, &state.sync_sessions)?;
|
||||
let after = params
|
||||
.get("after")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
@@ -733,22 +790,31 @@ async fn sync_acknowledgements(
|
||||
let attempts = sync.reconnects.entry(key).or_default();
|
||||
*attempts += 1;
|
||||
if *attempts == 1 {
|
||||
return Sse::new(
|
||||
return Ok(Sse::new(
|
||||
stream::iter([Ok(Event::default()
|
||||
.comment("reconnect")
|
||||
.retry(Duration::from_millis(25)))])
|
||||
.boxed(),
|
||||
)
|
||||
.keep_alive(KeepAlive::default());
|
||||
.keep_alive(KeepAlive::default()));
|
||||
}
|
||||
}
|
||||
let first_available = sync
|
||||
.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| acknowledgement.server_sequence > sync.retained_after)
|
||||
.filter(|acknowledgement| {
|
||||
visible_acknowledgement(principal, acknowledgement)
|
||||
&& acknowledgement.server_sequence > sync.retained_after
|
||||
})
|
||||
.map(|acknowledgement| acknowledgement.server_sequence)
|
||||
.min();
|
||||
let latest = sync.next_sequence.saturating_sub(1);
|
||||
let latest = sync
|
||||
.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| visible_acknowledgement(principal, acknowledgement))
|
||||
.map(|acknowledgement| acknowledgement.server_sequence)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let history_missing = after < latest
|
||||
&& first_available.is_none_or(|first_sequence| after.saturating_add(1) < first_sequence);
|
||||
let events = if history_missing {
|
||||
@@ -766,7 +832,8 @@ async fn sync_acknowledgements(
|
||||
sync.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| {
|
||||
acknowledgement.server_sequence > after
|
||||
visible_acknowledgement(principal, acknowledgement)
|
||||
&& acknowledgement.server_sequence > after
|
||||
&& acknowledgement.server_sequence > sync.retained_after
|
||||
})
|
||||
.map(|acknowledgement| {
|
||||
@@ -778,7 +845,7 @@ async fn sync_acknowledgements(
|
||||
})
|
||||
.collect::<Vec<Result<Event, Infallible>>>()
|
||||
};
|
||||
Sse::new(stream::iter(events).boxed()).keep_alive(KeepAlive::default())
|
||||
Ok(Sse::new(stream::iter(events).boxed()).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
|
||||
Reference in New Issue
Block a user