Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/data-model/src/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ impl User {
pub fn is_valid(&self) -> bool {
self.locked_at.is_none() && self.deactivated_at.is_none()
}

/// Returns `true` if the user is a valid actor, for example
/// of a personal session.
///
/// Currently: this is `true` unless the user is deactivated.
///
/// This is a weaker form of validity: `is_valid` always implies
/// `is_valid_actor`, but some users (currently: locked users)
/// can be valid actors for personal sessions but aren't valid
/// except through administrative access.
#[must_use]
pub fn is_valid_actor(&self) -> bool {
self.deactivated_at.is_none()
}
}

impl User {
Expand Down
11 changes: 10 additions & 1 deletion crates/handlers/src/activity_tracker/bound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/handlers/src/activity_tracker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ impl ActivityTracker {
}
}

/// Record activity in a personal access token session.
pub async fn record_personal_access_token_session(
/// Record activity in a personal session.
pub async fn record_personal_session(
&self,
clock: &dyn Clock,
session: &PersonalSession,
Expand Down
174 changes: 138 additions & 36 deletions crates/handlers/src/admin/call_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ 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, TokenFormatError, TokenType, User,
personal::session::{PersonalSession, PersonalSessionOwner},
};
use mas_storage::{BoxRepository, RepositoryError};
use oauth2_types::scope::Scope;
use ulid::Ulid;

use super::response::ErrorResponse;
Expand All @@ -41,6 +45,14 @@ 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(
#[source]
#[from]
Option<TokenFormatError>,
),

/// The access token could not be found in the database
#[error("Unknown access token")]
UnknownAccessToken,
Expand Down Expand Up @@ -90,7 +102,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(_)
Expand All @@ -113,7 +126,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
Expand Down Expand Up @@ -154,28 +167,93 @@ where
})?;

let token = token.token();
let token_type = TokenType::check(token)?;

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))?;

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);
}

// 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))?;

// Record the activity on the session
activity_tracker
.record_oauth2_session(&clock, &session)
.await;
if !token.is_valid(clock.now()) {
return Err(Rejection::TokenExpired);
}

// Check the validity of the owner of the personal session
match session.owner {
PersonalSessionOwner::User(owner_user_id) => {
let owner_user = repo
.user()
.lookup(owner_user_id)
.await?
.ok_or_else(|| Rejection::LoadUser(owner_user_id))?;
if !owner_user.is_valid() {
return Err(Rejection::UserLocked);
}
}
PersonalSessionOwner::OAuth2Client(_) => {
// nop: Client owners are always valid
}
}

// Record the activity on the session
activity_tracker
.record_personal_session(&clock, &session)
.await;

CallerSession::PersonalSession(session)
}
_other => {
return Err(Rejection::InvalidAccessTokenType(None));
}
};

// 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)
Expand All @@ -186,24 +264,25 @@ where
None
};

// If there is a user for this session, check that it is not locked
if let Some(user) = &user
&& !user.is_valid()
{
return Err(Rejection::UserLocked);
}

if !session.is_valid() {
return Err(Rejection::SessionRevoked);
}

if !token.is_valid(clock.now()) {
return Err(Rejection::TokenExpired);
if let CallerSession::PersonalSession(_) = &session {
// For personal sessions: check that the actor is valid enough
// to be an actor.
// unwrap: personal sessions always have an actor user
if !user.as_ref().unwrap().is_valid_actor() {
return Err(Rejection::UserLocked);
}
} else {
// If there is a user for this session, check that it is not locked
if let Some(user) = &user
&& !user.is_valid()
{
return Err(Rejection::UserLocked);
}
}

// 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);
}

Expand All @@ -215,3 +294,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),
}
}
}
11 changes: 3 additions & 8 deletions crates/handlers/src/admin/v1/personal_sessions/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions crates/handlers/src/admin/v1/personal_sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,31 @@ 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},
list::{doc as list_doc, handler as list},
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/handlers/src/oauth2/introspection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ pub(crate) async fn post(
};

activity_tracker
.record_personal_access_token_session(&clock, &session, ip)
.record_personal_session(&clock, &session, ip)
.await;

INTROSPECTION_COUNTER.add(
Expand Down
Loading