operator admin sync: cap the grant at one send, split out response verdicts
The admin-room command stays in #admins' history, so a grant tuwunel ignores must not be re-sent every five minutes. The controller now sends `make-user-admin` at most once per operator per process; an operator still missing from the room on a later pass is logged as "make-user-admin for an operator failed to take" and left alone until the controller restarts. The cap lives in the pure `plan()`. swarm-matrix-client's `user_exists` and `joined_members` read their responses through pure `profile_verdict` / `joined_verdict`, the shape `whoami` / `whoami_verdict` already has, with every branch tested: only M_NOT_FOUND is "no account", and a 403 or a list without `joined` is an error rather than an answer. The bridge's handling after authentication moves into `respond()`, which takes the config and write lock instead of the whole AppState, so a test can drive ListOperators through the real request path and see the 403 without an HTTP client or an authelia. docs/integrations/matrix.md no longer says @swarm is the only homeserver admin.
This commit is contained in:
parent
9e7fdb55ae
commit
cf88fbba9d
4 changed files with 261 additions and 42 deletions
|
|
@ -150,11 +150,19 @@ async fn handle_request(
|
|||
Ok(caller) => caller,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
if let Err(refusal) = permit(
|
||||
&req,
|
||||
caller.as_deref(),
|
||||
&state.config.operator_reader_client_id,
|
||||
) {
|
||||
respond(&state.config, &state.write_lock, caller.as_deref(), req).await
|
||||
}
|
||||
|
||||
/// Everything after authentication: the per-request [`permit`] check, then
|
||||
/// the operation. Takes the config and the write lock rather than
|
||||
/// [`AppState`], whose HTTP client only [`authorize`] uses.
|
||||
async fn respond(
|
||||
config: &Config,
|
||||
write_lock: &tokio::sync::Mutex<()>,
|
||||
caller: Option<&str>,
|
||||
req: BridgeRequest,
|
||||
) -> Response {
|
||||
if let Err(refusal) = permit(&req, caller, &config.operator_reader_client_id) {
|
||||
// The client id is not a credential; naming it is what lets an
|
||||
// operator see which client asked for something it may not have.
|
||||
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
|
||||
|
|
@ -162,10 +170,10 @@ async fn handle_request(
|
|||
}
|
||||
let (op, result) = match req {
|
||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||
("ensure_identity", handle(&state, name).await)
|
||||
("ensure_identity", handle(config, write_lock, name).await)
|
||||
}
|
||||
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
|
||||
BridgeRequest::ListOperators => ("list_operators", list_operators(&state)),
|
||||
BridgeRequest::ListAgentIdentities => ("list_identities", list(config)),
|
||||
BridgeRequest::ListOperators => ("list_operators", list_operators(config)),
|
||||
};
|
||||
match result {
|
||||
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||
|
|
@ -304,20 +312,23 @@ fn refuse(refusal: Refusal) -> Response {
|
|||
.into_response()
|
||||
}
|
||||
|
||||
async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
||||
async fn handle(
|
||||
cfg: &Config,
|
||||
write_lock: &tokio::sync::Mutex<()>,
|
||||
name: String,
|
||||
) -> Result<BridgeResponse> {
|
||||
store::validate_username(&name)?;
|
||||
// After the charset check and before anything touches the store: a
|
||||
// reserved name is refused on its own terms, not as a side effect of a
|
||||
// collision with whatever happens to be in the file today.
|
||||
store::reject_reserved_name(&name)?;
|
||||
let cfg = &state.config;
|
||||
|
||||
// Held across the whole load → insert → publish sequence, not just the
|
||||
// write — two concurrent `EnsureAgentIdentity` calls must not both read
|
||||
// the same on-disk snapshot before either publishes. See the field's
|
||||
// doc comment on `AppState::write_lock` for why this is reachable in
|
||||
// practice, not just theoretically.
|
||||
let _write_guard = state.write_lock.lock().await;
|
||||
let _write_guard = write_lock.lock().await;
|
||||
|
||||
let mut user_store = store::load_store(&cfg.users_file)?;
|
||||
if user_store.users.contains_key(&name) {
|
||||
|
|
@ -365,8 +376,8 @@ async fn handle(state: &AppState, name: String) -> Result<BridgeResponse> {
|
|||
/// reader sees either the whole old file or the whole new one — and holding
|
||||
/// the lock would make a read wait behind a password mint, which is an
|
||||
/// external process.
|
||||
fn list(state: &AppState) -> Result<BridgeResponse> {
|
||||
let user_store = store::load_store(&state.config.users_file)?;
|
||||
fn list(config: &Config) -> Result<BridgeResponse> {
|
||||
let user_store = store::load_store(&config.users_file)?;
|
||||
Ok(BridgeResponse::Agents {
|
||||
agents: store::agent_names(&user_store)
|
||||
.into_iter()
|
||||
|
|
@ -395,10 +406,10 @@ fn list(state: &AppState) -> Result<BridgeResponse> {
|
|||
}
|
||||
|
||||
/// The operator group's members. No write lock, for [`list`]'s reason.
|
||||
fn list_operators(state: &AppState) -> Result<BridgeResponse> {
|
||||
let user_store = store::load_store(&state.config.users_file)?;
|
||||
fn list_operators(config: &Config) -> Result<BridgeResponse> {
|
||||
let user_store = store::load_store(&config.users_file)?;
|
||||
Ok(BridgeResponse::Operators {
|
||||
operators: store::group_members(&user_store, &state.config.operator_group),
|
||||
operators: store::group_members(&user_store, &config.operator_group),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -444,7 +455,9 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BridgeRequest, Refusal, StatusCode, classify, introspect::Verdict, permit};
|
||||
use super::{
|
||||
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, permit, respond,
|
||||
};
|
||||
|
||||
/// Fail-closed, asserted per variant rather than as one negation:
|
||||
/// splitting blame out of a single denial is exactly the change that
|
||||
|
|
@ -559,4 +572,63 @@ mod tests {
|
|||
"the token is valid; a 401 would send the caller off to mint a new one"
|
||||
);
|
||||
}
|
||||
|
||||
/// A config whose users database holds one operator, `mara`. Every field
|
||||
/// the request path does not read is left empty.
|
||||
fn config_with_one_operator() -> Config {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"swarm-authelia-bridge-respond-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let users_file = dir.join("users.yml");
|
||||
std::fs::write(
|
||||
&users_file,
|
||||
"users:\n mara:\n displayname: mara\n password: \"$argon2id$x\"\n groups: [admins]\n",
|
||||
)
|
||||
.unwrap();
|
||||
Config {
|
||||
bind: String::new(),
|
||||
users_file,
|
||||
authelia_bin: std::path::PathBuf::new(),
|
||||
introspection_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: String::new(),
|
||||
operator_reader_client_id: CONTROLLER.to_owned(),
|
||||
operator_group: "admins".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The request path, not only [`permit`]: a dispatch that skipped the
|
||||
/// check would answer the hive below with the operator list.
|
||||
#[tokio::test]
|
||||
async fn the_request_path_answers_the_operator_list_to_the_controller_only() {
|
||||
let config = config_with_one_operator();
|
||||
let lock = tokio::sync::Mutex::new(());
|
||||
for caller in [Some("hive-alpha"), None] {
|
||||
let refused = respond(&config, &lock, caller, BridgeRequest::ListOperators).await;
|
||||
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{caller:?}");
|
||||
}
|
||||
|
||||
let answered = respond(
|
||||
&config,
|
||||
&lock,
|
||||
Some(CONTROLLER),
|
||||
BridgeRequest::ListOperators,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(answered.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
|
||||
serde_json::json!({"status": "operators", "operators": ["mara"]})
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(config.users_file.parent().unwrap());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue