Skip to content

Commit 0b6055d

Browse files
committed
ref: impl SQL helpers for User and Role
1 parent d6308bc commit 0b6055d

File tree

9 files changed

+248
-24
lines changed

9 files changed

+248
-24
lines changed

src/api/schema/columns/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//! Contains extensions to [`winvoice_adapter::schema::columns`] specific to the [server](crate).
2+
3+
mod user_columns;
4+
5+
pub use user_columns::UserColumns;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//! This module holds data for the columns of the [`User`](crate::api::schema::User) table.
2+
3+
mod columns_to_sql;
4+
mod table_to_sql;
5+
6+
use serde::{Deserialize, Serialize};
7+
use winvoice_adapter::fmt::{TableToSql, WithIdentifier};
8+
9+
/// The names of the columns of the `timesheets` table.
10+
#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
11+
pub struct UserColumns<T>
12+
{
13+
/// The name of the `employee_id` column of the `timesheets` table.
14+
pub employee_id: T,
15+
16+
/// The name of the `id` column of the `timesheets` table.
17+
pub id: T,
18+
}
19+
20+
impl<T> UserColumns<T>
21+
{
22+
/// Add a [scope](ExpenseColumns::scope) using the [default alias](TableToSql::default_alias)
23+
///
24+
/// # See also
25+
///
26+
/// * [`WithIdentifier`].
27+
pub fn default_scope(self) -> UserColumns<WithIdentifier<char, T>>
28+
{
29+
self.scope(Self::DEFAULT_ALIAS)
30+
}
31+
32+
/// Returns a [`UserColumns`] which modifies its fields' [`Display`]
33+
/// implementation to output `{alias}.{column}`.
34+
///
35+
/// # See also
36+
///
37+
/// * [`WithIdentifier`]
38+
#[allow(clippy::missing_const_for_fn)]
39+
pub fn scope<Alias>(self, alias: Alias) -> UserColumns<WithIdentifier<Alias, T>>
40+
where
41+
Alias: Copy,
42+
{
43+
UserColumns {
44+
employee_id: WithIdentifier(alias, self.employee_id),
45+
id: WithIdentifier(alias, self.id),
46+
job_id: WithIdentifier(alias, self.job_id),
47+
time_begin: WithIdentifier(alias, self.time_begin),
48+
time_end: WithIdentifier(alias, self.time_end),
49+
work_notes: WithIdentifier(alias, self.work_notes),
50+
}
51+
}
52+
}
53+
54+
impl UserColumns<&'static str>
55+
{
56+
/// The names of the columns in `organizations` without any aliasing.
57+
pub const fn default() -> Self
58+
{
59+
Self {
60+
id: "id",
61+
employee_id: "employee_id",
62+
job_id: "job_id",
63+
time_begin: "time_begin",
64+
time_end: "time_end",
65+
work_notes: "work_notes",
66+
}
67+
}
68+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use core::fmt::Display;
2+
3+
use sqlx::{Database, QueryBuilder};
4+
5+
use super::TimesheetColumns;
6+
use crate::fmt::{ColumnsToSql, QueryBuilderExt};
7+
8+
impl<Column> ColumnsToSql for TimesheetColumns<Column>
9+
where
10+
Column: Copy + Display,
11+
{
12+
fn push_to<Db>(&self, query: &mut QueryBuilder<Db>)
13+
where
14+
Db: Database,
15+
{
16+
query
17+
.separated(',')
18+
.push(self.employee_id)
19+
.push(self.id)
20+
.push(self.job_id)
21+
.push(self.time_begin)
22+
.push(self.time_end)
23+
.push(self.work_notes);
24+
}
25+
26+
fn push_set_to<Db, Values>(&self, query: &mut QueryBuilder<Db>, values_alias: Values)
27+
where
28+
Db: Database,
29+
Values: Copy + Display,
30+
{
31+
let values_columns = self.scope(values_alias);
32+
query
33+
.push_equal(self.employee_id, values_columns.employee_id)
34+
.push(',')
35+
.push_equal(self.job_id, values_columns.job_id)
36+
.push(',')
37+
.push_equal(self.time_begin, values_columns.time_begin)
38+
.push(',')
39+
.push_equal(self.time_end, values_columns.time_end)
40+
.push(',')
41+
.push_equal(self.work_notes, values_columns.work_notes);
42+
}
43+
44+
fn push_update_where_to<Db, Table, Values>(
45+
&self,
46+
query: &mut QueryBuilder<Db>,
47+
table_alias: Table,
48+
values_alias: Values,
49+
) where
50+
Db: Database,
51+
Table: Copy + Display,
52+
Values: Copy + Display,
53+
{
54+
query.push_equal(self.scope(table_alias).id, self.scope(values_alias).id);
55+
}
56+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use winvoice_adapter::fmt::TableToSql;
2+
3+
use super::UserColumns;
4+
5+
impl<T> TableToSql for UserColumns<T>
6+
{
7+
const DEFAULT_ALIAS: char = 'U';
8+
const TABLE_NAME: &'static str = "users";
9+
}

src/api/schema/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
//! Contains extensions to the [`winvoice_schema`] which are specific to the [server](crate).
22
3+
pub mod columns;
4+
mod role;
35
mod user;
46

7+
pub use role::Role;
58
pub use user::User;

src/api/schema/role.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//! Contains information about the [`Role`] of a [`User`](super::User).
2+
3+
use core::time::Duration;
4+
5+
use serde::{Deserialize, Serialize};
6+
use winvoice_schema::Id;
7+
8+
/// Corresponds to the `users` table in the [`winvoice-server`](crate) database.
9+
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
10+
#[cfg_attr(feature = "bin", derive(sqlx::FromRow))]
11+
pub struct Role
12+
{
13+
/// The unique identity of the [`Role`].
14+
id: Id,
15+
16+
/// The name of the [`Role`].
17+
name: String,
18+
19+
/// How frequent password rotation must occur for [`User`](super::User) with this [`Role`].
20+
///
21+
/// [`None`] indicates that the password lasts forever.
22+
password_ttl: Option<Duration>,
23+
}
24+
25+
impl Role
26+
{
27+
/// Create a new [`Role`].
28+
pub fn new(id: Id, name: String, password_ttl: Option<Duration>) -> Self
29+
{
30+
Self { id, name, password_ttl }
31+
}
32+
33+
/// The unique identity of the [`Role`].
34+
pub fn id(&self) -> i64
35+
{
36+
self.id
37+
}
38+
39+
/// The name of the [`Role`].
40+
pub fn name(&self) -> &str
41+
{
42+
self.name.as_ref()
43+
}
44+
45+
/// How frequent password rotation must occur for [`User`](super::User) with this [`Role`].
46+
///
47+
/// [`None`] indicates that the password lasts forever.
48+
pub fn password_ttl(&self) -> Option<Duration>
49+
{
50+
self.password_ttl
51+
}
52+
}

src/api/schema/user.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
mod auth_user;
66

7-
use sqlx::FromRow;
8-
use winvoice_schema::Id;
7+
use serde::{Deserialize, Serialize};
8+
use winvoice_schema::{
9+
chrono::{DateTime, Utc},
10+
Id,
11+
};
912

1013
/// Corresponds to the `users` table in the [`winvoice-server`](crate) database.
11-
#[derive(Clone, Debug, Default, Eq, FromRow, Hash, Ord, PartialEq, PartialOrd)]
14+
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
15+
#[cfg_attr(feature = "bin", derive(sqlx::FromRow))]
1216
pub struct User
1317
{
1418
/// The [`User`]'s [`Employee`](winvoice_schema::Employee) [`Id`], if they are employed.
@@ -23,6 +27,9 @@ pub struct User
2327
/// Get the [`User`]'s [`argon2`]-hashed password.
2428
password: String,
2529

30+
/// The [`DateTime`] that the `password` was set. Used to enforce password rotation.
31+
password_expires: Option<DateTime<Utc>>,
32+
2633
/// Get the [`User`]'s username.
2734
username: String,
2835
}
@@ -35,10 +42,11 @@ impl User
3542
id: Id,
3643
role: String,
3744
password: String,
45+
password_expires: Option<DateTime<Utc>>,
3846
username: String,
3947
) -> Self
4048
{
41-
Self { employee_id, id, role, password, username }
49+
Self { employee_id, id, role, password, password_expires, username }
4250
}
4351

4452
/// The [`User`]'s [`Employee`](winvoice_schema::Employee) [`Id`], if they are employed.
@@ -65,6 +73,12 @@ impl User
6573
self.password.as_ref()
6674
}
6775

76+
/// Get the [`DateTime`] that the `password` was set. Used to enforce password rotation.
77+
pub fn password_expires(&self) -> Option<DateTime<Utc>>
78+
{
79+
self.password_set
80+
}
81+
6882
/// Get the [`User`]'s username.
6983
pub fn username(&self) -> &str
7084
{

src/args.rs

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ impl Args
149149
RustlsConfig::from_pem_file(self.certificate, self.key).err_into::<DynError>(),
150150
)?;
151151

152-
init_watchman(permissions.clone(), model_path, policy_path).await?;
152+
if let Err(e) = init_watchman(permissions.clone(), model_path, policy_path).await
153+
{
154+
tracing::error!("Failed to enable hot-reloading permissions: {e}");
155+
}
153156

154157
match self.command
155158
{
@@ -215,7 +218,7 @@ fn leak_string(s: String) -> &'static str
215218
///
216219
/// This allows [`winvoice-server`](crate)'s permissions to be hot-reloaded while the server is
217220
/// running.
218-
#[instrument(level = Level::INFO, fields(permissions, model_path, policy_path))]
221+
#[instrument(level = "trace")]
219222
async fn init_watchman(
220223
permissions: Lock<Enforcer>,
221224
model_path: Option<&'static str>,
@@ -252,36 +255,44 @@ async fn init_watchman(
252255
tracing::info!("Watching for file changes");
253256
loop
254257
{
255-
match subscription.next().await?
258+
match subscription.next().await
256259
{
257-
SubscriptionData::Canceled =>
260+
Ok(SubscriptionData::Canceled) =>
258261
{
259-
tracing::info!(
262+
tracing::error!(
260263
"Watchman stopped unexpectedly. Hot reloading permissions is disabled."
261264
);
262265
break;
263266
},
264-
SubscriptionData::FilesChanged(query) =>
267+
Ok(SubscriptionData::FilesChanged(query)) =>
265268
{
266-
tracing::trace!("Notified of file change: {query:#?}");
269+
tracing::debug!("Notified of file change: {query:#?}");
267270
let mut p = permissions.write().await;
268271
*p = match Enforcer::new(model_path, policy_path).await
269272
{
270273
Ok(e) => e,
271274
Err(e) =>
272275
{
273-
tracing::debug!("Could not reload permissions: {e}");
276+
tracing::info!("Could not reload permissions: {e}");
274277
continue;
275278
},
276279
};
277280
},
278-
_ => (),
281+
Ok(event) => tracing::trace!("Notified of ignored event: {event}"),
282+
Err(e) =>
283+
{
284+
tracing::error!(
285+
"Encountered an error while watching for file changes: {e}. Hot \
286+
reloading permissions is disabled"
287+
);
288+
break;
289+
},
279290
}
280291
}
281292

282293
Ok::<_, watchman_client::Error>(())
283294
}
284-
.instrument(tracing::info_span!("hot_reload_permissions")),
295+
.instrument(tracing::error_span!("hot_reload_permissions")),
285296
);
286297

287298
Ok(())

src/server/auth/init_users_table.rs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,41 @@
11
//! Contains the [`InitUsersTable`] trait, and implementations for various [`Database`]s.
22
3-
use sqlx::{Database, Executor, Result};
3+
use sqlx::{Database, Pool, Result};
44

55
/// Initialize the `users`
66
#[async_trait::async_trait]
77
pub trait InitUsersTable: Database
88
{
99
/// Initialize the `users` table on the [`Database`]
10-
async fn init_users_table<'conn, C>(connection: C) -> Result<()>
11-
where
12-
C: Executor<'conn, Database = Self>;
10+
async fn init_users_table(pool: &Pool<Db>) -> Result<()>;
1311
}
1412

1513
#[cfg(feature = "postgres")]
1614
#[async_trait::async_trait]
1715
impl InitUsersTable for sqlx::Postgres
1816
{
19-
async fn init_users_table<'conn, C>(connection: C) -> Result<()>
20-
where
21-
C: Executor<'conn, Database = Self>,
17+
async fn init_users_table(pool: &Pool<Db>) -> Result<()>
2218
{
2319
sqlx::query!(
2420
"CREATE TABLE IF NOT EXISTS users
2521
(
2622
employee_id bigint REFERENCES employees(id),
2723
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
28-
role text DEFAULT 'guest',
2924
password text NOT NULL,
30-
username text NOT NULL,
25+
password_expires timestamptz,
26+
role text DEFAULT 'guest',
27+
username text NOT NULL UNIQUE,
28+
);"
29+
)
30+
.execute(connection)
31+
.await?;
3132

32-
CONSTRAINT users__username_uq UNIQUE (username)
33+
sqlx::query!(
34+
"CREATE TABLE IF NOT EXISTS roles
35+
(
36+
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
37+
name text NOT NULL UNIQUE,
38+
password_ttl interval,
3339
);"
3440
)
3541
.execute(connection)

0 commit comments

Comments
 (0)