diff --git a/Cargo.lock b/Cargo.lock index f84e5c91..55b075cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3139,6 +3139,7 @@ dependencies = [ "itertools", "listenfd", "mas-config", + "mas-data-model", "mas-email", "mas-handlers", "mas-http", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3903c0d3..2103f7bd 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -46,6 +46,7 @@ sentry-tracing = "0.31.3" sentry-tower = { version = "0.31.3", features = ["http"] } mas-config = { path = "../config" } +mas-data-model = { path = "../data-model" } mas-email = { path = "../email" } mas-handlers = { path = "../handlers", default-features = false } mas-http = { path = "../http", default-features = false, features = ["axum", "client"] } diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index f78c9987..5a362828 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -15,9 +15,11 @@ use anyhow::Context; use clap::{Parser, ValueEnum}; use mas_config::{DatabaseConfig, PasswordsConfig, RootConfig}; +use mas_data_model::{Device, TokenType}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_router::UrlBuilder; use mas_storage::{ + compat::{CompatAccessTokenRepository, CompatSessionRepository}, oauth2::OAuth2ClientRepository, upstream_oauth2::UpstreamOAuthProviderRepository, user::{UserEmailRepository, UserPasswordRepository, UserRepository}, @@ -182,6 +184,20 @@ enum Subcommand { #[arg(long)] client_secret: Option, }, + + /// Issue a compatibility token + IssueCompatibilityToken { + /// User for which to issue the token + username: String, + + /// Device ID to set in the token. If not specified, a random device ID + /// will be generated. + device_id: Option, + + /// Whether that token should be admin + #[arg(long = "yes-i-want-to-grant-synapse-admin-privileges")] + admin: bool, + }, } impl Options { @@ -375,6 +391,53 @@ impl Options { Ok(()) } + + SC::IssueCompatibilityToken { + username, + admin, + device_id, + } => { + let config: DatabaseConfig = root.load_config()?; + let pool = database_from_config(&config).await?; + let mut repo = PgRepository::from_pool(&pool).await?.boxed(); + + let user = repo + .user() + .find_by_username(username) + .await? + .context("User not found")?; + + let device = if let Some(device_id) = device_id { + device_id.clone().try_into()? + } else { + Device::generate(&mut rng) + }; + + let compat_session = repo + .compat_session() + .add(&mut rng, &clock, &user, device, *admin) + .await?; + + let token = TokenType::CompatAccessToken.generate(&mut rng); + + let compat_access_token = repo + .compat_access_token() + .add(&mut rng, &clock, &compat_session, token, None) + .await?; + + repo.save().await?; + + info!( + %compat_access_token.id, + %compat_session.id, + %compat_session.device, + %user.id, + %user.username, + "Compatibility token issued: {}", compat_access_token.token + ); + + Ok(()) + } } } } diff --git a/crates/data-model/src/compat/device.rs b/crates/data-model/src/compat/device.rs index 688e0e4e..71bb5ded 100644 --- a/crates/data-model/src/compat/device.rs +++ b/crates/data-model/src/compat/device.rs @@ -80,6 +80,12 @@ impl TryFrom for Device { } } +impl std::fmt::Display for Device { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.id) + } +} + #[cfg(test)] mod test { use oauth2_types::scope::OPENID; diff --git a/crates/data-model/src/compat/session.rs b/crates/data-model/src/compat/session.rs index 1dbd0722..1132e02e 100644 --- a/crates/data-model/src/compat/session.rs +++ b/crates/data-model/src/compat/session.rs @@ -68,6 +68,7 @@ pub struct CompatSession { pub user_id: Ulid, pub device: Device, pub created_at: DateTime, + pub is_synapse_admin: bool, } impl std::ops::Deref for CompatSession { diff --git a/crates/handlers/src/compat/login.rs b/crates/handlers/src/compat/login.rs index 96bfde15..2541ad6a 100644 --- a/crates/handlers/src/compat/login.rs +++ b/crates/handlers/src/compat/login.rs @@ -399,7 +399,7 @@ async fn user_password_login( let session = repo .compat_session() - .add(&mut rng, clock, &user, device) + .add(&mut rng, clock, &user, device, false) .await?; Ok((session, user)) @@ -726,7 +726,7 @@ mod tests { // Complete the flow by fulfilling it with a session let compat_session = repo .compat_session() - .add(&mut state.rng(), &state.clock, user, device.clone()) + .add(&mut state.rng(), &state.clock, user, device.clone(), false) .await .unwrap(); diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index 1443699b..099ab9ca 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -200,7 +200,7 @@ pub async fn post( let compat_session = repo .compat_session() - .add(&mut rng, &clock, &session.user, device) + .add(&mut rng, &clock, &session.user, device, false) .await?; repo.compat_sso_login() diff --git a/crates/handlers/src/oauth2/introspection.rs b/crates/handlers/src/oauth2/introspection.rs index f9478de4..73049851 100644 --- a/crates/handlers/src/oauth2/introspection.rs +++ b/crates/handlers/src/oauth2/introspection.rs @@ -120,6 +120,7 @@ const INACTIVE: IntrospectionResponse = IntrospectionResponse { }; const API_SCOPE: ScopeToken = ScopeToken::from_static("urn:matrix:org.matrix.msc2967.client:api:*"); +const SYNAPSE_ADMIN_SCOPE: ScopeToken = ScopeToken::from_static("urn:synapse:admin:*"); #[tracing::instrument( name = "handlers.oauth2.introspection.post", @@ -267,8 +268,13 @@ pub(crate) async fn post( // XXX: is that the right error to bubble up? .ok_or(RouteError::UnknownToken)?; + // Grant the synapse admin scope if the session has the admin flag set. + let synapse_admin = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); let device_scope = session.device.to_scope_token(); - let scope = [API_SCOPE, device_scope].into_iter().collect(); + let scope = [API_SCOPE, device_scope] + .into_iter() + .chain(synapse_admin) + .collect(); IntrospectionResponse { active: true, @@ -308,8 +314,13 @@ pub(crate) async fn post( // XXX: is that the right error to bubble up? .ok_or(RouteError::UnknownToken)?; + // Grant the synapse admin scope if the session has the admin flag set. + let synapse_admin = session.is_synapse_admin.then_some(SYNAPSE_ADMIN_SCOPE); let device_scope = session.device.to_scope_token(); - let scope = [API_SCOPE, device_scope].into_iter().collect(); + let scope = [API_SCOPE, device_scope] + .into_iter() + .chain(synapse_admin) + .collect(); IntrospectionResponse { active: true, diff --git a/crates/storage-pg/migrations/20230616093555_compat_admin_flag.sql b/crates/storage-pg/migrations/20230616093555_compat_admin_flag.sql new file mode 100644 index 00000000..ce42c6f9 --- /dev/null +++ b/crates/storage-pg/migrations/20230616093555_compat_admin_flag.sql @@ -0,0 +1,16 @@ +-- Copyright 2023 The Matrix.org Foundation C.I.C. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +ALTER TABLE compat_sessions + ADD COLUMN is_synapse_admin BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/crates/storage-pg/sqlx-data.json b/crates/storage-pg/sqlx-data.json index 7681b47d..1fda026a 100644 --- a/crates/storage-pg/sqlx-data.json +++ b/crates/storage-pg/sqlx-data.json @@ -14,6 +14,56 @@ }, "query": "\n UPDATE oauth2_authorization_grants\n SET fulfilled_at = $2\n , oauth2_session_id = $3\n WHERE oauth2_authorization_grant_id = $1\n " }, + "0469c1d3ad11fd96febacad33302709c870ead848d6920cdfdb18912d543488e": { + "describe": { + "columns": [ + { + "name": "compat_session_id", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "device_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Uuid" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "finished_at", + "ordinal": 4, + "type_info": "Timestamptz" + }, + { + "name": "is_synapse_admin", + "ordinal": 5, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Uuid" + ] + } + }, + "query": "\n SELECT compat_session_id\n , device_id\n , user_id\n , created_at\n , finished_at\n , is_synapse_admin\n FROM compat_sessions\n WHERE compat_session_id = $1\n " + }, "08d7df347c806ef14b6d0fb031cab041d79ba48528420160e23286369db7af35": { "describe": { "columns": [ @@ -112,21 +162,6 @@ }, "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n created_at\n FROM upstream_oauth_providers\n " }, - "18c3e56c72ef26bd42653c379767ffdd97bb06cb1686dfbf4099f3ad3d7b22c8": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Timestamptz" - ] - } - }, - "query": "\n INSERT INTO compat_sessions (compat_session_id, user_id, device_id, created_at)\n VALUES ($1, $2, $3, $4)\n " - }, "1a8701f5672de052bb766933f60b93249acc7237b996e8b93cd61b9f69c902ff": { "describe": { "columns": [], @@ -1913,6 +1948,22 @@ }, "query": "\n UPDATE oauth2_authorization_grants\n SET exchanged_at = $2\n WHERE oauth2_authorization_grant_id = $1\n " }, + "cff3ac0fff62ffdc5640fce08c2ffabc1d89202561b736c5d03b501dfcd8d886": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz", + "Bool" + ] + } + }, + "query": "\n INSERT INTO compat_sessions (compat_session_id, user_id, device_id, created_at, is_synapse_admin)\n VALUES ($1, $2, $3, $4, $5)\n " + }, "d0b403e9c843ef19fa5ad60bec32ebf14a1ba0d01681c3836366d3f55e7851f4": { "describe": { "columns": [], @@ -2388,50 +2439,6 @@ }, "query": "\n SELECT oauth2_session_id\n , user_session_id\n , oauth2_client_id\n , scope\n , created_at\n , finished_at\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n " }, - "f3ee06958d827b152c57328caa0a6030c372cb99cdb60e4b75a28afeb5096f45": { - "describe": { - "columns": [ - { - "name": "compat_session_id", - "ordinal": 0, - "type_info": "Uuid" - }, - { - "name": "device_id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "user_id", - "ordinal": 2, - "type_info": "Uuid" - }, - { - "name": "created_at", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "finished_at", - "ordinal": 4, - "type_info": "Timestamptz" - } - ], - "nullable": [ - false, - false, - false, - false, - true - ], - "parameters": { - "Left": [ - "Uuid" - ] - } - }, - "query": "\n SELECT compat_session_id\n , device_id\n , user_id\n , created_at\n , finished_at\n FROM compat_sessions\n WHERE compat_session_id = $1\n " - }, "f5edcd4c306ca8179cdf9d4aab59fbba971b54611c91345849920954dd8089b3": { "describe": { "columns": [], diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index 8fe49763..29ae7539 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -62,7 +62,7 @@ mod tests { let device_str = device.as_str().to_owned(); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device) + .add(&mut rng, &clock, &user, device, false) .await .unwrap(); assert_eq!(session.user_id, user.id); @@ -118,7 +118,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device) + .add(&mut rng, &clock, &user, device, false) .await .unwrap(); @@ -238,7 +238,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device) + .add(&mut rng, &clock, &user, device, false) .await .unwrap(); @@ -391,7 +391,7 @@ mod tests { let device = Device::generate(&mut rng); let session = repo .compat_session() - .add(&mut rng, &clock, &user, device) + .add(&mut rng, &clock, &user, device, false) .await .unwrap(); diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index 283a9a59..ea4bc251 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -42,6 +42,7 @@ struct CompatSessionLookup { user_id: Uuid, created_at: DateTime, finished_at: Option>, + is_synapse_admin: bool, } impl TryFrom for CompatSession { @@ -67,6 +68,7 @@ impl TryFrom for CompatSession { user_id: value.user_id.into(), device, created_at: value.created_at, + is_synapse_admin: value.is_synapse_admin, }; Ok(session) @@ -95,6 +97,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { , user_id , created_at , finished_at + , is_synapse_admin FROM compat_sessions WHERE compat_session_id = $1 "#, @@ -128,6 +131,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { clock: &dyn Clock, user: &User, device: Device, + is_synapse_admin: bool, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_source(created_at.into(), rng); @@ -135,13 +139,14 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { sqlx::query!( r#" - INSERT INTO compat_sessions (compat_session_id, user_id, device_id, created_at) - VALUES ($1, $2, $3, $4) + INSERT INTO compat_sessions (compat_session_id, user_id, device_id, created_at, is_synapse_admin) + VALUES ($1, $2, $3, $4, $5) "#, Uuid::from(id), Uuid::from(user.id), device.as_str(), created_at, + is_synapse_admin, ) .traced() .execute(&mut *self.conn) @@ -153,6 +158,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { user_id: user.id, device, created_at, + is_synapse_admin, }) } diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index fb9dea73..8dd1b069 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -49,6 +49,7 @@ pub trait CompatSessionRepository: Send + Sync { /// * `clock`: The clock used to generate timestamps /// * `user`: The user to create the compat session for /// * `device`: The device ID of this session + /// * `is_synapse_admin`: Whether the session is a synapse admin session /// /// # Errors /// @@ -59,6 +60,7 @@ pub trait CompatSessionRepository: Send + Sync { clock: &dyn Clock, user: &User, device: Device, + is_synapse_admin: bool, ) -> Result; /// End a compat session @@ -89,6 +91,7 @@ repository_impl!(CompatSessionRepository: clock: &dyn Clock, user: &User, device: Device, + is_synapse_admin: bool, ) -> Result; async fn finish(