Skip to content

Commit dc3e400

Browse files
committed
Add personal sessions admin API
1 parent 8cde9fe commit dc3e400

File tree

7 files changed

+1987
-0
lines changed

7 files changed

+1987
-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: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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+
use aide::{NoApi, OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use chrono::Duration;
9+
use hyper::StatusCode;
10+
use mas_axum_utils::record_error;
11+
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
12+
use oauth2_types::scope::Scope;
13+
use schemars::JsonSchema;
14+
use serde::Deserialize;
15+
use ulid::Ulid;
16+
17+
use crate::{
18+
admin::{
19+
call_context::CallContext,
20+
model::{InconsistentPersonalSession, PersonalSession},
21+
response::{ErrorResponse, SingleResponse},
22+
},
23+
impl_from_error_for_route,
24+
};
25+
26+
#[derive(Debug, thiserror::Error, OperationIo)]
27+
#[aide(output_with = "Json<ErrorResponse>")]
28+
pub enum RouteError {
29+
#[error(transparent)]
30+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
31+
32+
#[error("User not found")]
33+
UserNotFound,
34+
35+
#[error("Invalid scope")]
36+
InvalidScope,
37+
}
38+
39+
impl_from_error_for_route!(mas_storage::RepositoryError);
40+
impl_from_error_for_route!(InconsistentPersonalSession);
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+
pub fn doc(operation: TransformOperation) -> TransformOperation {
75+
operation
76+
.id("createPersonalSession")
77+
.summary("Create a new personal session with personal access token")
78+
.tag("personal-session")
79+
.response_with::<201, Json<SingleResponse<PersonalSession>>, _>(|t| {
80+
t.description("Personal session and personal access token were created")
81+
})
82+
.response_with::<400, RouteError, _>(|t| {
83+
let response = ErrorResponse::from_error(&RouteError::InvalidScope);
84+
t.description("Invalid scope provided").example(response)
85+
})
86+
.response_with::<404, RouteError, _>(|t| {
87+
let response = ErrorResponse::from_error(&RouteError::UserNotFound);
88+
t.description("User was not found").example(response)
89+
})
90+
}
91+
92+
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)]
93+
pub async fn handler(
94+
CallContext {
95+
mut repo,
96+
clock,
97+
session,
98+
..
99+
}: CallContext,
100+
NoApi(mut rng): NoApi<BoxRng>,
101+
Json(params): Json<Request>,
102+
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
103+
let owner = if let Some(user_id) = session.user_id {
104+
// User-owned session
105+
PersonalSessionOwner::User(user_id)
106+
} else {
107+
// No admin user means this is a client-owned session
108+
PersonalSessionOwner::OAuth2Client(session.client_id)
109+
};
110+
111+
let actor_user = repo
112+
.user()
113+
.lookup(params.actor_user_id)
114+
.await?
115+
.ok_or(RouteError::UserNotFound)?;
116+
117+
let scope: Scope = params.scope.parse().map_err(|_| RouteError::InvalidScope)?;
118+
119+
// Create the personal session
120+
let session = repo
121+
.personal_session()
122+
.add(
123+
&mut rng,
124+
&clock,
125+
owner,
126+
&actor_user,
127+
params.human_name,
128+
scope,
129+
)
130+
.await?;
131+
132+
// Create the initial token for the session
133+
let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng);
134+
let access_token = repo
135+
.personal_access_token()
136+
.add(
137+
&mut rng,
138+
&clock,
139+
&session,
140+
&access_token_string,
141+
params
142+
.expires_in
143+
.map(|exp_in| Duration::seconds(i64::try_from(exp_in).unwrap_or(i64::MAX))),
144+
)
145+
.await?;
146+
147+
repo.save().await?;
148+
149+
Ok((
150+
StatusCode::CREATED,
151+
Json(SingleResponse::new_canonical(
152+
PersonalSession::try_from((session, Some(access_token)))?
153+
.with_token(access_token_string),
154+
)),
155+
))
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use hyper::{Request, StatusCode};
161+
use insta::assert_json_snapshot;
162+
use serde_json::Value;
163+
use sqlx::PgPool;
164+
165+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
166+
167+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
168+
async fn test_create_personal_session_with_token(pool: PgPool) {
169+
setup();
170+
let mut state = TestState::from_pool(pool).await.unwrap();
171+
let token = state.token_with_scope("urn:mas:admin").await;
172+
173+
// Create a user for testing
174+
let mut repo = state.repository().await.unwrap();
175+
let mut rng = state.rng();
176+
let user = repo
177+
.user()
178+
.add(&mut rng, &state.clock, "alice".to_owned())
179+
.await
180+
.unwrap();
181+
182+
repo.save().await.unwrap();
183+
184+
let request_body = serde_json::json!({
185+
"actor_user_id": user.id,
186+
"human_name": "Test Session",
187+
"scope": "openid urn:mas:admin",
188+
"expires_in": 3600
189+
});
190+
191+
let request = Request::post("/api/admin/v1/personal-sessions")
192+
.bearer(&token)
193+
.json(&request_body);
194+
195+
let response = state.request(request).await;
196+
response.assert_status(StatusCode::CREATED);
197+
198+
let body: Value = response.json();
199+
200+
assert_json_snapshot!(body, @r#"
201+
{
202+
"data": {
203+
"type": "personal-session",
204+
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
205+
"attributes": {
206+
"created_at": "2022-01-16T14:40:00Z",
207+
"revoked_at": null,
208+
"owner_user_id": null,
209+
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
210+
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
211+
"human_name": "Test Session",
212+
"scope": "openid urn:mas:admin",
213+
"last_active_at": null,
214+
"last_active_ip": null,
215+
"expires_at": "2022-01-16T15:40:00Z",
216+
"access_token": "mpt_FM44zJN5qePGMLvvMXC4Ds1A3lCWc6_bJ9Wj1"
217+
},
218+
"links": {
219+
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
220+
}
221+
},
222+
"links": {
223+
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
224+
}
225+
}
226+
"#);
227+
}
228+
229+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
230+
async fn test_create_personal_session_invalid_user(pool: PgPool) {
231+
setup();
232+
let mut state = TestState::from_pool(pool).await.unwrap();
233+
let token = state.token_with_scope("urn:mas:admin").await;
234+
235+
let request_body = serde_json::json!({
236+
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
237+
"scope": "openid",
238+
"human_name": "Test Session",
239+
"expires_in": 3600
240+
});
241+
242+
let request = Request::post("/api/admin/v1/personal-sessions")
243+
.bearer(&token)
244+
.json(&request_body);
245+
246+
let response = state.request(request).await;
247+
response.assert_status(StatusCode::NOT_FOUND);
248+
}
249+
250+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
251+
async fn test_create_personal_session_invalid_scope(pool: PgPool) {
252+
setup();
253+
let mut state = TestState::from_pool(pool).await.unwrap();
254+
let token = state.token_with_scope("urn:mas:admin").await;
255+
256+
// Create a user for testing
257+
let mut repo = state.repository().await.unwrap();
258+
let mut rng = state.rng();
259+
let user = repo
260+
.user()
261+
.add(&mut rng, &state.clock, "alice".to_owned())
262+
.await
263+
.unwrap();
264+
265+
repo.save().await.unwrap();
266+
267+
let request_body = serde_json::json!({
268+
"actor_user_id": user.id,
269+
"human_name": "Test Session",
270+
"scope": "invalid\nscope",
271+
"expires_in": 3600
272+
});
273+
274+
let request = Request::post("/api/admin/v1/personal-sessions")
275+
.bearer(&token)
276+
.json(&request_body);
277+
278+
let response = state.request(request).await;
279+
response.assert_status(StatusCode::BAD_REQUEST);
280+
}
281+
}

0 commit comments

Comments
 (0)