Skip to main content

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}