Implement role system

https://codeberg.org/silverpill/mitra/issues/25
This commit is contained in:
silverpill 2023-01-19 13:13:49 +00:00
parent 771f45baab
commit 2ea14635d2
9 changed files with 198 additions and 12 deletions

View file

@ -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

View file

@ -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:

View file

@ -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;

View file

@ -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()
);

View file

@ -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<AccountField>,
}
/// https://docs.joinmastodon.org/entities/Role/
#[derive(Serialize)]
pub struct ApiRole {
pub id: i32,
pub name: String,
pub permissions: Vec<String>,
}
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<Source>,
pub role: Option<ApiRole>,
}
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
}
}

View file

@ -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<HttpResponse, HttpError> {
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(&current_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<HttpResponse, HttpError> {
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(&current_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"));

View file

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

View file

@ -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() {

View file

@ -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<Permission> {
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<i16> for Role {
type Error = DatabaseTypeError;
fn try_from(value: i16) -> Result<Self, Self::Error> {
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<String>,
private_key: String,
invite_code: Option<String>,
user_role: Role,
created_at: DateTime<Utc>,
}
@ -28,6 +99,7 @@ pub struct User {
pub wallet_address: Option<String>, // login address
pub password_hash: Option<String>,
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,
}
}