You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-06 06:02:40 +03:00
Setup a repository to track user terms agreements
This commit is contained in:
17
crates/storage-pg/.sqlx/query-037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf.json
generated
Normal file
17
crates/storage-pg/.sqlx/query-037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO user_terms (user_terms_id, user_id, terms_url, created_at)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, terms_url) DO NOTHING\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Timestamptz"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "037fae6964130343453ef607791c4c3deaa01b5aaa091d3a3487caf3e2634daf"
|
||||||
|
}
|
32
crates/storage-pg/migrations/20240207100003_user_terms.sql
Normal file
32
crates/storage-pg/migrations/20240207100003_user_terms.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
--
|
||||||
|
-- Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
-- you may not use this file except in compliance with the License.
|
||||||
|
-- You may obtain a copy of the License at
|
||||||
|
--
|
||||||
|
-- http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
--
|
||||||
|
-- Unless required by applicable law or agreed to in writing, software
|
||||||
|
-- distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
-- See the License for the specific language governing permissions and
|
||||||
|
-- limitations under the License.
|
||||||
|
|
||||||
|
-- Track when users have accepted the terms of service, and which version they accepted.
|
||||||
|
CREATE TABLE user_terms (
|
||||||
|
"user_terms_id" UUID NOT NULL
|
||||||
|
PRIMARY KEY,
|
||||||
|
|
||||||
|
-- The user who accepted the terms of service.
|
||||||
|
"user_id" UUID NOT NULL
|
||||||
|
REFERENCES users (user_id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The URL of the terms of service that the user accepted.
|
||||||
|
"terms_url" TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- When the user accepted the terms of service.
|
||||||
|
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
|
||||||
|
-- Unique constraint to ensure that a user can only accept a given version of the terms once.
|
||||||
|
UNIQUE ("user_id", "terms_url")
|
||||||
|
);
|
@@ -54,7 +54,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
|
PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository,
|
||||||
PgUserRepository,
|
PgUserRepository, PgUserTermsRepository,
|
||||||
},
|
},
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
};
|
};
|
||||||
@@ -179,6 +179,12 @@ where
|
|||||||
Box::new(PgUserPasswordRepository::new(self.conn.as_mut()))
|
Box::new(PgUserPasswordRepository::new(self.conn.as_mut()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_terms<'c>(
|
||||||
|
&'c mut self,
|
||||||
|
) -> Box<dyn mas_storage::user::UserTermsRepository<Error = Self::Error> + 'c> {
|
||||||
|
Box::new(PgUserTermsRepository::new(self.conn.as_mut()))
|
||||||
|
}
|
||||||
|
|
||||||
fn browser_session<'c>(
|
fn browser_session<'c>(
|
||||||
&'c mut self,
|
&'c mut self,
|
||||||
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
||||||
|
@@ -29,13 +29,14 @@ use crate::{tracing::ExecuteExt, DatabaseError};
|
|||||||
mod email;
|
mod email;
|
||||||
mod password;
|
mod password;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod terms;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
email::PgUserEmailRepository, password::PgUserPasswordRepository,
|
email::PgUserEmailRepository, password::PgUserPasswordRepository,
|
||||||
session::PgBrowserSessionRepository,
|
session::PgBrowserSessionRepository, terms::PgUserTermsRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// An implementation of [`UserRepository`] for a PostgreSQL connection
|
/// An implementation of [`UserRepository`] for a PostgreSQL connection
|
||||||
|
82
crates/storage-pg/src/user/terms.rs
Normal file
82
crates/storage-pg/src/user/terms.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use mas_data_model::User;
|
||||||
|
use mas_storage::{user::UserTermsRepository, Clock};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sqlx::PgConnection;
|
||||||
|
use ulid::Ulid;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{tracing::ExecuteExt, DatabaseError};
|
||||||
|
|
||||||
|
/// An implementation of [`UserTermsRepository`] for a PostgreSQL connection
|
||||||
|
pub struct PgUserTermsRepository<'c> {
|
||||||
|
conn: &'c mut PgConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'c> PgUserTermsRepository<'c> {
|
||||||
|
/// Create a new [`PgUserTermsRepository`] from an active PostgreSQL
|
||||||
|
/// connection
|
||||||
|
pub fn new(conn: &'c mut PgConnection) -> Self {
|
||||||
|
Self { conn }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'c> UserTermsRepository for PgUserTermsRepository<'c> {
|
||||||
|
type Error = DatabaseError;
|
||||||
|
|
||||||
|
#[tracing::instrument(
|
||||||
|
name = "db.user_terms.accept_terms",
|
||||||
|
skip_all,
|
||||||
|
fields(
|
||||||
|
db.statement,
|
||||||
|
%user.id,
|
||||||
|
user_terms.id,
|
||||||
|
%user_terms.url = terms_url.as_str(),
|
||||||
|
),
|
||||||
|
err,
|
||||||
|
)]
|
||||||
|
async fn accept_terms(
|
||||||
|
&mut self,
|
||||||
|
rng: &mut (dyn RngCore + Send),
|
||||||
|
clock: &dyn Clock,
|
||||||
|
user: &User,
|
||||||
|
terms_url: Url,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
let created_at = clock.now();
|
||||||
|
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
|
||||||
|
tracing::Span::current().record("user_terms.id", tracing::field::display(id));
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO user_terms (user_terms_id, user_id, terms_url, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, terms_url) DO NOTHING
|
||||||
|
"#,
|
||||||
|
Uuid::from(id),
|
||||||
|
Uuid::from(user.id),
|
||||||
|
terms_url.as_str(),
|
||||||
|
created_at,
|
||||||
|
)
|
||||||
|
.traced()
|
||||||
|
.execute(&mut *self.conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
@@ -517,3 +517,58 @@ async fn test_user_session(pool: PgPool) {
|
|||||||
// This time the session is finished
|
// This time the session is finished
|
||||||
assert!(session_lookup.finished_at.is_some());
|
assert!(session_lookup.finished_at.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrator = "crate::MIGRATOR")]
|
||||||
|
async fn test_user_terms(pool: PgPool) {
|
||||||
|
let mut repo = PgRepository::from_pool(&pool).await.unwrap();
|
||||||
|
let mut rng = ChaChaRng::seed_from_u64(42);
|
||||||
|
let clock = MockClock::default();
|
||||||
|
|
||||||
|
let user = repo
|
||||||
|
.user()
|
||||||
|
.add(&mut rng, &clock, "john".to_owned())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Accepting the terms should work
|
||||||
|
repo.user_terms()
|
||||||
|
.accept_terms(
|
||||||
|
&mut rng,
|
||||||
|
&clock,
|
||||||
|
&user,
|
||||||
|
"https://example.com/terms".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Accepting a second time should also work
|
||||||
|
repo.user_terms()
|
||||||
|
.accept_terms(
|
||||||
|
&mut rng,
|
||||||
|
&clock,
|
||||||
|
&user,
|
||||||
|
"https://example.com/terms".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Accepting a different terms should also work
|
||||||
|
repo.user_terms()
|
||||||
|
.accept_terms(
|
||||||
|
&mut rng,
|
||||||
|
&clock,
|
||||||
|
&user,
|
||||||
|
"https://example.com/terms?v=2".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut conn = repo.into_inner();
|
||||||
|
|
||||||
|
// We should have two rows, as the first terms was deduped
|
||||||
|
let res: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_terms")
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res, 2);
|
||||||
|
}
|
||||||
|
@@ -30,7 +30,10 @@ use crate::{
|
|||||||
UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository,
|
UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository,
|
||||||
UpstreamOAuthSessionRepository,
|
UpstreamOAuthSessionRepository,
|
||||||
},
|
},
|
||||||
user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository},
|
user::{
|
||||||
|
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
|
||||||
|
UserTermsRepository,
|
||||||
|
},
|
||||||
MapErr,
|
MapErr,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,6 +149,9 @@ pub trait RepositoryAccess: Send {
|
|||||||
fn user_password<'c>(&'c mut self)
|
fn user_password<'c>(&'c mut self)
|
||||||
-> Box<dyn UserPasswordRepository<Error = Self::Error> + 'c>;
|
-> Box<dyn UserPasswordRepository<Error = Self::Error> + 'c>;
|
||||||
|
|
||||||
|
/// Get an [`UserTermsRepository`]
|
||||||
|
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c>;
|
||||||
|
|
||||||
/// Get a [`BrowserSessionRepository`]
|
/// Get a [`BrowserSessionRepository`]
|
||||||
fn browser_session<'c>(
|
fn browser_session<'c>(
|
||||||
&'c mut self,
|
&'c mut self,
|
||||||
@@ -231,6 +237,7 @@ mod impls {
|
|||||||
},
|
},
|
||||||
user::{
|
user::{
|
||||||
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
|
BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository,
|
||||||
|
UserTermsRepository,
|
||||||
},
|
},
|
||||||
MapErr, Repository, RepositoryTransaction,
|
MapErr, Repository, RepositoryTransaction,
|
||||||
};
|
};
|
||||||
@@ -315,6 +322,10 @@ mod impls {
|
|||||||
Box::new(MapErr::new(self.inner.user_password(), &mut self.mapper))
|
Box::new(MapErr::new(self.inner.user_password(), &mut self.mapper))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c> {
|
||||||
|
Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper))
|
||||||
|
}
|
||||||
|
|
||||||
fn browser_session<'c>(
|
fn browser_session<'c>(
|
||||||
&'c mut self,
|
&'c mut self,
|
||||||
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
||||||
@@ -445,6 +456,10 @@ mod impls {
|
|||||||
(**self).user_password()
|
(**self).user_password()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_terms<'c>(&'c mut self) -> Box<dyn UserTermsRepository<Error = Self::Error> + 'c> {
|
||||||
|
(**self).user_terms()
|
||||||
|
}
|
||||||
|
|
||||||
fn browser_session<'c>(
|
fn browser_session<'c>(
|
||||||
&'c mut self,
|
&'c mut self,
|
||||||
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
) -> Box<dyn BrowserSessionRepository<Error = Self::Error> + 'c> {
|
||||||
|
@@ -24,11 +24,13 @@ use crate::{repository_impl, Clock};
|
|||||||
mod email;
|
mod email;
|
||||||
mod password;
|
mod password;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod terms;
|
||||||
|
|
||||||
pub use self::{
|
pub use self::{
|
||||||
email::{UserEmailFilter, UserEmailRepository},
|
email::{UserEmailFilter, UserEmailRepository},
|
||||||
password::UserPasswordRepository,
|
password::UserPasswordRepository,
|
||||||
session::{BrowserSessionFilter, BrowserSessionRepository},
|
session::{BrowserSessionFilter, BrowserSessionRepository},
|
||||||
|
terms::UserTermsRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A [`UserRepository`] helps interacting with [`User`] saved in the storage
|
/// A [`UserRepository`] helps interacting with [`User`] saved in the storage
|
||||||
|
57
crates/storage/src/user/terms.rs
Normal file
57
crates/storage/src/user/terms.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use mas_data_model::User;
|
||||||
|
use rand_core::RngCore;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::{repository_impl, Clock};
|
||||||
|
|
||||||
|
/// A [`UserTermsRepository`] helps interacting with the terms of service agreed by a [`User`]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserTermsRepository: Send + Sync {
|
||||||
|
/// The error type returned by the repository
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
/// Accept the terms of service by a [`User`]
|
||||||
|
///
|
||||||
|
/// # Parameters
|
||||||
|
///
|
||||||
|
/// * `rng`: A random number generator used to generate IDs
|
||||||
|
/// * `clock`: The clock used to generate timestamps
|
||||||
|
/// * `user`: The [`User`] accepting the terms
|
||||||
|
/// * `terms_url`: The URL of the terms of service the user is accepting
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Self::Error`] if the underlying repository fails
|
||||||
|
async fn accept_terms(
|
||||||
|
&mut self,
|
||||||
|
rng: &mut (dyn RngCore + Send),
|
||||||
|
clock: &dyn Clock,
|
||||||
|
user: &User,
|
||||||
|
terms_url: Url,
|
||||||
|
) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
repository_impl!(UserTermsRepository:
|
||||||
|
async fn accept_terms(
|
||||||
|
&mut self,
|
||||||
|
rng: &mut (dyn RngCore + Send),
|
||||||
|
clock: &dyn Clock,
|
||||||
|
user: &User,
|
||||||
|
terms_url: Url,
|
||||||
|
) -> Result<(), Self::Error>;
|
||||||
|
);
|
Reference in New Issue
Block a user