Support OAuth2 clients as owners of personal sessions
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
20
crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json
generated
Normal file
20
crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -114,6 +114,8 @@ pub enum PersonalSessions {
|
||||
Table,
|
||||
PersonalSessionId,
|
||||
OwnerUserId,
|
||||
#[iden = "owner_oauth2_client_id"]
|
||||
OwnerOAuth2ClientId,
|
||||
ActorUserId,
|
||||
HumanName,
|
||||
ScopeList,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user