From 8ca8d878e77cdfa7a49c209fcecc47dac5ecde06 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Thu, 2 Oct 2025 14:34:34 +0100 Subject: [PATCH] Add personal access token and session storage --- Cargo.lock | 1 + ...56577d98074e244a35c0d3be24bc18d9d0daa.json | 15 ++ ...63aa46225245a04d1c7bc24b5275c44a6d58d.json | 15 ++ ...7a18ff07e0b5cd08cc525ac9d5dcceece7311.json | 70 ++++++ ...6f5c701411387c939f6b8a3478b41b3de4f20.json | 46 ++++ ...7e260ba8911123744980e24a52bc9b95bd056.json | 18 ++ ...f063537d5a7f13c48d031367c1d8dba2f8af5.json | 19 ++ ...e1ef2f192ca66f8000d1385626154e5ce4f7e.json | 46 ++++ crates/storage-pg/Cargo.toml | 1 + crates/storage-pg/src/lib.rs | 1 + .../storage-pg/src/personal/access_token.rs | 216 +++++++++++++++++ crates/storage-pg/src/personal/mod.rs | 13 ++ crates/storage-pg/src/personal/session.rs | 218 ++++++++++++++++++ crates/storage-pg/src/repository.rs | 15 ++ crates/storage/src/lib.rs | 1 + crates/storage/src/personal/access_token.rs | 119 ++++++++++ crates/storage/src/personal/mod.rs | 13 ++ crates/storage/src/personal/session.rs | 101 ++++++++ crates/storage/src/repository.rs | 39 ++++ 19 files changed, 967 insertions(+) create mode 100644 crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json create mode 100644 crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json create mode 100644 crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json create mode 100644 crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json create mode 100644 crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json create mode 100644 crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json create mode 100644 crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json create mode 100644 crates/storage-pg/src/personal/access_token.rs create mode 100644 crates/storage-pg/src/personal/mod.rs create mode 100644 crates/storage-pg/src/personal/session.rs create mode 100644 crates/storage/src/personal/access_token.rs create mode 100644 crates/storage/src/personal/mod.rs create mode 100644 crates/storage/src/personal/session.rs diff --git a/Cargo.lock b/Cargo.lock index 253308775..67e00137b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3676,6 +3676,7 @@ dependencies = [ "sea-query", "sea-query-binder", "serde_json", + "sha2", "sqlx", "thiserror 2.0.17", "tracing", diff --git a/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json new file mode 100644 index 000000000..55509569c --- /dev/null +++ b/crates/storage-pg/.sqlx/query-06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_sessions\n SET revoked_at = $2\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "06d67595eeef23d5f2773632e0956577d98074e244a35c0d3be24bc18d9d0daa" +} diff --git a/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json new file mode 100644 index 000000000..5bba6548d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE personal_access_tokens\n SET revoked_at = $2\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e45995714e60b71e0f0158500a63aa46225245a04d1c7bc24b5275c44a6d58d" +} diff --git a/crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json b/crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json new file mode 100644 index 000000000..d7eb2c798 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_session_id\n , owner_user_id\n , actor_user_id\n , scope_list\n , created_at\n , revoked_at\n , human_name\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM personal_sessions\n\n WHERE personal_session_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "owner_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "scope_list", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "human_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "last_active_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_active_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311" +} diff --git a/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json new file mode 100644 index 000000000..66aab4ee6 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE access_token_sha256 = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "90875bdd2f75cdf0dc3f48dc2516f5c701411387c939f6b8a3478b41b3de4f20" +} diff --git a/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json new file mode 100644 index 000000000..3542f8481 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_access_tokens\n (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a0be6c56e470382b9470df414497e260ba8911123744980e24a52bc9b95bd056" +} diff --git a/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json b/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json new file mode 100644 index 000000000..9dec975ca --- /dev/null +++ b/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_sessions\n ( personal_session_id\n , owner_user_id\n , actor_user_id\n , human_name\n , scope_list\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5" +} diff --git a/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json new file mode 100644 index 000000000..2112e7603 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT personal_access_token_id\n , personal_session_id\n , created_at\n , expires_at\n , revoked_at\n\n FROM personal_access_tokens\n\n WHERE personal_access_token_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "personal_access_token_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "personal_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "e1746b33c2f0d10f26332195f78e1ef2f192ca66f8000d1385626154e5ce4f7e" +} diff --git a/crates/storage-pg/Cargo.toml b/crates/storage-pg/Cargo.toml index 149e92fc6..8710ead70 100644 --- a/crates/storage-pg/Cargo.toml +++ b/crates/storage-pg/Cargo.toml @@ -27,6 +27,7 @@ rand.workspace = true sea-query-binder.workspace = true sea-query.workspace = true serde_json.workspace = true +sha2.workspace = true sqlx.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/crates/storage-pg/src/lib.rs b/crates/storage-pg/src/lib.rs index 908058df6..207235667 100644 --- a/crates/storage-pg/src/lib.rs +++ b/crates/storage-pg/src/lib.rs @@ -165,6 +165,7 @@ use sqlx::migrate::Migrator; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod queue; pub mod upstream_oauth2; pub mod user; diff --git a/crates/storage-pg/src/personal/access_token.rs b/crates/storage-pg/src/personal/access_token.rs new file mode 100644 index 000000000..832e867f5 --- /dev/null +++ b/crates/storage-pg/src/personal/access_token.rs @@ -0,0 +1,216 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use mas_storage::personal::PersonalAccessTokenRepository; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{DatabaseError, tracing::ExecuteExt as _}; + +/// An implementation of [`PersonalAccessTokenRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalAccessTokenRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalAccessTokenRepository<'c> { + /// Create a new [`PgPersonalAccessTokenRepository`] from an active + /// PostgreSQL connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct PersonalAccessTokenLookup { + personal_access_token_id: Uuid, + personal_session_id: Uuid, + created_at: DateTime, + expires_at: Option>, + revoked_at: Option>, +} + +impl From for PersonalAccessToken { + fn from(value: PersonalAccessTokenLookup) -> Self { + Self { + id: Ulid::from(value.personal_access_token_id), + session_id: Ulid::from(value.personal_session_id), + created_at: value.created_at, + expires_at: value.expires_at, + revoked_at: value.revoked_at, + } + } +} + +#[async_trait] +impl PersonalAccessTokenRepository for PgPersonalAccessTokenRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_access_token.lookup", + skip_all, + fields( + db.query.text, + personal_access_token.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE personal_access_token_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.find_by_token", + skip_all, + fields( + db.query.text, + ), + err, + )] + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error> { + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let res = sqlx::query_as!( + PersonalAccessTokenLookup, + r#" + SELECT personal_access_token_id + , personal_session_id + , created_at + , expires_at + , revoked_at + + FROM personal_access_tokens + + WHERE access_token_sha256 = $1 + "#, + &token_sha256, + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.into())) + } + + #[tracing::instrument( + name = "db.personal_access_token.add", + skip_all, + fields( + db.query.text, + personal_access_token.id, + %session.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: String, + expires_after: Option, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("personal_access_token.id", tracing::field::display(id)); + + let token_sha256 = Sha256::digest(access_token.as_bytes()).to_vec(); + + let expires_at = expires_after.map(|expires_after| created_at + expires_after); + + sqlx::query!( + r#" + INSERT INTO personal_access_tokens + (personal_access_token_id, personal_session_id, access_token_sha256, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#, + Uuid::from(id), + Uuid::from(session.id), + &token_sha256, + created_at, + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalAccessToken { + id, + session_id: session.id, + created_at, + expires_at, + revoked_at: None, + }) + } + + #[tracing::instrument( + name = "db.personal_access_token.revoke", + skip_all, + fields( + db.query.text, + %access_token.id, + personal_session.id = %access_token.session_id, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + mut access_token: PersonalAccessToken, + ) -> Result { + let revoked_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE personal_access_tokens + SET revoked_at = $2 + WHERE personal_access_token_id = $1 + "#, + Uuid::from(access_token.id), + revoked_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + access_token.revoked_at = Some(revoked_at); + Ok(access_token) + } +} diff --git a/crates/storage-pg/src/personal/mod.rs b/crates/storage-pg/src/personal/mod.rs new file mode 100644 index 000000000..e60daccc4 --- /dev/null +++ b/crates/storage-pg/src/personal/mod.rs @@ -0,0 +1,13 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! A module containing the PostgreSQL implementations of the +//! Personal Access Token / Personal Session repositories + +mod access_token; +mod session; + +pub use access_token::PgPersonalAccessTokenRepository; +pub use session::PgPersonalSessionRepository; diff --git a/crates/storage-pg/src/personal/session.rs b/crates/storage-pg/src/personal/session.rs new file mode 100644 index 000000000..514293ba9 --- /dev/null +++ b/crates/storage-pg/src/personal/session.rs @@ -0,0 +1,218 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + Clock, User, + personal::session::{PersonalSession, SessionState}, +}; +use mas_storage::personal::PersonalSessionRepository; +use oauth2_types::scope::Scope; +use rand::RngCore; +use sqlx::PgConnection; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{DatabaseError, errors::DatabaseInconsistencyError, tracing::ExecuteExt as _}; + +/// An implementation of [`PersonalSessionRepository`] for a PostgreSQL +/// connection +pub struct PgPersonalSessionRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgPersonalSessionRepository<'c> { + /// Create a new [`PgOAuth2SessionRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct PersonalSessionLookup { + personal_session_id: Uuid, + owner_user_id: Uuid, + actor_user_id: Uuid, + human_name: String, + scope_list: Vec, + created_at: DateTime, + revoked_at: Option>, + last_active_at: Option>, + last_active_ip: Option, +} + +impl TryFrom for PersonalSession { + type Error = DatabaseInconsistencyError; + + fn try_from(value: PersonalSessionLookup) -> Result { + let id = Ulid::from(value.personal_session_id); + let scope: Result = value.scope_list.iter().map(|s| s.parse()).collect(); + let scope = scope.map_err(|e| { + DatabaseInconsistencyError::on("personal_sessions") + .column("scope") + .row(id) + .source(e) + })?; + + let state = match value.revoked_at { + None => SessionState::Valid, + Some(revoked_at) => SessionState::Revoked { revoked_at }, + }; + + Ok(PersonalSession { + id, + state, + owner_user_id: Ulid::from(value.owner_user_id), + actor_user_id: Ulid::from(value.actor_user_id), + human_name: value.human_name, + scope, + created_at: value.created_at, + last_active_at: value.last_active_at, + last_active_ip: value.last_active_ip, + }) + } +} + +#[async_trait] +impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.personal_session.lookup", + skip_all, + fields( + db.query.text, + session.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + PersonalSessionLookup, + r#" + SELECT personal_session_id + , owner_user_id + , actor_user_id + , scope_list + , created_at + , revoked_at + , human_name + , last_active_at + , last_active_ip as "last_active_ip: IpAddr" + FROM personal_sessions + + WHERE personal_session_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(session) = res else { return Ok(None) }; + + Ok(Some(session.try_into()?)) + } + + #[tracing::instrument( + name = "db.personal_session.add", + skip_all, + fields( + db.query.text, + session.id, + session.scope = %scope, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner_user: &User, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("session.id", tracing::field::display(id)); + + let scope_list: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + + sqlx::query!( + r#" + INSERT INTO personal_sessions + ( personal_session_id + , owner_user_id + , actor_user_id + , human_name + , scope_list + , created_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + Uuid::from(id), + Uuid::from(owner_user.id), + Uuid::from(actor_user.id), + &human_name, + &scope_list, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(PersonalSession { + id, + state: SessionState::Valid, + owner_user_id: owner_user.id, + actor_user_id: actor_user.id, + human_name, + scope, + created_at, + last_active_at: None, + last_active_ip: None, + }) + } + + #[tracing::instrument( + name = "db.personal_session.revoke", + skip_all, + fields( + db.query.text, + %session.id, + %session.scope, + ), + err, + )] + async fn revoke( + &mut self, + clock: &dyn Clock, + session: PersonalSession, + ) -> Result { + let finished_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE personal_sessions + SET revoked_at = $2 + WHERE personal_session_id = $1 + "#, + Uuid::from(session.id), + finished_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + session + .finish(finished_at) + .map_err(DatabaseError::to_invalid_operation) + } +} diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 7911cd2b6..210d66a02 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -20,6 +20,7 @@ use mas_storage::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::PersonalSessionRepository, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -47,6 +48,7 @@ use crate::{ PgOAuth2ClientRepository, PgOAuth2DeviceCodeGrantRepository, PgOAuth2RefreshTokenRepository, PgOAuth2SessionRepository, }, + personal::{PgPersonalAccessTokenRepository, PgPersonalSessionRepository}, policy_data::PgPolicyDataRepository, queue::{ job::PgQueueJobRepository, schedule::PgQueueScheduleRepository, @@ -328,6 +330,19 @@ where Box::new(PgCompatRefreshTokenRepository::new(self.conn.as_mut())) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> + { + Box::new(PgPersonalAccessTokenRepository::new(self.conn.as_mut())) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgPersonalSessionRepository::new(self.conn.as_mut())) + } + fn queue_worker<'c>(&'c mut self) -> Box + 'c> { Box::new(PgQueueWorkerRepository::new(self.conn.as_mut())) } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 605dea279..7a19f05ac 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -111,6 +111,7 @@ mod utils; pub mod app_session; pub mod compat; pub mod oauth2; +pub mod personal; pub mod policy_data; pub mod queue; pub mod upstream_oauth2; diff --git a/crates/storage/src/personal/access_token.rs b/crates/storage/src/personal/access_token.rs new file mode 100644 index 000000000..4f06bbf34 --- /dev/null +++ b/crates/storage/src/personal/access_token.rs @@ -0,0 +1,119 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::Duration; +use mas_data_model::{ + Clock, + personal::{PersonalAccessToken, session::PersonalSession}, +}; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::repository_impl; + +/// An [`PersonalAccessTokenRepository`] helps interacting with +/// [`PersonalAccessToken`] saved in the storage backend +#[async_trait] +pub trait PersonalAccessTokenRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup an access token by its ID + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Find an access token by its token + /// + /// Returns the access token if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `access_token`: The token of the access token to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + /// Add a new access token to the database + /// + /// Returns the newly created access token + /// + /// # Parameters + /// + /// * `rng`: A random number generator + /// * `clock`: The clock used to generate timestamps + /// * `session`: The session the access token is associated with + /// * `access_token`: The access token to add + /// * `expires_after`: The duration after which the access token expires. If + /// [`None`] the access token never expires + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: String, + expires_after: Option, + ) -> Result; + + /// Revoke an access token + /// + /// Returns the revoked access token + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `access_token`: The access token to revoke + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +} + +repository_impl!(PersonalAccessTokenRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn find_by_token( + &mut self, + access_token: &str, + ) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + session: &PersonalSession, + access_token: String, + expires_after: Option, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + access_token: PersonalAccessToken, + ) -> Result; +); diff --git a/crates/storage/src/personal/mod.rs b/crates/storage/src/personal/mod.rs new file mode 100644 index 000000000..28a33e1a0 --- /dev/null +++ b/crates/storage/src/personal/mod.rs @@ -0,0 +1,13 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Repositories to deal with Personal Sessions and Personal Access Tokens +//! (PATs), which are sessions/access tokens created manually by users for use +//! in scripts, bots and similar applications. + +mod access_token; +mod session; + +pub use self::{access_token::PersonalAccessTokenRepository, session::PersonalSessionRepository}; diff --git a/crates/storage/src/personal/session.rs b/crates/storage/src/personal/session.rs new file mode 100644 index 000000000..7d0a76a37 --- /dev/null +++ b/crates/storage/src/personal/session.rs @@ -0,0 +1,101 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{Clock, Device, User, personal::session::PersonalSession}; +use oauth2_types::scope::Scope; +use rand_core::RngCore; +use ulid::Ulid; + +use crate::repository_impl; + +/// A [`PersonalSessionRepository`] helps interacting with +/// [`PersonalSession`] saved in the storage backend +#[async_trait] +pub trait PersonalSessionRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a Personal session by its ID + /// + /// Returns the Personal session if it exists, `None` otherwise + /// + /// # Parameters + /// + /// * `id`: The ID of the Personal session to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Start a new Personal session + /// + /// Returns the newly created Personal session + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `owner_user`: The user that will own the personal session + /// * `actor_user`: The user that will be represented by the personal + /// session + /// * `device`: The device ID of this session + /// * `human_name`: The human-readable name of the session provided by the + /// client or the user + /// * `scope`: The [`Scope`] of the [`PersonalSession`] + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner_user: &User, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + /// End a Personal session + /// + /// Returns the ended Personal session + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `Personal_session`: The Personal session to end + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; +} + +repository_impl!(PersonalSessionRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + owner_user: &User, + actor_user: &User, + human_name: String, + scope: Scope, + ) -> Result; + + async fn revoke( + &mut self, + clock: &dyn Clock, + personal_session: PersonalSession, + ) -> Result; +); diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index 518769eb1..f6eb191e6 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -18,6 +18,7 @@ use crate::{ OAuth2AccessTokenRepository, OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -214,6 +215,16 @@ pub trait RepositoryAccess: Send { &'c mut self, ) -> Box + 'c>; + /// Get a [`PersonalAccessTokenRepository`] + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c>; + + /// Get a [`PersonalSessionRepository`] + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get a [`QueueWorkerRepository`] fn queue_worker<'c>(&'c mut self) -> Box + 'c>; @@ -247,6 +258,7 @@ mod impls { OAuth2ClientRepository, OAuth2DeviceCodeGrantRepository, OAuth2RefreshTokenRepository, OAuth2SessionRepository, }, + personal::{PersonalAccessTokenRepository, PersonalSessionRepository}, policy_data::PolicyDataRepository, queue::{QueueJobRepository, QueueScheduleRepository, QueueWorkerRepository}, upstream_oauth2::{ @@ -458,6 +470,21 @@ mod impls { )) } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.personal_access_token(), + &mut self.mapper, + )) + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new(self.inner.personal_session(), &mut self.mapper)) + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> { @@ -610,6 +637,18 @@ mod impls { (**self).compat_refresh_token() } + fn personal_access_token<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_access_token() + } + + fn personal_session<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).personal_session() + } + fn queue_worker<'c>( &'c mut self, ) -> Box + 'c> {