use std::sync::Arc;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::control::ControlDb;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ControlOp {
CreateDatabase {
db_id: i64,
name: String,
path: String,
config: String,
created_at: String,
},
CreateToken {
db_id: i64,
token_hash: String,
label: String,
created_at: String,
},
RevokeToken {
token_hash: String,
revoked_at: String,
},
CreateUser {
username: String,
password_hash: String,
role: String,
created_at: String,
},
SetUserRole { username: String, role: String },
SetUserPassword {
username: String,
password_hash: String,
},
DisableUser {
username: String,
disabled_at: String,
},
SetAdminSessionKey { kid: String, value: String },
}
impl ControlOp {
pub fn encode(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec(self).map_err(|e| format!("encode ControlOp: {e}"))
}
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
serde_json::from_slice(bytes).map_err(|e| format!("decode ControlOp: {e}"))
}
pub fn claim_key(&self) -> u64 {
let mut buf = Vec::new();
match self {
ControlOp::CreateDatabase { db_id, name, .. } => {
buf.extend_from_slice(b"ctl:createdb:");
buf.extend_from_slice(&db_id.to_le_bytes());
buf.extend_from_slice(name.as_bytes());
}
ControlOp::CreateToken { token_hash, .. } => {
buf.extend_from_slice(b"ctl:createtok:");
buf.extend_from_slice(token_hash.as_bytes());
}
ControlOp::RevokeToken { token_hash, .. } => {
buf.extend_from_slice(b"ctl:revoketok:");
buf.extend_from_slice(token_hash.as_bytes());
}
ControlOp::CreateUser { username, .. } => {
buf.extend_from_slice(b"ctl:createuser:");
buf.extend_from_slice(username.as_bytes());
}
ControlOp::SetUserRole { username, role } => {
buf.extend_from_slice(b"ctl:userrole:");
buf.extend_from_slice(username.as_bytes());
buf.push(b':');
buf.extend_from_slice(role.as_bytes());
}
ControlOp::SetUserPassword {
username,
password_hash,
} => {
buf.extend_from_slice(b"ctl:userpw:");
buf.extend_from_slice(username.as_bytes());
buf.push(b':');
buf.extend_from_slice(password_hash.as_bytes());
}
ControlOp::DisableUser { username, .. } => {
buf.extend_from_slice(b"ctl:userdisable:");
buf.extend_from_slice(username.as_bytes());
}
ControlOp::SetAdminSessionKey { kid, .. } => {
buf.extend_from_slice(b"ctl:sesskey:");
buf.extend_from_slice(kid.as_bytes());
}
}
super::op::fnv1a64(&buf)
}
pub fn audit_action(&self) -> (&'static str, String) {
match self {
ControlOp::CreateDatabase { name, db_id, .. } => {
("create_database", format!("{name} (#{db_id})"))
}
ControlOp::CreateToken { db_id, label, .. } => {
("mint_token", format!("db #{db_id} [{label}]"))
}
ControlOp::RevokeToken { .. } => ("revoke_token", String::new()),
ControlOp::CreateUser { username, role, .. } => {
("create_user", format!("{username} ({role})"))
}
ControlOp::SetUserRole { username, role } => {
("set_user_role", format!("{username} -> {role}"))
}
ControlOp::SetUserPassword { username, .. } => ("set_user_password", username.clone()),
ControlOp::DisableUser { username, .. } => ("disable_user", username.clone()),
ControlOp::SetAdminSessionKey { kid, .. } => ("rotate_session_key", kid.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlEnvelope {
#[serde(default)]
pub actor: String,
#[serde(default)]
pub at: String,
pub op: ControlOp,
}
impl ControlEnvelope {
pub fn new(actor: impl Into<String>, op: ControlOp) -> Self {
Self {
actor: actor.into(),
at: chrono_now_rfc3339(),
op,
}
}
pub fn encode(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec(self).map_err(|e| format!("encode ControlEnvelope: {e}"))
}
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
match serde_json::from_slice::<ControlEnvelope>(bytes) {
Ok(env) => Ok(env),
Err(_) => ControlOp::decode(bytes).map(|op| ControlEnvelope {
actor: String::new(),
at: String::new(),
op,
}),
}
}
pub fn claim_key(&self) -> u64 {
self.op.claim_key()
}
}
pub struct ControlApplySink {
control: Arc<Mutex<ControlDb>>,
}
impl ControlApplySink {
pub fn new(control: Arc<Mutex<ControlDb>>) -> Self {
Self { control }
}
pub fn apply(&self, index: u64, env: &ControlEnvelope) -> Result<(), String> {
let db = self.control.lock();
if !env.actor.is_empty() {
let (action, target) = env.op.audit_action();
let at = if env.at.is_empty() {
chrono_now_rfc3339()
} else {
env.at.clone()
};
db.apply_audit(index, &env.actor, action, &target, &at)
.map_err(|e| format!("control audit write at {index}: {e}"))?;
}
let op = &env.op;
match op {
ControlOp::CreateDatabase {
db_id,
name,
path,
config,
created_at,
} => db
.apply_create_database(*db_id, name, path, config, created_at)
.map(|_| ())
.map_err(|e| format!("control apply CreateDatabase({name}): {e}")),
ControlOp::CreateToken {
db_id,
token_hash,
label,
created_at,
} => db
.apply_create_token(token_hash, *db_id, label, created_at)
.map(|_| ())
.map_err(|e| format!("control apply CreateToken(db={db_id}): {e}")),
ControlOp::RevokeToken {
token_hash,
revoked_at,
} => db
.apply_revoke_token(token_hash, revoked_at)
.map(|_| ())
.map_err(|e| format!("control apply RevokeToken: {e}")),
ControlOp::CreateUser {
username,
password_hash,
role,
created_at,
} => db
.apply_create_user(username, password_hash, role, created_at)
.map(|_| ())
.map_err(|e| format!("control apply CreateUser({username}): {e}")),
ControlOp::SetUserRole { username, role } => db
.apply_set_user_role(index, username, role)
.map(|_| ())
.map_err(|e| format!("control apply SetUserRole({username}): {e}")),
ControlOp::SetUserPassword {
username,
password_hash,
} => db
.apply_set_user_password(index, username, password_hash)
.map(|_| ())
.map_err(|e| format!("control apply SetUserPassword({username}): {e}")),
ControlOp::DisableUser {
username,
disabled_at,
} => db
.apply_disable_user(index, username, disabled_at)
.map(|_| ())
.map_err(|e| format!("control apply DisableUser({username}): {e}")),
ControlOp::SetAdminSessionKey { kid, value } => db
.apply_set_admin_session_key(kid, value)
.map_err(|e| format!("control apply SetAdminSessionKey({kid}): {e}")),
}
}
}
fn chrono_now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_op_round_trips() {
let op = ControlOp::CreateToken {
db_id: 7,
token_hash: "abc123".into(),
label: "svc".into(),
created_at: "2026-07-27T00:00:00Z".into(),
};
let bytes = op.encode().unwrap();
assert_eq!(ControlOp::decode(&bytes).unwrap(), op);
}
#[test]
fn claim_keys_are_distinct_per_identity() {
let a = ControlOp::CreateToken {
db_id: 1,
token_hash: "h1".into(),
label: String::new(),
created_at: String::new(),
};
let b = ControlOp::RevokeToken {
token_hash: "h1".into(),
revoked_at: String::new(),
};
assert_ne!(a.claim_key(), b.claim_key());
}
#[test]
fn apply_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let db = ControlDb::open(&tmp.path().join("control.db")).unwrap();
let id = db.next_database_id().unwrap();
db.apply_create_database(id, "acme", "/dev/null", "{}", "2026-07-27T00:00:00Z")
.unwrap();
let sink = ControlApplySink::new(Arc::new(Mutex::new(db)));
let tok = ControlOp::CreateToken {
db_id: id,
token_hash: "deadbeef".into(),
label: "t".into(),
created_at: "2026-07-27T00:00:00Z".into(),
};
let env = ControlEnvelope::new("tester", tok);
sink.apply(10, &env).unwrap();
sink.apply(10, &env).unwrap();
assert_eq!(
sink.control.lock().validate_token("deadbeef").unwrap(),
Some(id)
);
assert_eq!(sink.control.lock().list_audit(10).unwrap().len(), 1);
}
}