Accept PATs on the Admin API

This commit is contained in:
Olivier 'reivilibre
2025-10-21 09:36:50 +01:00
parent 09bb647e68
commit e8ba1681a2
5 changed files with 135 additions and 44 deletions

View File

@@ -6,7 +6,9 @@
use std::net::IpAddr;
use mas_data_model::{BrowserSession, Clock, CompatSession, Session};
use mas_data_model::{
BrowserSession, Clock, CompatSession, Session, personal::session::PersonalSession,
};
use crate::activity_tracker::ActivityTracker;
@@ -37,6 +39,13 @@ impl Bound {
.await;
}
/// Record activity in a personal session.
pub async fn record_personal_session(&self, clock: &dyn Clock, session: &PersonalSession) {
self.tracker
.record_personal_session(clock, session, self.ip)
.await;
}
/// Record activity in a compatibility session.
pub async fn record_compat_session(&self, clock: &dyn Clock, session: &CompatSession) {
self.tracker

View File

@@ -16,8 +16,9 @@ use axum_extra::TypedHeader;
use headers::{Authorization, authorization::Bearer};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_data_model::{BoxClock, Session, User};
use mas_data_model::{BoxClock, Session, TokenType, User, personal::session::PersonalSession};
use mas_storage::{BoxRepository, RepositoryError};
use oauth2_types::scope::Scope;
use ulid::Ulid;
use super::response::ErrorResponse;
@@ -41,6 +42,10 @@ pub enum Rejection {
#[error("Invalid repository operation")]
Repository(#[from] RepositoryError),
/// The access token was not of the correct type for the Admin API
#[error("Invalid type of access token")]
InvalidAccessTokenType,
/// The access token could not be found in the database
#[error("Unknown access token")]
UnknownAccessToken,
@@ -90,7 +95,8 @@ impl IntoResponse for Rejection {
| Rejection::TokenExpired
| Rejection::SessionRevoked
| Rejection::UserLocked
| Rejection::MissingScope => StatusCode::UNAUTHORIZED,
| Rejection::MissingScope
| Rejection::InvalidAccessTokenType => StatusCode::UNAUTHORIZED,
Rejection::RepositorySetup(_)
| Rejection::Repository(_)
@@ -113,7 +119,7 @@ pub struct CallContext {
pub repo: BoxRepository,
pub clock: BoxClock,
pub user: Option<User>,
pub session: Session,
pub session: CallerSession,
}
impl<S> FromRequestParts<S> for CallContext
@@ -154,28 +160,76 @@ where
})?;
let token = token.token();
let token_type = TokenType::check(token).or(Err(Rejection::InvalidAccessTokenType))?;
// Look for the access token in the database
let token = repo
.oauth2_access_token()
.find_by_token(token)
.await?
.ok_or(Rejection::UnknownAccessToken)?;
let session = match token_type {
TokenType::AccessToken => {
// Look for the access token in the database
let token = repo
.oauth2_access_token()
.find_by_token(token)
.await?
.ok_or(Rejection::UnknownAccessToken)?;
// Look for the associated session in the database
let session = repo
.oauth2_session()
.lookup(token.session_id)
.await?
.ok_or_else(|| Rejection::LoadSession(token.session_id))?;
// Look for the associated session in the database
let session = repo
.oauth2_session()
.lookup(token.session_id)
.await?
.ok_or_else(|| Rejection::LoadSession(token.session_id))?;
// Record the activity on the session
activity_tracker
.record_oauth2_session(&clock, &session)
.await;
if !session.is_valid() {
return Err(Rejection::SessionRevoked);
}
if !token.is_valid(clock.now()) {
return Err(Rejection::TokenExpired);
}
// Record the activity on the session
activity_tracker
.record_oauth2_session(&clock, &session)
.await;
CallerSession::OAuth2Session(session)
}
TokenType::PersonalAccessToken => {
// Look for the access token in the database
let token = repo
.personal_access_token()
.find_by_token(token)
.await?
.ok_or(Rejection::UnknownAccessToken)?;
// Look for the associated session in the database
let session = repo
.personal_session()
.lookup(token.session_id)
.await?
.ok_or_else(|| Rejection::LoadSession(token.session_id))?;
if !session.is_valid() {
return Err(Rejection::SessionRevoked);
}
if !token.is_valid(clock.now()) {
return Err(Rejection::TokenExpired);
}
// Record the activity on the session
activity_tracker
.record_personal_session(&clock, &session)
.await;
CallerSession::PersonalSession(session)
}
_other => {
return Err(Rejection::InvalidAccessTokenType);
}
};
// Load the user if there is one
let user = if let Some(user_id) = session.user_id {
let user = if let Some(user_id) = session.user_id() {
let user = repo
.user()
.lookup(user_id)
@@ -193,17 +247,9 @@ where
return Err(Rejection::UserLocked);
}
if !session.is_valid() {
return Err(Rejection::SessionRevoked);
}
if !token.is_valid(clock.now()) {
return Err(Rejection::TokenExpired);
}
// For now, we only check that the session has the admin scope
// Later we might want to check other route-specific scopes
if !session.scope.contains("urn:mas:admin") {
if !session.scope().contains("urn:mas:admin") {
return Err(Rejection::MissingScope);
}
@@ -215,3 +261,26 @@ where
})
}
}
/// The session representing the caller of the Admin API;
/// could either be an OAuth session or a personal session.
pub enum CallerSession {
OAuth2Session(Session),
PersonalSession(PersonalSession),
}
impl CallerSession {
pub fn scope(&self) -> &Scope {
match self {
CallerSession::OAuth2Session(session) => &session.scope,
CallerSession::PersonalSession(session) => &session.scope,
}
}
pub fn user_id(&self) -> Option<Ulid> {
match self {
CallerSession::OAuth2Session(session) => session.user_id,
CallerSession::PersonalSession(session) => Some(session.actor_user_id),
}
}
}

View File

@@ -8,7 +8,7 @@ use axum::{Json, response::IntoResponse};
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
use mas_data_model::{BoxRng, TokenType};
use oauth2_types::scope::Scope;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -19,6 +19,7 @@ use crate::{
call_context::CallContext,
model::{InconsistentPersonalSession, PersonalSession},
response::{ErrorResponse, SingleResponse},
v1::personal_sessions::personal_session_owner_from_caller,
},
impl_from_error_for_route,
};
@@ -100,13 +101,7 @@ pub async fn handler(
NoApi(mut rng): NoApi<BoxRng>,
Json(params): Json<Request>,
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
let owner = if let Some(user_id) = session.user_id {
// User-owned session
PersonalSessionOwner::User(user_id)
} else {
// No admin user means this is a client-owned session
PersonalSessionOwner::OAuth2Client(session.client_id)
};
let owner = personal_session_owner_from_caller(&session);
let actor_user = repo
.user()

View File

@@ -9,6 +9,8 @@ mod list;
mod regenerate;
mod revoke;
use mas_data_model::personal::session::PersonalSessionOwner;
pub use self::{
add::{doc as add_doc, handler as add},
get::{doc as get_doc, handler as get},
@@ -16,3 +18,22 @@ pub use self::{
regenerate::{doc as regenerate_doc, handler as regenerate},
revoke::{doc as revoke_doc, handler as revoke},
};
use crate::admin::call_context::CallerSession;
/// Given the [`CallerSession`] of a caller of the Admin API,
/// return the [`PersonalSessionOwner`] that should own created personal
/// sessions.
fn personal_session_owner_from_caller(caller: &CallerSession) -> PersonalSessionOwner {
match caller {
CallerSession::OAuth2Session(session) => {
if let Some(user_id) = session.user_id {
PersonalSessionOwner::User(user_id)
} else {
PersonalSessionOwner::OAuth2Client(session.client_id)
}
}
CallerSession::PersonalSession(session) => {
PersonalSessionOwner::User(session.actor_user_id)
}
}
}

View File

@@ -8,7 +8,7 @@ use axum::{Json, response::IntoResponse};
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
use mas_data_model::{BoxRng, TokenType};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing::error;
@@ -19,6 +19,7 @@ use crate::{
model::{InconsistentPersonalSession, PersonalSession},
params::UlidPathParam,
response::{ErrorResponse, SingleResponse},
v1::personal_sessions::personal_session_owner_from_caller,
},
impl_from_error_for_route,
};
@@ -111,11 +112,7 @@ pub async fn handler(
// If the owner is not the current caller, then currently we reject the
// regeneration.
let caller = if let Some(user_id) = caller_session.user_id {
PersonalSessionOwner::User(user_id)
} else {
PersonalSessionOwner::OAuth2Client(caller_session.client_id)
};
let caller = personal_session_owner_from_caller(&caller_session);
if session.owner != caller {
return Err(RouteError::SessionNotYours);
}