From 2ea14635d267bd41a7877b9e29f71473bdc47436 Mon Sep 17 00:00:00 2001 From: silverpill Date: Thu, 19 Jan 2023 13:13:49 +0000 Subject: [PATCH] Implement role system https://codeberg.org/silverpill/mitra/issues/25 --- CHANGELOG.md | 2 + docs/openapi.yaml | 37 ++++++++-- migrations/V0041__user_account__user_role.sql | 2 + migrations/schema.sql | 1 + src/mastodon_api/accounts/types.rs | 40 ++++++++++ src/mastodon_api/statuses/views.rs | 8 +- src/models/posts/helpers.rs | 22 +++++- src/models/users/queries.rs | 25 ++++++- src/models/users/types.rs | 73 +++++++++++++++++++ 9 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 migrations/V0041__user_account__user_role.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cff696..05f5e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added `approval_required` and `invites_enabled` flags to `/api/v1/instance` endpoint response. - Added `registration.type` configuration option (replaces `registrations_open`). +- Implemented roles & permissions. +- Added "read-only user" role. ### Deprecated diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a709329..5fdd27d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -114,7 +114,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AccountWithSource' + $ref: '#/components/schemas/CredentialAccount' 400: description: Invalid user data /api/v1/accounts/verify_credentials: @@ -128,7 +128,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AccountWithSource' + $ref: '#/components/schemas/CredentialAccount' /api/v1/accounts/update_credentials: patch: summary: Update the user's display and preferences. @@ -183,7 +183,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AccountWithSource' + $ref: '#/components/schemas/CredentialAccount' 400: description: Invalid user data. /api/v1/accounts/signed_update: @@ -703,7 +703,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AccountWithSource' + $ref: '#/components/schemas/CredentialAccount' 400: description: Invalid request data. /api/v1/settings/export_followers: @@ -780,7 +780,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AccountWithSource' + $ref: '#/components/schemas/CredentialAccount' 400: description: Invalid data. /api/v1/statuses: @@ -1324,7 +1324,7 @@ components: subscribers_count: description: The reported subscribers of this profile. type: number - AccountWithSource: + CredentialAccount: allOf: - $ref: '#/components/schemas/Account' - type: object @@ -1336,6 +1336,9 @@ components: note: description: Profile bio. type: string + role: + description: The role assigned to the currently authorized user. + $ref: '#/components/schemas/Role' ActivityParameters: type: object properties: @@ -1617,6 +1620,28 @@ components: description: Are you receiving this user's replies in your home timeline? type: boolean default: true + Role: + type: object + properties: + id: + description: The ID of the role in the database. + type: integer + example: 1 + name: + description: The name of the role. + type: string + enum: + - user + - admin + - read_only_user + permissions: + description: A list of all permissions granted to the role. + type: array + items: + type: string + enum: + - create_follow_request + - create_post Signature: type: object properties: diff --git a/migrations/V0041__user_account__user_role.sql b/migrations/V0041__user_account__user_role.sql new file mode 100644 index 0000000..abf5a83 --- /dev/null +++ b/migrations/V0041__user_account__user_role.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_account ADD COLUMN user_role SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE user_account ALTER COLUMN user_role DROP DEFAULT; diff --git a/migrations/schema.sql b/migrations/schema.sql index 87fcd34..4c938a7 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -47,6 +47,7 @@ CREATE TABLE user_account ( password_hash VARCHAR(200), private_key TEXT NOT NULL, invite_code VARCHAR(100) UNIQUE REFERENCES user_invite_code (code) ON DELETE SET NULL, + user_role SMALLINT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index 01f7b72..52ec509 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -22,6 +22,8 @@ use crate::models::{ subscriptions::types::Subscription, users::types::{ validate_local_username, + Role, + Permission, User, }, }; @@ -53,6 +55,39 @@ pub struct Source { pub fields: Vec, } +/// https://docs.joinmastodon.org/entities/Role/ +#[derive(Serialize)] +pub struct ApiRole { + pub id: i32, + pub name: String, + pub permissions: Vec, +} + +impl ApiRole { + fn from_db(role: Role) -> Self { + let role_name = match role { + Role::Guest => unimplemented!(), + Role::NormalUser => "user", + Role::Admin => "admin", + Role::ReadOnlyUser => "read_only_user", + }; + // Mastodon 4.0 uses bitmask + let permissions = role.get_permissions().iter() + .map(|permission| { + match permission { + Permission::CreateFollowRequest => "create_follow_request", + Permission::CreatePost => "create_post", + }.to_string() + }) + .collect(); + Self { + id: i16::from(&role).into(), + name: role_name.to_string(), + permissions: permissions, + } + } +} + /// https://docs.joinmastodon.org/entities/account/ #[derive(Serialize)] pub struct Account { @@ -74,7 +109,9 @@ pub struct Account { pub subscribers_count: i32, pub statuses_count: i32, + // CredentialAccount attributes pub source: Option, + pub role: Option, } impl Account { @@ -161,6 +198,7 @@ impl Account { subscribers_count: profile.subscriber_count, statuses_count: profile.post_count, source: None, + role: None, } } @@ -177,8 +215,10 @@ impl Account { note: user.profile.bio_source.clone(), fields: fields_sources, }; + let role = ApiRole::from_db(user.role); let mut account = Self::from_profile(user.profile, instance_url); account.source = Some(source); + account.role = Some(role); account } } diff --git a/src/mastodon_api/statuses/views.rs b/src/mastodon_api/statuses/views.rs index afa3f94..34037b9 100644 --- a/src/mastodon_api/statuses/views.rs +++ b/src/mastodon_api/statuses/views.rs @@ -20,7 +20,7 @@ use crate::ipfs::store as ipfs_store; use crate::ipfs::posts::PostMetadata; use crate::ipfs::utils::get_ipfs_url; use crate::mastodon_api::oauth::auth::get_current_user; -use crate::models::posts::helpers::can_view_post; +use crate::models::posts::helpers::{can_create_post, can_view_post}; use crate::models::posts::queries::{ create_post, get_post_by_id, @@ -68,6 +68,9 @@ async fn create_status( ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; + if !can_create_post(¤t_user) { + return Err(HttpError::PermissionError); + }; let instance = config.instance(); let status_data = status_data.into_inner(); let visibility = match status_data.visibility.as_deref() { @@ -392,6 +395,9 @@ async fn reblog( ) -> Result { let db_client = &mut **get_database_client(&db_pool).await?; let current_user = get_current_user(db_client, auth.token()).await?; + if !can_create_post(¤t_user) { + return Err(HttpError::PermissionError); + }; let mut post = get_post_by_id(db_client, &status_id).await?; if !post.is_public() || post.repost_of_id.is_some() { return Err(HttpError::NotFoundError("post")); diff --git a/src/models/posts/helpers.rs b/src/models/posts/helpers.rs index 5dedf37..4a542e2 100644 --- a/src/models/posts/helpers.rs +++ b/src/models/posts/helpers.rs @@ -5,7 +5,7 @@ use crate::database::{DatabaseClient, DatabaseError}; use crate::models::reactions::queries::find_favourited_by_user; use crate::models::relationships::queries::has_relationship; use crate::models::relationships::types::RelationshipType; -use crate::models::users::types::User; +use crate::models::users::types::{Permission, User}; use super::queries::{ get_post_by_id, get_post_by_remote_object_id, @@ -123,6 +123,13 @@ pub async fn can_view_post( Ok(result) } +pub fn can_create_post( + user: &User, +) -> bool { + let permissions = user.role.get_permissions(); + permissions.contains(&Permission::CreatePost) +} + pub async fn get_local_post_by_id( db_client: &impl DatabaseClient, post_id: &Uuid, @@ -162,7 +169,7 @@ mod tests { use crate::models::posts::types::PostCreateData; use crate::models::relationships::queries::{follow, subscribe}; use crate::models::users::queries::create_user; - use crate::models::users::types::UserCreateData; + use crate::models::users::types::{Role, User, UserCreateData}; use super::*; #[tokio::test] @@ -296,4 +303,15 @@ mod tests { true, ); } + + #[test] + fn test_can_create_post() { + let mut user = User { + role: Role::NormalUser, + ..Default::default() + }; + assert_eq!(can_create_post(&user), true); + user.role = Role::ReadOnlyUser; + assert_eq!(can_create_post(&user), false); + } } diff --git a/src/models/users/queries.rs b/src/models/users/queries.rs index 0f7e185..6a0a9d8 100644 --- a/src/models/users/queries.rs +++ b/src/models/users/queries.rs @@ -9,7 +9,7 @@ use crate::identity::{did::Did, did_pkh::DidPkh}; use crate::models::profiles::queries::create_profile; use crate::models::profiles::types::{DbActorProfile, ProfileCreateData}; use crate::utils::currencies::Currency; -use super::types::{DbUser, User, UserCreateData}; +use super::types::{DbUser, Role, User, UserCreateData}; use super::utils::generate_invite_code; pub async fn create_invite_code( @@ -111,9 +111,14 @@ pub async fn create_user( let row = transaction.query_one( " INSERT INTO user_account ( - id, wallet_address, password_hash, private_key, invite_code + id, + wallet_address, + password_hash, + private_key, + invite_code, + user_role ) - VALUES ($1, $2, $3, $4, $5) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING user_account ", &[ @@ -122,6 +127,7 @@ pub async fn create_user( &user_data.password_hash, &user_data.private_key_pem, &user_data.invite_code, + &Role::default(), ], ).await.map_err(catch_unique_violation("user"))?; let db_user: DbUser = row.try_get("user_account")?; @@ -271,6 +277,19 @@ mod tests { use crate::database::test_utils::create_test_database; use super::*; + #[tokio::test] + #[serial] + async fn test_create_user() { + let db_client = &mut create_test_database().await; + let user_data = UserCreateData { + username: "myname".to_string(), + ..Default::default() + }; + let user = create_user(db_client, user_data).await.unwrap(); + assert_eq!(user.profile.username, "myname"); + assert_eq!(user.role, Role::NormalUser); + } + #[tokio::test] #[serial] async fn test_create_user_impersonation_protection() { diff --git a/src/models/users/types.rs b/src/models/users/types.rs index db95f89..4fbefc6 100644 --- a/src/models/users/types.rs +++ b/src/models/users/types.rs @@ -3,11 +3,81 @@ use postgres_types::FromSql; use regex::Regex; use uuid::Uuid; +use crate::database::{ + int_enum::{int_enum_from_sql, int_enum_to_sql}, + DatabaseTypeError, +}; use crate::errors::ValidationError; use crate::identity::did::Did; use crate::models::profiles::types::DbActorProfile; use crate::utils::currencies::Currency; +#[derive(PartialEq)] +pub enum Permission { + CreateFollowRequest, + CreatePost, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Role { + Guest, + NormalUser, + Admin, + ReadOnlyUser, +} + +impl Default for Role { + fn default() -> Self { Self::NormalUser } +} + +impl Role { + pub fn get_permissions(&self) -> Vec { + match self { + Self::Guest => vec![], + Self::NormalUser => vec![ + Permission::CreateFollowRequest, + Permission::CreatePost, + ], + Self::Admin => vec![ + Permission::CreateFollowRequest, + Permission::CreatePost, + ], + Self::ReadOnlyUser => vec![ + Permission::CreateFollowRequest, + ], + } + } +} + +impl From<&Role> for i16 { + fn from(value: &Role) -> i16 { + match value { + Role::Guest => 0, + Role::NormalUser => 1, + Role::Admin => 2, + Role::ReadOnlyUser => 3, + } + } +} + +impl TryFrom for Role { + type Error = DatabaseTypeError; + + fn try_from(value: i16) -> Result { + let role = match value { + 0 => Self::Guest, + 1 => Self::NormalUser, + 2 => Self::Admin, + 3 => Self::ReadOnlyUser, + _ => return Err(DatabaseTypeError), + }; + Ok(role) + } +} + +int_enum_from_sql!(Role); +int_enum_to_sql!(Role); + #[allow(dead_code)] #[derive(FromSql)] #[postgres(name = "user_account")] @@ -17,6 +87,7 @@ pub struct DbUser { password_hash: Option, private_key: String, invite_code: Option, + user_role: Role, created_at: DateTime, } @@ -28,6 +99,7 @@ pub struct User { pub wallet_address: Option, // login address pub password_hash: Option, pub private_key: String, + pub role: Role, pub profile: DbActorProfile, } @@ -42,6 +114,7 @@ impl User { wallet_address: db_user.wallet_address, password_hash: db_user.password_hash, private_key: db_user.private_key, + role: db_user.user_role, profile: db_profile, } }