1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-09 04:22:45 +03:00

"Can request admin" flag on user

This commit is contained in:
Quentin Gliech
2023-10-09 18:30:36 +02:00
parent 05e167b917
commit 3cb8a26d95
18 changed files with 360 additions and 13 deletions

View File

@@ -27,6 +27,7 @@ pub struct User {
pub primary_user_email_id: Option<Ulid>,
pub created_at: DateTime<Utc>,
pub locked_at: Option<DateTime<Utc>>,
pub can_request_admin: bool,
}
impl User {
@@ -47,6 +48,7 @@ impl User {
primary_user_email_id: None,
created_at: now,
locked_at: None,
can_request_admin: false,
}]
}
}

View File

@@ -73,6 +73,11 @@ impl User {
self.0.locked_at
}
/// Whether the user can request admin privileges.
pub async fn can_request_admin(&self) -> bool {
self.0.can_request_admin
}
/// Access to the user's Matrix account information.
async fn matrix(&self, ctx: &Context<'_>) -> Result<MatrixUser, async_graphql::Error> {
let state = ctx.state();

View File

@@ -126,6 +126,37 @@ impl LockUserPayload {
}
}
/// The input for the `setCanRequestAdmin` mutation.
#[derive(InputObject)]
struct SetCanRequestAdminInput {
/// The ID of the user to update.
user_id: ID,
/// Whether the user can request admin.
can_request_admin: bool,
}
/// The payload for the `setCanRequestAdmin` mutation.
#[derive(Description)]
enum SetCanRequestAdminPayload {
/// The user was updated.
Updated(mas_data_model::User),
/// The user was not found.
NotFound,
}
#[Object(use_type_description)]
impl SetCanRequestAdminPayload {
/// The user that was updated.
async fn user(&self) -> Option<User> {
match self {
Self::Updated(user) => Some(User(user.clone())),
Self::NotFound => None,
}
}
}
fn valid_username_character(c: char) -> bool {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
@@ -232,4 +263,37 @@ impl UserMutations {
Ok(LockUserPayload::Locked(user))
}
/// Set whether a user can request admin. This is only available to
/// administrators.
async fn set_can_request_admin(
&self,
ctx: &Context<'_>,
input: SetCanRequestAdminInput,
) -> Result<SetCanRequestAdminPayload, async_graphql::Error> {
let state = ctx.state();
let requester = ctx.requester();
if !requester.is_admin() {
return Err(async_graphql::Error::new("Unauthorized"));
}
let mut repo = state.repository().await?;
let user_id = NodeType::User.extract_ulid(&input.user_id)?;
let user = repo.user().lookup(user_id).await?;
let Some(user) = user else {
return Ok(SetCanRequestAdminPayload::NotFound);
};
let user = repo
.user()
.set_can_request_admin(user, input.can_request_admin)
.await?;
repo.save().await?;
Ok(SetCanRequestAdminPayload::Updated(user))
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n FROM users\n WHERE user_id = $1\n ",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
@@ -27,6 +27,11 @@
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "can_request_admin",
"type_info": "Bool"
}
],
"parameters": {
@@ -39,8 +44,9 @@
false,
true,
false,
true
true,
false
]
},
"hash": "e0ea7d93ab3f565828b2faab4cc5e1a6ac868c95bfaee3a6960df1cf484d53da"
"hash": "0d892dc8589ba54bb886972b6db00eaf7e41ff0db98fabdff5dcba0a7aa4e77d"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users\n SET can_request_admin = $2\n WHERE user_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Bool"
]
},
"nullable": []
},
"hash": "1dbc50cdab36da307c569891ab7b1ab4aaf128fed6be67ca0f139d697614c63b"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n FROM users\n WHERE username = $1\n ",
"query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ",
"describe": {
"columns": [
{
@@ -27,6 +27,11 @@
"ordinal": 4,
"name": "locked_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "can_request_admin",
"type_info": "Bool"
}
],
"parameters": {
@@ -39,8 +44,9 @@
false,
true,
false,
true
true,
false
]
},
"hash": "bfa5eaeaa5b4574bb083c86711eb4599f6374c96bb4a6827d400acb22fb0fd39"
"hash": "423e6aa88e0b8a01a90e108107a3d3998418fa43638b6510f28b56a2d6952222"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ",
"query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ",
"describe": {
"columns": [
{
@@ -57,6 +57,11 @@
"ordinal": 10,
"name": "user_locked_at",
"type_info": "Timestamptz"
},
{
"ordinal": 11,
"name": "user_can_request_admin",
"type_info": "Bool"
}
],
"parameters": {
@@ -75,8 +80,9 @@
false,
true,
false,
true
true,
false
]
},
"hash": "2b0d54c284dc4d946faae4190568bf597c04b40f010132dd7bf68462c47f9eac"
"hash": "e602a7c76386f732de686694257e03f35c18643c91a06f9c4a3fa0a5f103df58"
}

View File

@@ -0,0 +1,17 @@
-- 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.
-- Adds a `can_request_admin` column to the `users` table
ALTER TABLE users
ADD COLUMN can_request_admin BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -34,6 +34,7 @@ pub enum Users {
PrimaryUserEmailId,
CreatedAt,
LockedAt,
CanRequestAdmin,
}
#[derive(sea_query::Iden)]

View File

@@ -57,6 +57,7 @@ struct UserLookup {
primary_user_email_id: Option<Uuid>,
created_at: DateTime<Utc>,
locked_at: Option<DateTime<Utc>>,
can_request_admin: bool,
}
impl From<UserLookup> for User {
@@ -69,6 +70,7 @@ impl From<UserLookup> for User {
primary_user_email_id: value.primary_user_email_id.map(Into::into),
created_at: value.created_at,
locked_at: value.locked_at,
can_request_admin: value.can_request_admin,
}
}
}
@@ -95,6 +97,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
, primary_user_email_id
, created_at
, locked_at
, can_request_admin
FROM users
WHERE user_id = $1
"#,
@@ -127,6 +130,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
, primary_user_email_id
, created_at
, locked_at
, can_request_admin
FROM users
WHERE username = $1
"#,
@@ -186,6 +190,7 @@ impl<'c> UserRepository for PgUserRepository<'c> {
primary_user_email_id: None,
created_at,
locked_at: None,
can_request_admin: false,
})
}
@@ -281,4 +286,39 @@ impl<'c> UserRepository for PgUserRepository<'c> {
Ok(user)
}
#[tracing::instrument(
name = "db.user.set_can_request_admin",
skip_all,
fields(
db.statement,
%user.id,
user.can_request_admin = can_request_admin,
),
err,
)]
async fn set_can_request_admin(
&mut self,
mut user: User,
can_request_admin: bool,
) -> Result<User, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE users
SET can_request_admin = $2
WHERE user_id = $1
"#,
Uuid::from(user.id),
can_request_admin,
)
.traced()
.execute(&mut *self.conn)
.await?;
DatabaseError::ensure_affected_rows(&res, 1)?;
user.can_request_admin = can_request_admin;
Ok(user)
}
}

View File

@@ -63,6 +63,7 @@ struct SessionLookup {
user_primary_user_email_id: Option<Uuid>,
user_created_at: DateTime<Utc>,
user_locked_at: Option<DateTime<Utc>>,
user_can_request_admin: bool,
}
impl TryFrom<SessionLookup> for BrowserSession {
@@ -77,6 +78,7 @@ impl TryFrom<SessionLookup> for BrowserSession {
primary_user_email_id: value.user_primary_user_email_id.map(Into::into),
created_at: value.user_created_at,
locked_at: value.user_locked_at,
can_request_admin: value.user_can_request_admin,
};
Ok(BrowserSession {
@@ -155,6 +157,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
, u.primary_user_email_id AS "user_primary_user_email_id"
, u.created_at AS "user_created_at"
, u.locked_at AS "user_locked_at"
, u.can_request_admin AS "user_can_request_admin"
FROM user_sessions s
INNER JOIN users u
USING (user_id)
@@ -313,6 +316,10 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
Expr::col((Users::Table, Users::LockedAt)),
SessionLookupIden::UserLockedAt,
)
.expr_as(
Expr::col((Users::Table, Users::CanRequestAdmin)),
SessionLookupIden::UserCanRequestAdmin,
)
.from(UserSessions::Table)
.inner_join(
Users::Table,

View File

@@ -95,6 +95,26 @@ async fn test_user_repo(pool: PgPool) {
let user = repo.user().unlock(user).await.unwrap();
assert!(user.is_valid());
// Set the can_request_admin flag
let user = repo.user().set_can_request_admin(user, true).await.unwrap();
assert!(user.can_request_admin);
// Check that the property is retrieved on lookup
let user = repo.user().lookup(user.id).await.unwrap().unwrap();
assert!(user.can_request_admin);
// Unset the can_request_admin flag
let user = repo
.user()
.set_can_request_admin(user, false)
.await
.unwrap();
assert!(!user.can_request_admin);
// Check that the property is retrieved on lookup
let user = repo.user().lookup(user.id).await.unwrap().unwrap();
assert!(!user.can_request_admin);
repo.save().await.unwrap();
}

View File

@@ -123,6 +123,23 @@ pub trait UserRepository: Send + Sync {
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn unlock(&mut self, user: User) -> Result<User, Self::Error>;
/// Set whether a [`User`] can request admin
///
/// Returns the [`User`] with the new `can_request_admin` value
///
/// # Parameters
///
/// * `user`: The [`User`] to update
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn set_can_request_admin(
&mut self,
user: User,
can_request_admin: bool,
) -> Result<User, Self::Error>;
}
repository_impl!(UserRepository:
@@ -137,4 +154,9 @@ repository_impl!(UserRepository:
async fn exists(&mut self, username: &str) -> Result<bool, Self::Error>;
async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result<User, Self::Error>;
async fn unlock(&mut self, user: User) -> Result<User, Self::Error>;
async fn set_can_request_admin(
&mut self,
user: User,
can_request_admin: bool,
) -> Result<User, Self::Error>;
);

View File

@@ -643,6 +643,13 @@ type Mutation {
"""
lockUser(input: LockUserInput!): LockUserPayload!
"""
Set whether a user can request admin. This is only available to
administrators.
"""
setCanRequestAdmin(
input: SetCanRequestAdminInput!
): SetCanRequestAdminPayload!
"""
Create a new arbitrary OAuth 2.0 Session.
Only available for administrators.
@@ -1018,6 +1025,30 @@ enum SessionState {
FINISHED
}
"""
The input for the `setCanRequestAdmin` mutation.
"""
input SetCanRequestAdminInput {
"""
The ID of the user to update.
"""
userId: ID!
"""
Whether the user can request admin.
"""
canRequestAdmin: Boolean!
}
"""
The payload for the `setCanRequestAdmin` mutation.
"""
type SetCanRequestAdminPayload {
"""
The user that was updated.
"""
user: User
}
"""
The input for the `addEmail` mutation
"""
@@ -1233,6 +1264,10 @@ type User implements Node {
"""
lockedAt: DateTime
"""
Whether the user can request admin privileges.
"""
canRequestAdmin: Boolean!
"""
Access to the user's Matrix account information.
"""
matrix: MatrixUser!

View File

@@ -436,6 +436,11 @@ export type Mutation = {
removeEmail: RemoveEmailPayload;
/** Send a verification code for an email address */
sendVerificationEmail: SendVerificationEmailPayload;
/**
* Set whether a user can request admin. This is only available to
* administrators.
*/
setCanRequestAdmin: SetCanRequestAdminPayload;
/** Set the display name of a user */
setDisplayName: SetDisplayNamePayload;
/** Set an email address as primary */
@@ -489,6 +494,11 @@ export type MutationSendVerificationEmailArgs = {
input: SendVerificationEmailInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationSetCanRequestAdminArgs = {
input: SetCanRequestAdminInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationSetDisplayNameArgs = {
input: SetDisplayNameInput;
@@ -762,6 +772,21 @@ export enum SessionState {
Finished = "FINISHED",
}
/** The input for the `setCanRequestAdmin` mutation. */
export type SetCanRequestAdminInput = {
/** Whether the user can request admin. */
canRequestAdmin: Scalars["Boolean"]["input"];
/** The ID of the user to update. */
userId: Scalars["ID"]["input"];
};
/** The payload for the `setCanRequestAdmin` mutation. */
export type SetCanRequestAdminPayload = {
__typename?: "SetCanRequestAdminPayload";
/** The user that was updated. */
user?: Maybe<User>;
};
/** The input for the `addEmail` mutation */
export type SetDisplayNameInput = {
/** The display name to set. If `None`, the display name will be removed. */
@@ -891,6 +916,8 @@ export type User = Node & {
appSessions: AppSessionConnection;
/** Get the list of active browser sessions, chronologically sorted */
browserSessions: BrowserSessionConnection;
/** Whether the user can request admin privileges. */
canRequestAdmin: Scalars["Boolean"]["output"];
/** Get the list of compatibility sessions, chronologically sorted */
compatSessions: CompatSessionConnection;
/** Get the list of compatibility SSO logins, chronologically sorted */

View File

@@ -1261,6 +1261,29 @@ export default {
},
],
},
{
name: "setCanRequestAdmin",
type: {
kind: "NON_NULL",
ofType: {
kind: "OBJECT",
name: "SetCanRequestAdminPayload",
ofType: null,
},
},
args: [
{
name: "input",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
},
],
},
{
name: "setDisplayName",
type: {
@@ -2140,6 +2163,22 @@ export default {
},
],
},
{
kind: "OBJECT",
name: "SetCanRequestAdminPayload",
fields: [
{
name: "user",
type: {
kind: "OBJECT",
name: "User",
ofType: null,
},
args: [],
},
],
interfaces: [],
},
{
kind: "OBJECT",
name: "SetDisplayNamePayload",
@@ -2623,6 +2662,17 @@ export default {
},
],
},
{
name: "canRequestAdmin",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
args: [],
},
{
name: "compatSessions",
type: {

View File

@@ -11,6 +11,18 @@ allow {
count(violation) == 0
}
# Users can request admin scopes if either:
# 1. They are in the admin_users list
can_request_admin(user) {
some admin_user in data.admin_users
user.username == admin_user
}
# 2. They have the can_request_admin flag set to true
can_request_admin(user) {
user.can_request_admin
}
# Special case to make empty scope work
allowed_scope("") = true
@@ -22,8 +34,7 @@ allowed_scope("email") = true
allowed_scope("urn:synapse:admin:*") {
# Synapse doesn't support user-less tokens yet, so access to the admin API can only be used with an authorization_code grant as the user is present
input.grant_type == "authorization_code"
some user in data.admin_users
input.user.username == user
can_request_admin(input.user)
}
# This grants access to the /graphql API endpoint
@@ -32,8 +43,7 @@ allowed_scope("urn:mas:graphql:*") = true
# This makes it possible to query and do anything in the GraphQL API as an admin
allowed_scope("urn:mas:admin") {
input.grant_type == "authorization_code"
some user in data.admin_users
input.user.username == user
can_request_admin(input.user)
}
# This makes it possible to get the admin scope for clients that are allowed

View File

@@ -96,6 +96,20 @@ test_synapse_admin_scopes {
with data.admin_users as []
with input.grant_type as "authorization_code"
with input.scope as "urn:synapse:admin:*"
allow with input.user as user
with input.user.can_request_admin as true
with input.client as client
with data.admin_users as []
with input.grant_type as "authorization_code"
with input.scope as "urn:synapse:admin:*"
not allow with input.user as user
with input.user.can_request_admin as false
with input.client as client
with data.admin_users as []
with input.grant_type as "authorization_code"
with input.scope as "urn:synapse:admin:*"
}
test_mas_scopes {