Support OAuth2 clients as owners of personal sessions

This commit is contained in:
Olivier 'reivilibre
2025-10-07 16:02:55 +01:00
parent e4dee42cb3
commit b9e1cdb554
9 changed files with 153 additions and 50 deletions

View File

@@ -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<IpAddr>,
}
#[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;

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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);

View File

@@ -114,6 +114,8 @@ pub enum PersonalSessions {
Table,
PersonalSessionId,
OwnerUserId,
#[iden = "owner_oauth2_client_id"]
OwnerOAuth2ClientId,
ActorUserId,
HumanName,
ScopeList,

View File

@@ -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,

View File

@@ -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<Uuid>,
owner_oauth2_client_id: Option<Uuid>,
actor_user_id: Uuid,
human_name: String,
scope_list: Vec<String>,
@@ -88,10 +89,23 @@ impl TryFrom<PersonalSessionLookup> 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<String> = 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))

View File

@@ -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<PersonalSessionState>,
@@ -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