diff --git a/crates/data-model/src/personal/session.rs b/crates/data-model/src/personal/session.rs index f8904f810..ddca07590 100644 --- a/crates/data-model/src/personal/session.rs +++ b/crates/data-model/src/personal/session.rs @@ -10,7 +10,7 @@ use oauth2_types::scope::Scope; use serde::Serialize; use ulid::Ulid; -use crate::InvalidTransitionError; +use crate::{Client, InvalidTransitionError, User}; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub enum SessionState { @@ -74,10 +74,10 @@ impl SessionState { pub struct PersonalSession { pub id: Ulid, pub state: SessionState, - pub owner_user_id: Ulid, + pub owner: PersonalSessionOwner, pub actor_user_id: Ulid, pub human_name: String, - /// The scope for the session, identical to OAuth2 sessions. + /// The scope for the session, identical to OAuth 2 sessions. /// May or may not include a device scope /// (personal sessions can be deviceless). pub scope: Scope, @@ -86,6 +86,27 @@ pub struct PersonalSession { pub last_active_ip: Option, } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)] +pub enum PersonalSessionOwner { + /// The personal session is owned by the user with the given `user_id`. + User(Ulid), + /// The personal session is owned by the OAuth 2 Client with the given + /// `oauth2_client_id`. + OAuth2Client(Ulid), +} + +impl<'a> From<&'a User> for PersonalSessionOwner { + fn from(value: &'a User) -> Self { + PersonalSessionOwner::User(value.id) + } +} + +impl<'a> From<&'a Client> for PersonalSessionOwner { + fn from(value: &'a Client) -> Self { + PersonalSessionOwner::OAuth2Client(value.id) + } +} + impl std::ops::Deref for PersonalSession { type Target = SessionState; diff --git a/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json new file mode 100644 index 000000000..83400921a --- /dev/null +++ b/crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO personal_sessions\n ( personal_session_id\n , owner_user_id\n , owner_oauth2_client_id\n , actor_user_id\n , human_name\n , scope_list\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2" +} diff --git a/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json b/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json deleted file mode 100644 index 9dec975ca..000000000 --- a/crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json similarity index 64% rename from crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json rename to crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json index d7eb2c798..b46904ccb 100644 --- a/crates/storage-pg/.sqlx/query-8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311.json +++ b/crates/storage-pg/.sqlx/query-fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2.json @@ -1,6 +1,6 @@ { "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 ", + "query": "\n SELECT personal_session_id\n , owner_user_id\n , owner_oauth2_client_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": [ { @@ -15,36 +15,41 @@ }, { "ordinal": 2, - "name": "actor_user_id", + "name": "owner_oauth2_client_id", "type_info": "Uuid" }, { "ordinal": 3, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, "name": "scope_list", "type_info": "TextArray" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 6, "name": "revoked_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "human_name", "type_info": "Text" }, { - "ordinal": 7, + "ordinal": 8, "name": "last_active_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "last_active_ip: IpAddr", "type_info": "Inet" } @@ -56,7 +61,8 @@ }, "nullable": [ false, - false, + true, + true, false, false, false, @@ -66,5 +72,5 @@ true ] }, - "hash": "8816802493ba098c0705b8a8fa87a18ff07e0b5cd08cc525ac9d5dcceece7311" + "hash": "fd32368fa6cd16a9704cdea54f7729681d450669563dd1178c492ffce51e5ff2" } diff --git a/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql index 62df7bc3e..0e113b156 100644 --- a/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql +++ b/crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql @@ -7,7 +7,16 @@ -- themselves, allowing tokens to be regenerated whilst still retaining a persistent identifier for them. CREATE TABLE personal_sessions ( personal_session_id UUID NOT NULL PRIMARY KEY, - owner_user_id UUID NOT NULL REFERENCES users(user_id), + + -- If this session is owned by a user, the ID of the user. + -- Null otherwise. + owner_user_id UUID REFERENCES users(user_id), + + -- If this session is owned by an OAuth 2 Client (via Client Credentials grant), + -- the ID of the owning client. + -- Null otherwise. + owner_oauth2_client_id UUID REFERENCES oauth2_clients(oauth2_client_id), + actor_user_id UUID NOT NULL REFERENCES users(user_id), -- A human-readable label, intended to describe what the session is for. human_name TEXT NOT NULL, @@ -18,13 +27,16 @@ CREATE TABLE personal_sessions ( -- If set, none of the tokens will be valid anymore. revoked_at TIMESTAMP WITH TIME ZONE, last_active_at TIMESTAMP WITH TIME ZONE, - last_active_ip INET + last_active_ip INET, + + -- There must be exactly one owner. + CONSTRAINT personal_sessions_exactly_one_owner CHECK ((owner_user_id IS NULL) <> (owner_oauth2_client_id IS NULL)) ); -- Individual tokens. CREATE TABLE personal_access_tokens ( - -- The family this access token belongs to. personal_access_token_id UUID NOT NULL PRIMARY KEY, + -- The session this access token belongs to. personal_session_id UUID NOT NULL REFERENCES personal_sessions(personal_session_id), -- SHA256 of the access token. -- This is a lightweight measure to stop a database backup (or other @@ -51,5 +63,6 @@ CREATE UNIQUE INDEX ON personal_access_tokens (personal_session_id) WHERE revoke -- Add indices to satisfy foreign key backward checks -- (and likely filter queries) -CREATE INDEX ON personal_sessions (owner_user_id); +CREATE INDEX ON personal_sessions (owner_user_id) WHERE owner_user_id IS NOT NULL; +CREATE INDEX ON personal_sessions (owner_oauth2_client_id) WHERE owner_oauth2_client_id IS NOT NULL; CREATE INDEX ON personal_sessions (actor_user_id); diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index c2198434e..947d1a86f 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -114,6 +114,8 @@ pub enum PersonalSessions { Table, PersonalSessionId, OwnerUserId, + #[iden = "owner_oauth2_client_id"] + OwnerOAuth2ClientId, ActorUserId, HumanName, ScopeList, diff --git a/crates/storage-pg/src/personal/mod.rs b/crates/storage-pg/src/personal/mod.rs index 3b19bcfe2..cc2e2413f 100644 --- a/crates/storage-pg/src/personal/mod.rs +++ b/crates/storage-pg/src/personal/mod.rs @@ -15,7 +15,9 @@ pub use session::PgPersonalSessionRepository; #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::{Clock, Device, clock::MockClock}; + use mas_data_model::{ + Clock, Device, clock::MockClock, personal::session::PersonalSessionOwner, + }; use mas_storage::{ Pagination, RepositoryAccess, personal::{ @@ -84,14 +86,14 @@ mod tests { .add( &mut rng, &clock, - &admin_user, + (&admin_user).into(), &bot_user, "Test Personal Session".to_owned(), scope.clone(), ) .await .unwrap(); - assert_eq!(session.owner_user_id, admin_user.id); + assert_eq!(session.owner, PersonalSessionOwner::User(admin_user.id)); assert_eq!(session.actor_user_id, bot_user.id); assert!(session.is_valid()); assert!(!session.is_revoked()); @@ -128,7 +130,10 @@ mod tests { .unwrap() .expect("personal session not found"); assert_eq!(session_lookup.id, session.id); - assert_eq!(session_lookup.owner_user_id, admin_user.id); + assert_eq!( + session_lookup.owner, + PersonalSessionOwner::User(admin_user.id) + ); assert_eq!(session_lookup.actor_user_id, bot_user.id); assert_eq!(session_lookup.scope, scope); assert!(session_lookup.is_valid()); @@ -207,7 +212,7 @@ mod tests { .add( &mut rng, &clock, - &admin_user, + (&admin_user).into(), &bot_user, "Test Personal Session".to_owned(), scope, diff --git a/crates/storage-pg/src/personal/session.rs b/crates/storage-pg/src/personal/session.rs index 3b5b0a601..28c725a24 100644 --- a/crates/storage-pg/src/personal/session.rs +++ b/crates/storage-pg/src/personal/session.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ Clock, User, - personal::session::{PersonalSession, SessionState}, + personal::session::{PersonalSession, PersonalSessionOwner, SessionState}, }; use mas_storage::{ Page, Pagination, @@ -54,7 +54,8 @@ impl<'c> PgPersonalSessionRepository<'c> { #[enum_def] struct PersonalSessionLookup { personal_session_id: Uuid, - owner_user_id: Uuid, + owner_user_id: Option, + owner_oauth2_client_id: Option, actor_user_id: Uuid, human_name: String, scope_list: Vec, @@ -88,10 +89,23 @@ impl TryFrom for PersonalSession { Some(revoked_at) => SessionState::Revoked { revoked_at }, }; + let owner = match (value.owner_user_id, value.owner_oauth2_client_id) { + (Some(owner_user_id), None) => PersonalSessionOwner::User(Ulid::from(owner_user_id)), + (None, Some(owner_oauth2_client_id)) => { + PersonalSessionOwner::OAuth2Client(Ulid::from(owner_oauth2_client_id)) + } + _ => { + // should be impossible (CHECK constraint in Postgres prevents it) + return Err(DatabaseInconsistencyError::on("personal_sessions") + .column("owner_user_id, owner_oauth2_client_id") + .row(id)); + } + }; + Ok(PersonalSession { id, state, - owner_user_id: Ulid::from(value.owner_user_id), + owner, actor_user_id: Ulid::from(value.actor_user_id), human_name: value.human_name, scope, @@ -121,6 +135,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { r#" SELECT personal_session_id , owner_user_id + , owner_oauth2_client_id , actor_user_id , scope_list , created_at @@ -157,7 +172,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - owner_user: &User, + owner: PersonalSessionOwner, actor_user: &User, human_name: String, scope: Scope, @@ -168,20 +183,27 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { let scope_list: Vec = scope.iter().map(|s| s.as_str().to_owned()).collect(); + let (owner_user_id, owner_oauth2_client_id) = match owner { + PersonalSessionOwner::User(ulid) => (Some(Uuid::from(ulid)), None), + PersonalSessionOwner::OAuth2Client(ulid) => (None, Some(Uuid::from(ulid))), + }; + sqlx::query!( r#" INSERT INTO personal_sessions ( personal_session_id , owner_user_id + , owner_oauth2_client_id , actor_user_id , human_name , scope_list , created_at ) - VALUES ($1, $2, $3, $4, $5, $6) + VALUES ($1, $2, $3, $4, $5, $6, $7) "#, Uuid::from(id), - Uuid::from(owner_user.id), + owner_user_id, + owner_oauth2_client_id, Uuid::from(actor_user.id), &human_name, &scope_list, @@ -194,7 +216,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { Ok(PersonalSession { id, state: SessionState::Valid, - owner_user_id: owner_user.id, + owner, actor_user_id: actor_user.id, human_name, scope, @@ -262,6 +284,13 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> { Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)), PersonalSessionLookupIden::OwnerUserId, ) + .expr_as( + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )), + PersonalSessionLookupIden::OwnerOauth2ClientId, + ) .expr_as( Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)), PersonalSessionLookupIden::ActorUserId, @@ -341,6 +370,13 @@ impl Filter for PersonalSessionFilter<'_> { Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)) .eq(Uuid::from(user.id)) })) + .add_option(self.owner_oauth2_client().map(|client| { + Expr::col(( + PersonalSessions::Table, + PersonalSessions::OwnerOAuth2ClientId, + )) + .eq(Uuid::from(client.id)) + })) .add_option(self.actor_user().map(|user| { Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)) .eq(Uuid::from(user.id)) diff --git a/crates/storage/src/personal/session.rs b/crates/storage/src/personal/session.rs index aedb939e0..c090efa30 100644 --- a/crates/storage/src/personal/session.rs +++ b/crates/storage/src/personal/session.rs @@ -5,7 +5,10 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{Clock, Device, User, personal::session::PersonalSession}; +use mas_data_model::{ + Client, Clock, Device, User, + personal::session::{PersonalSession, PersonalSessionOwner}, +}; use oauth2_types::scope::Scope; use rand_core::RngCore; use ulid::Ulid; @@ -55,7 +58,7 @@ pub trait PersonalSessionRepository: Send + Sync { &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - owner_user: &User, + owner: PersonalSessionOwner, actor_user: &User, human_name: String, scope: Scope, @@ -115,7 +118,7 @@ repository_impl!(PersonalSessionRepository: &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - owner_user: &User, + owner: PersonalSessionOwner, actor_user: &User, human_name: String, scope: Scope, @@ -140,6 +143,7 @@ repository_impl!(PersonalSessionRepository: #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct PersonalSessionFilter<'a> { owner_user: Option<&'a User>, + owner_oauth2_client: Option<&'a Client>, actor_user: Option<&'a User>, device: Option<&'a Device>, state: Option, @@ -173,6 +177,21 @@ impl<'a> PersonalSessionFilter<'a> { self } + /// Get the owner user filter + /// + /// Returns [`None`] if no user filter was set + #[must_use] + pub fn owner_oauth2_client(&self) -> Option<&'a Client> { + self.owner_oauth2_client + } + + /// List sessions owned by a specific user + #[must_use] + pub fn for_owner_oauth2_client(mut self, client: &'a Client) -> Self { + self.owner_oauth2_client = Some(client); + self + } + /// Get the owner user filter /// /// Returns [`None`] if no user filter was set