Skip to content

Commit 9dffe4f

Browse files
committed
Support OAuth2 clients as owners of personal sessions
1 parent 7b784e0 commit 9dffe4f

9 files changed

+153
-50
lines changed

crates/data-model/src/personal/session.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use oauth2_types::scope::Scope;
1010
use serde::Serialize;
1111
use ulid::Ulid;
1212

13-
use crate::InvalidTransitionError;
13+
use crate::{Client, InvalidTransitionError, User};
1414

1515
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
1616
pub enum SessionState {
@@ -74,10 +74,10 @@ impl SessionState {
7474
pub struct PersonalSession {
7575
pub id: Ulid,
7676
pub state: SessionState,
77-
pub owner_user_id: Ulid,
77+
pub owner: PersonalSessionOwner,
7878
pub actor_user_id: Ulid,
7979
pub human_name: String,
80-
/// The scope for the session, identical to OAuth2 sessions.
80+
/// The scope for the session, identical to OAuth 2 sessions.
8181
/// May or may not include a device scope
8282
/// (personal sessions can be deviceless).
8383
pub scope: Scope,
@@ -86,6 +86,27 @@ pub struct PersonalSession {
8686
pub last_active_ip: Option<IpAddr>,
8787
}
8888

89+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize)]
90+
pub enum PersonalSessionOwner {
91+
/// The personal session is owned by the user with the given `user_id`.
92+
User(Ulid),
93+
/// The personal session is owned by the OAuth 2 Client with the given
94+
/// `oauth2_client_id`.
95+
OAuth2Client(Ulid),
96+
}
97+
98+
impl<'a> From<&'a User> for PersonalSessionOwner {
99+
fn from(value: &'a User) -> Self {
100+
PersonalSessionOwner::User(value.id)
101+
}
102+
}
103+
104+
impl<'a> From<&'a Client> for PersonalSessionOwner {
105+
fn from(value: &'a Client) -> Self {
106+
PersonalSessionOwner::OAuth2Client(value.id)
107+
}
108+
}
109+
89110
impl std::ops::Deref for PersonalSession {
90111
type Target = SessionState;
91112

crates/storage-pg/.sqlx/query-109f0c859e123966462f1001aef550e4e12d1778474aba72762d9aa093d21ee2.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/.sqlx/query-c55d8dc9c1d1120ebc2c82e3779f063537d5a7f13c48d031367c1d8dba2f8af5.json

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 15 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/storage-pg/migrations/20250924132713_personal_access_tokens.sql

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
-- themselves, allowing tokens to be regenerated whilst still retaining a persistent identifier for them.
88
CREATE TABLE personal_sessions (
99
personal_session_id UUID NOT NULL PRIMARY KEY,
10-
owner_user_id UUID NOT NULL REFERENCES users(user_id),
10+
11+
-- If this session is owned by a user, the ID of the user.
12+
-- Null otherwise.
13+
owner_user_id UUID REFERENCES users(user_id),
14+
15+
-- If this session is owned by an OAuth 2 Client (via Client Credentials grant),
16+
-- the ID of the owning client.
17+
-- Null otherwise.
18+
owner_oauth2_client_id UUID REFERENCES oauth2_clients(oauth2_client_id),
19+
1120
actor_user_id UUID NOT NULL REFERENCES users(user_id),
1221
-- A human-readable label, intended to describe what the session is for.
1322
human_name TEXT NOT NULL,
@@ -18,13 +27,16 @@ CREATE TABLE personal_sessions (
1827
-- If set, none of the tokens will be valid anymore.
1928
revoked_at TIMESTAMP WITH TIME ZONE,
2029
last_active_at TIMESTAMP WITH TIME ZONE,
21-
last_active_ip INET
30+
last_active_ip INET,
31+
32+
-- There must be exactly one owner.
33+
CONSTRAINT personal_sessions_exactly_one_owner CHECK ((owner_user_id IS NULL) <> (owner_oauth2_client_id IS NULL))
2234
);
2335

2436
-- Individual tokens.
2537
CREATE TABLE personal_access_tokens (
26-
-- The family this access token belongs to.
2738
personal_access_token_id UUID NOT NULL PRIMARY KEY,
39+
-- The session this access token belongs to.
2840
personal_session_id UUID NOT NULL REFERENCES personal_sessions(personal_session_id),
2941
-- SHA256 of the access token.
3042
-- 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
5163

5264
-- Add indices to satisfy foreign key backward checks
5365
-- (and likely filter queries)
54-
CREATE INDEX ON personal_sessions (owner_user_id);
66+
CREATE INDEX ON personal_sessions (owner_user_id) WHERE owner_user_id IS NOT NULL;
67+
CREATE INDEX ON personal_sessions (owner_oauth2_client_id) WHERE owner_oauth2_client_id IS NOT NULL;
5568
CREATE INDEX ON personal_sessions (actor_user_id);

crates/storage-pg/src/iden.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ pub enum PersonalSessions {
114114
Table,
115115
PersonalSessionId,
116116
OwnerUserId,
117+
#[iden = "owner_oauth2_client_id"]
118+
OwnerOAuth2ClientId,
117119
ActorUserId,
118120
HumanName,
119121
ScopeList,

crates/storage-pg/src/personal/mod.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ pub use session::PgPersonalSessionRepository;
1515
#[cfg(test)]
1616
mod tests {
1717
use chrono::Duration;
18-
use mas_data_model::{Clock, Device, clock::MockClock};
18+
use mas_data_model::{
19+
Clock, Device, clock::MockClock, personal::session::PersonalSessionOwner,
20+
};
1921
use mas_storage::{
2022
Pagination, RepositoryAccess,
2123
personal::{
@@ -84,14 +86,14 @@ mod tests {
8486
.add(
8587
&mut rng,
8688
&clock,
87-
&admin_user,
89+
(&admin_user).into(),
8890
&bot_user,
8991
"Test Personal Session".to_owned(),
9092
scope.clone(),
9193
)
9294
.await
9395
.unwrap();
94-
assert_eq!(session.owner_user_id, admin_user.id);
96+
assert_eq!(session.owner, PersonalSessionOwner::User(admin_user.id));
9597
assert_eq!(session.actor_user_id, bot_user.id);
9698
assert!(session.is_valid());
9799
assert!(!session.is_revoked());
@@ -128,7 +130,10 @@ mod tests {
128130
.unwrap()
129131
.expect("personal session not found");
130132
assert_eq!(session_lookup.id, session.id);
131-
assert_eq!(session_lookup.owner_user_id, admin_user.id);
133+
assert_eq!(
134+
session_lookup.owner,
135+
PersonalSessionOwner::User(admin_user.id)
136+
);
132137
assert_eq!(session_lookup.actor_user_id, bot_user.id);
133138
assert_eq!(session_lookup.scope, scope);
134139
assert!(session_lookup.is_valid());
@@ -207,7 +212,7 @@ mod tests {
207212
.add(
208213
&mut rng,
209214
&clock,
210-
&admin_user,
215+
(&admin_user).into(),
211216
&bot_user,
212217
"Test Personal Session".to_owned(),
213218
scope,

crates/storage-pg/src/personal/session.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use async_trait::async_trait;
99
use chrono::{DateTime, Utc};
1010
use mas_data_model::{
1111
Clock, User,
12-
personal::session::{PersonalSession, SessionState},
12+
personal::session::{PersonalSession, PersonalSessionOwner, SessionState},
1313
};
1414
use mas_storage::{
1515
Page, Pagination,
@@ -54,7 +54,8 @@ impl<'c> PgPersonalSessionRepository<'c> {
5454
#[enum_def]
5555
struct PersonalSessionLookup {
5656
personal_session_id: Uuid,
57-
owner_user_id: Uuid,
57+
owner_user_id: Option<Uuid>,
58+
owner_oauth2_client_id: Option<Uuid>,
5859
actor_user_id: Uuid,
5960
human_name: String,
6061
scope_list: Vec<String>,
@@ -88,10 +89,23 @@ impl TryFrom<PersonalSessionLookup> for PersonalSession {
8889
Some(revoked_at) => SessionState::Revoked { revoked_at },
8990
};
9091

92+
let owner = match (value.owner_user_id, value.owner_oauth2_client_id) {
93+
(Some(owner_user_id), None) => PersonalSessionOwner::User(Ulid::from(owner_user_id)),
94+
(None, Some(owner_oauth2_client_id)) => {
95+
PersonalSessionOwner::OAuth2Client(Ulid::from(owner_oauth2_client_id))
96+
}
97+
_ => {
98+
// should be impossible (CHECK constraint in Postgres prevents it)
99+
return Err(DatabaseInconsistencyError::on("personal_sessions")
100+
.column("owner_user_id, owner_oauth2_client_id")
101+
.row(id));
102+
}
103+
};
104+
91105
Ok(PersonalSession {
92106
id,
93107
state,
94-
owner_user_id: Ulid::from(value.owner_user_id),
108+
owner,
95109
actor_user_id: Ulid::from(value.actor_user_id),
96110
human_name: value.human_name,
97111
scope,
@@ -121,6 +135,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
121135
r#"
122136
SELECT personal_session_id
123137
, owner_user_id
138+
, owner_oauth2_client_id
124139
, actor_user_id
125140
, scope_list
126141
, created_at
@@ -157,7 +172,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
157172
&mut self,
158173
rng: &mut (dyn RngCore + Send),
159174
clock: &dyn Clock,
160-
owner_user: &User,
175+
owner: PersonalSessionOwner,
161176
actor_user: &User,
162177
human_name: String,
163178
scope: Scope,
@@ -168,20 +183,27 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
168183

169184
let scope_list: Vec<String> = scope.iter().map(|s| s.as_str().to_owned()).collect();
170185

186+
let (owner_user_id, owner_oauth2_client_id) = match owner {
187+
PersonalSessionOwner::User(ulid) => (Some(Uuid::from(ulid)), None),
188+
PersonalSessionOwner::OAuth2Client(ulid) => (None, Some(Uuid::from(ulid))),
189+
};
190+
171191
sqlx::query!(
172192
r#"
173193
INSERT INTO personal_sessions
174194
( personal_session_id
175195
, owner_user_id
196+
, owner_oauth2_client_id
176197
, actor_user_id
177198
, human_name
178199
, scope_list
179200
, created_at
180201
)
181-
VALUES ($1, $2, $3, $4, $5, $6)
202+
VALUES ($1, $2, $3, $4, $5, $6, $7)
182203
"#,
183204
Uuid::from(id),
184-
Uuid::from(owner_user.id),
205+
owner_user_id,
206+
owner_oauth2_client_id,
185207
Uuid::from(actor_user.id),
186208
&human_name,
187209
&scope_list,
@@ -194,7 +216,7 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
194216
Ok(PersonalSession {
195217
id,
196218
state: SessionState::Valid,
197-
owner_user_id: owner_user.id,
219+
owner,
198220
actor_user_id: actor_user.id,
199221
human_name,
200222
scope,
@@ -262,6 +284,13 @@ impl PersonalSessionRepository for PgPersonalSessionRepository<'_> {
262284
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId)),
263285
PersonalSessionLookupIden::OwnerUserId,
264286
)
287+
.expr_as(
288+
Expr::col((
289+
PersonalSessions::Table,
290+
PersonalSessions::OwnerOAuth2ClientId,
291+
)),
292+
PersonalSessionLookupIden::OwnerOauth2ClientId,
293+
)
265294
.expr_as(
266295
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId)),
267296
PersonalSessionLookupIden::ActorUserId,
@@ -341,6 +370,13 @@ impl Filter for PersonalSessionFilter<'_> {
341370
Expr::col((PersonalSessions::Table, PersonalSessions::OwnerUserId))
342371
.eq(Uuid::from(user.id))
343372
}))
373+
.add_option(self.owner_oauth2_client().map(|client| {
374+
Expr::col((
375+
PersonalSessions::Table,
376+
PersonalSessions::OwnerOAuth2ClientId,
377+
))
378+
.eq(Uuid::from(client.id))
379+
}))
344380
.add_option(self.actor_user().map(|user| {
345381
Expr::col((PersonalSessions::Table, PersonalSessions::ActorUserId))
346382
.eq(Uuid::from(user.id))

0 commit comments

Comments
 (0)