cairn_mod/moderators.rs
1//! Moderator identity + role-based authorization primitives shared
2//! between the HTTP admin surface and the `cairn moderator` CLI
3//! (#24).
4//!
5//! Lives outside `server::admin` so the CLI can depend on it
6//! without reaching into a server-internal module. Owns:
7//!
8//! - [`Role`] — enum mirroring the `moderators.role` column's
9//! CHECK constraint (`'mod' | 'admin'`).
10//! - `Moderator` — record struct mapping one row.
11//! - `Error` — thiserror-wrapped DB + corruption variants.
12//! - `add`, `remove`, `list`, `count_admins` — the DB helpers
13//! `cairn moderator` invokes (pub(crate); links omitted because
14//! the public-API docs would treat them as broken).
15
16use sqlx::{Pool, Sqlite};
17
18use crate::writer::epoch_ms_now;
19
20/// Role values persisted in `moderators.role`. The schema CHECK
21/// constrains the column to exactly these two strings, so any
22/// other value in a read means corrupt data, not an unknown role.
23///
24/// Public so the `cairn moderator` CLI in the binary crate can
25/// construct values for the helpers below; helpers themselves stay
26/// `pub(crate)` — moderator-table mutation is not part of the
27/// library's external API.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Role {
30 /// Standard moderator role: can apply, negate, and resolve.
31 Mod,
32 /// Elevated role: everything `Mod` can do, plus
33 /// `tools.cairn.admin.listAuditLog` (§F12).
34 Admin,
35}
36
37impl Role {
38 /// DB-side string representation. Must match the CHECK
39 /// constraint in migrations and the values read at
40 /// `server::admin::common` auth time.
41 pub(crate) fn as_str(self) -> &'static str {
42 match self {
43 Role::Mod => "mod",
44 Role::Admin => "admin",
45 }
46 }
47
48 /// Parse the string stored in `moderators.role`. Returns
49 /// `None` if the value doesn't match the CHECK constraint —
50 /// which should be unreachable in practice and is surfaced
51 /// as [`Error::CorruptRole`] by the helpers below.
52 pub(crate) fn from_db_str(s: &str) -> Option<Role> {
53 match s {
54 "mod" => Some(Role::Mod),
55 "admin" => Some(Role::Admin),
56 _ => None,
57 }
58 }
59}
60
61impl std::fmt::Display for Role {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str(self.as_str())
64 }
65}
66
67/// One row of the `moderators` table, decoded into strongly-typed
68/// fields. `added_by` is nullable: populated for HTTP-admin
69/// insertions via the JWT `iss`, and left NULL for CLI-initiated
70/// inserts (#24 decision C — no attested caller identity at the
71/// CLI boundary).
72///
73/// Public so the `cairn moderator list` orchestrator in the
74/// binary crate can return `Vec<Moderator>`.
75#[derive(Debug, Clone)]
76pub struct Moderator {
77 /// DID identifier of the moderator.
78 pub did: String,
79 /// Role assigned to this DID.
80 pub role: Role,
81 /// Optional attribution: the caller-DID that performed the
82 /// add operation, populated only on HTTP-admin inserts. NULL
83 /// for CLI-initiated inserts (no attested caller identity).
84 pub added_by: Option<String>,
85 /// Unix epoch milliseconds when the row was inserted.
86 pub added_at: i64,
87}
88
89/// Error shape for the helpers in this module. DB errors bubble
90/// through unchanged; [`CorruptRole`](Self::CorruptRole) covers
91/// the CHECK-violating-row case that's nominally unreachable.
92#[derive(Debug, thiserror::Error)]
93pub(crate) enum Error {
94 /// Any sqlx-side failure: connection closed, serialization,
95 /// constraint violation other than the CHECK.
96 #[error("database error: {0}")]
97 Db(#[from] sqlx::Error),
98 /// A `moderators.role` read returned a value outside the
99 /// CHECK-constrained set. Shouldn't happen with an uncorrupted
100 /// DB; surfaced so callers don't silently drop rows.
101 #[error("corrupt moderator row: role value {0:?} violates CHECK constraint on moderators.role")]
102 CorruptRole(String),
103}
104
105pub(crate) type Result<T> = std::result::Result<T, Error>;
106
107/// Outcome of [`add`].
108#[derive(Debug, PartialEq, Eq)]
109pub(crate) enum AddOutcome {
110 /// No prior row for this DID; new row inserted.
111 Inserted,
112 /// DID already existed with a different role and
113 /// `allow_role_update` was `true`; role was updated.
114 RoleUpdated {
115 /// Role the row held before this call.
116 previous: Role,
117 },
118 /// DID already existed with the requested role; no change
119 /// made. Returned whether or not `allow_role_update` was set.
120 Unchanged,
121 /// DID already existed with a different role and
122 /// `allow_role_update` was `false`; the caller chose not to
123 /// permit role changes via this invocation. No DB write
124 /// occurred.
125 DuplicateBlocked {
126 /// Current role of the existing row.
127 current_role: Role,
128 },
129}
130
131/// Insert or (optionally) update a moderator row.
132///
133/// `added_by` is the caller-DID for attribution, `None` for
134/// CLI-initiated inserts (per #24 decision C). `allow_role_update`
135/// gates whether an existing DID's role can be rewritten by this
136/// call: the `cairn moderator add` CLI path exposes this as the
137/// `--update-role` flag. Same-role re-invocation is always
138/// [`AddOutcome::Unchanged`] — never an error.
139pub(crate) async fn add(
140 pool: &Pool<Sqlite>,
141 did: &str,
142 role: Role,
143 added_by: Option<&str>,
144 allow_role_update: bool,
145) -> Result<AddOutcome> {
146 let existing_role: Option<Role> =
147 sqlx::query_scalar!("SELECT role FROM moderators WHERE did = ?1", did)
148 .fetch_optional(pool)
149 .await?
150 .map(|s| Role::from_db_str(&s).ok_or(Error::CorruptRole(s)))
151 .transpose()?;
152
153 match existing_role {
154 Some(current) if current == role => Ok(AddOutcome::Unchanged),
155 Some(current) if !allow_role_update => Ok(AddOutcome::DuplicateBlocked {
156 current_role: current,
157 }),
158 Some(current) => {
159 let role_str = role.as_str();
160 sqlx::query!(
161 "UPDATE moderators SET role = ?1 WHERE did = ?2",
162 role_str,
163 did
164 )
165 .execute(pool)
166 .await?;
167 Ok(AddOutcome::RoleUpdated { previous: current })
168 }
169 None => {
170 // CLI inserts have no attested caller identity; added_by
171 // is set only for HTTP-admin attribution via JWT iss.
172 let now = epoch_ms_now();
173 let role_str = role.as_str();
174 sqlx::query!(
175 "INSERT INTO moderators (did, role, added_by, added_at) VALUES (?1, ?2, ?3, ?4)",
176 did,
177 role_str,
178 added_by,
179 now,
180 )
181 .execute(pool)
182 .await?;
183 Ok(AddOutcome::Inserted)
184 }
185 }
186}
187
188/// Outcome of [`remove`].
189#[derive(Debug, PartialEq, Eq)]
190pub(crate) enum RemoveOutcome {
191 /// Row existed and was deleted.
192 Removed,
193 /// No row for the given DID — nothing to remove.
194 NotFound,
195}
196
197/// Delete a moderator row. See [`RemoveOutcome`] for the
198/// not-found-vs-removed distinction; `cairn moderator remove`
199/// maps `NotFound` to a usage-exit-code error, not a silent
200/// success.
201pub(crate) async fn remove(pool: &Pool<Sqlite>, did: &str) -> Result<RemoveOutcome> {
202 let res = sqlx::query!("DELETE FROM moderators WHERE did = ?1", did)
203 .execute(pool)
204 .await?;
205 if res.rows_affected() == 0 {
206 Ok(RemoveOutcome::NotFound)
207 } else {
208 Ok(RemoveOutcome::Removed)
209 }
210}
211
212/// List all moderators, optionally filtered to a single role.
213/// Ordered by `added_at ASC, did ASC` for deterministic output in
214/// tests and in the CLI's tabular rendering.
215pub(crate) async fn list(pool: &Pool<Sqlite>, role_filter: Option<Role>) -> Result<Vec<Moderator>> {
216 // The two queries share a projection but differ in the WHERE
217 // clause; kept as two explicit call sites rather than a runtime
218 // SQL-concat because `sqlx::query!` checks each string against
219 // the compile-time cache.
220 let rows = match role_filter {
221 Some(r) => {
222 let role_str = r.as_str();
223 sqlx::query!(
224 "SELECT did, role, added_by, added_at FROM moderators
225 WHERE role = ?1 ORDER BY added_at ASC, did ASC",
226 role_str
227 )
228 .fetch_all(pool)
229 .await?
230 .into_iter()
231 .map(|r| (r.did, r.role, r.added_by, r.added_at))
232 .collect::<Vec<_>>()
233 }
234 None => sqlx::query!(
235 "SELECT did, role, added_by, added_at FROM moderators
236 ORDER BY added_at ASC, did ASC"
237 )
238 .fetch_all(pool)
239 .await?
240 .into_iter()
241 .map(|r| (r.did, r.role, r.added_by, r.added_at))
242 .collect::<Vec<_>>(),
243 };
244
245 rows.into_iter()
246 .map(|(did, role, added_by, added_at)| {
247 let role = Role::from_db_str(&role).ok_or(Error::CorruptRole(role))?;
248 Ok(Moderator {
249 did,
250 role,
251 added_by,
252 added_at,
253 })
254 })
255 .collect()
256}
257
258/// Count admin-role rows. Used by `cairn moderator remove` to
259/// block removing the last admin (unless `--force` is set) — see
260/// #24 decision 5.
261pub(crate) async fn count_admins(pool: &Pool<Sqlite>) -> Result<i64> {
262 let n = sqlx::query_scalar!("SELECT COUNT(*) FROM moderators WHERE role = 'admin'")
263 .fetch_one(pool)
264 .await?;
265 Ok(n)
266}