Skip to content

Commit 26bf0b0

Browse files
committed
Add personal sessions admin API
1 parent c2c75c2 commit 26bf0b0

File tree

6 files changed

+996
-0
lines changed

6 files changed

+996
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use crate::passwords::PasswordManager;
2020

2121
mod compat_sessions;
2222
mod oauth2_sessions;
23+
mod personal_sessions;
2324
mod policy_data;
2425
mod site_config;
2526
mod upstream_oauth_links;
@@ -80,6 +81,31 @@ where
8081
self::oauth2_sessions::finish_doc,
8182
),
8283
)
84+
.api_route(
85+
"/personal-sessions",
86+
get_with(
87+
self::personal_sessions::list,
88+
self::personal_sessions::list_doc,
89+
)
90+
.post_with(
91+
self::personal_sessions::add,
92+
self::personal_sessions::add_doc,
93+
),
94+
)
95+
.api_route(
96+
"/personal-sessions/{id}",
97+
get_with(
98+
self::personal_sessions::get,
99+
self::personal_sessions::get_doc,
100+
),
101+
)
102+
.api_route(
103+
"/personal-sessions/{id}/revoke",
104+
post_with(
105+
self::personal_sessions::revoke,
106+
self::personal_sessions::revoke_doc,
107+
),
108+
)
83109
.api_route(
84110
"/policy-data",
85111
post_with(self::policy_data::set, self::policy_data::set_doc),
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
7+
use aide::{NoApi, OperationIo, transform::TransformOperation};
8+
use axum::{Json, response::IntoResponse};
9+
use chrono::Duration;
10+
use hyper::StatusCode;
11+
use mas_axum_utils::record_error;
12+
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
13+
use oauth2_types::scope::Scope;
14+
use schemars::JsonSchema;
15+
use serde::{Deserialize, Serialize};
16+
use ulid::Ulid;
17+
18+
use crate::{
19+
admin::{
20+
call_context::CallContext,
21+
model::{PersonalAccessToken, PersonalSession},
22+
response::ErrorResponse,
23+
},
24+
impl_from_error_for_route,
25+
};
26+
27+
#[derive(Debug, thiserror::Error, OperationIo)]
28+
#[aide(output_with = "Json<ErrorResponse>")]
29+
pub enum RouteError {
30+
#[error(transparent)]
31+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
32+
33+
#[error("User not found")]
34+
UserNotFound,
35+
36+
#[error("Invalid scope")]
37+
InvalidScope,
38+
}
39+
40+
impl_from_error_for_route!(mas_storage::RepositoryError);
41+
42+
impl IntoResponse for RouteError {
43+
fn into_response(self) -> axum::response::Response {
44+
let error = ErrorResponse::from_error(&self);
45+
let sentry_event_id = record_error!(self, Self::Internal(_));
46+
let status = match self {
47+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
48+
Self::UserNotFound => StatusCode::NOT_FOUND,
49+
Self::InvalidScope => StatusCode::BAD_REQUEST,
50+
};
51+
(status, sentry_event_id, Json(error)).into_response()
52+
}
53+
}
54+
55+
/// # JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint
56+
#[derive(Deserialize, JsonSchema)]
57+
#[serde(rename = "CreatePersonalSessionRequest")]
58+
pub struct Request {
59+
/// The user this session will act on behalf of
60+
#[schemars(with = "crate::admin::schema::Ulid")]
61+
actor_user_id: Ulid,
62+
63+
/// Human-readable name for the session
64+
human_name: String,
65+
66+
/// `OAuth2` scopes for this session
67+
scope: String,
68+
69+
/// Token expiry time in seconds.
70+
/// If not set, the token won't expire.
71+
expires_in: Option<u64>,
72+
}
73+
74+
/// Response containing both the personal session and access token
75+
#[derive(Serialize, JsonSchema)]
76+
#[serde(rename = "CreatePersonalSessionResponse")]
77+
pub struct Response {
78+
/// The created personal session
79+
session: PersonalSession,
80+
81+
/// The created personal access token
82+
access_token: PersonalAccessToken,
83+
}
84+
85+
pub fn doc(operation: TransformOperation) -> TransformOperation {
86+
operation
87+
.id("createPersonalSession")
88+
.summary("Create a new personal session with personal access token")
89+
.tag("personal-session")
90+
.response_with::<201, Json<Response>, _>(|t| {
91+
t.description("Personal session and personal access token were created")
92+
})
93+
.response_with::<400, RouteError, _>(|t| {
94+
let response = ErrorResponse::from_error(&RouteError::InvalidScope);
95+
t.description("Invalid scope provided").example(response)
96+
})
97+
.response_with::<404, RouteError, _>(|t| {
98+
let response = ErrorResponse::from_error(&RouteError::UserNotFound);
99+
t.description("User was not found").example(response)
100+
})
101+
}
102+
103+
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)]
104+
pub async fn handler(
105+
CallContext {
106+
mut repo,
107+
clock,
108+
session,
109+
..
110+
}: CallContext,
111+
NoApi(mut rng): NoApi<BoxRng>,
112+
Json(params): Json<Request>,
113+
) -> Result<(StatusCode, Json<Response>), RouteError> {
114+
let owner = if let Some(user_id) = session.user_id {
115+
// User-owned session
116+
PersonalSessionOwner::User(user_id)
117+
} else {
118+
// No admin user means this is a client-owned session
119+
PersonalSessionOwner::OAuth2Client(session.client_id)
120+
};
121+
122+
let actor_user = repo
123+
.user()
124+
.lookup(params.actor_user_id)
125+
.await?
126+
.ok_or(RouteError::UserNotFound)?;
127+
128+
let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?;
129+
130+
// Create the personal session
131+
let session = repo
132+
.personal_session()
133+
.add(
134+
&mut rng,
135+
&clock,
136+
owner,
137+
&actor_user,
138+
params.human_name,
139+
scope,
140+
)
141+
.await?;
142+
143+
// Create the initial token for the session
144+
let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng);
145+
let access_token = repo
146+
.personal_access_token()
147+
.add(
148+
&mut rng,
149+
&clock,
150+
&session,
151+
&access_token_string,
152+
params
153+
.expires_in
154+
.map(|exp_in| Duration::seconds(i64::try_from(exp_in).unwrap_or(i64::MAX))),
155+
)
156+
.await?;
157+
158+
repo.save().await?;
159+
160+
Ok((
161+
StatusCode::CREATED,
162+
Json(Response {
163+
session: PersonalSession::from(session),
164+
access_token: PersonalAccessToken::from(access_token).with_token(access_token_string),
165+
}),
166+
))
167+
}
168+
169+
#[cfg(test)]
170+
mod tests {
171+
use hyper::{Request, StatusCode};
172+
use insta::assert_json_snapshot;
173+
use serde_json::Value;
174+
use sqlx::PgPool;
175+
176+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
177+
178+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
179+
async fn test_create_personal_session_with_token(pool: PgPool) {
180+
setup();
181+
let mut state = TestState::from_pool(pool).await.unwrap();
182+
let token = state.token_with_scope("urn:mas:admin").await;
183+
184+
// Create a user for testing
185+
let mut repo = state.repository().await.unwrap();
186+
let mut rng = state.rng();
187+
let user = repo
188+
.user()
189+
.add(&mut rng, &state.clock, "alice".to_owned())
190+
.await
191+
.unwrap();
192+
193+
repo.save().await.unwrap();
194+
195+
let request_body = serde_json::json!({
196+
"actor_user_id": user.id,
197+
"human_name": "Test Session",
198+
"scope": "openid urn:mas:admin",
199+
"expires_in": 3600
200+
});
201+
202+
let request = Request::post("/api/admin/v1/personal-sessions")
203+
.bearer(&token)
204+
.json(&request_body);
205+
206+
let response = state.request(request).await;
207+
response.assert_status(StatusCode::CREATED);
208+
209+
let body: Value = response.json();
210+
211+
assert_json_snapshot!(body, @r#"
212+
{
213+
"session": {
214+
"created_at": "2022-01-16T14:40:00Z",
215+
"revoked_at": null,
216+
"owner_user_id": null,
217+
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
218+
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
219+
"human_name": "Test Session",
220+
"scope": "openid urn:mas:admin",
221+
"last_active_at": null,
222+
"last_active_ip": null
223+
},
224+
"access_token": {
225+
"session_id": "01FSHN9AG07HNEZXNQM2KNBNF6",
226+
"created_at": "2022-01-16T14:40:00Z",
227+
"expires_at": "2022-01-16T15:40:00Z",
228+
"revoked_at": null,
229+
"access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1"
230+
}
231+
}
232+
"#);
233+
}
234+
235+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
236+
async fn test_create_personal_session_invalid_user(pool: PgPool) {
237+
setup();
238+
let mut state = TestState::from_pool(pool).await.unwrap();
239+
let token = state.token_with_scope("urn:mas:admin").await;
240+
241+
let request_body = serde_json::json!({
242+
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
243+
"scope": "openid",
244+
"human_name": "Test Session",
245+
"expires_in": 3600
246+
});
247+
248+
let request = Request::post("/api/admin/v1/personal-sessions")
249+
.bearer(&token)
250+
.json(&request_body);
251+
252+
let response = state.request(request).await;
253+
response.assert_status(StatusCode::NOT_FOUND);
254+
}
255+
256+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
257+
async fn test_create_personal_session_invalid_scope(pool: PgPool) {
258+
setup();
259+
let mut state = TestState::from_pool(pool).await.unwrap();
260+
let token = state.token_with_scope("urn:mas:admin").await;
261+
262+
// Create a user for testing
263+
let mut repo = state.repository().await.unwrap();
264+
let mut rng = state.rng();
265+
let user = repo
266+
.user()
267+
.add(&mut rng, &state.clock, "alice".to_owned())
268+
.await
269+
.unwrap();
270+
271+
repo.save().await.unwrap();
272+
273+
let request_body = serde_json::json!({
274+
"actor_user_id": user.id,
275+
"human_name": "Test Session",
276+
"scope": "invalid\nscope",
277+
"expires_in": 3600
278+
});
279+
280+
let request = Request::post("/api/admin/v1/personal-sessions")
281+
.bearer(&token)
282+
.json(&request_body);
283+
284+
let response = state.request(request).await;
285+
response.assert_status(StatusCode::BAD_REQUEST);
286+
}
287+
}

0 commit comments

Comments
 (0)