From 637040495df53695b561b005a85f73c2fb6aeadb Mon Sep 17 00:00:00 2001 From: Flawed Date: Fri, 6 Mar 2026 01:53:55 -0800 Subject: [PATCH 1/7] Add table schemas for user roles and permissions --- .../migrations/20220906103252_deepwell.sql | 68 +++++++++++++++++++ deepwell/src/models/mod.rs | 4 ++ deepwell/src/models/permission.rs | 40 +++++++++++ deepwell/src/models/prelude.rs | 4 ++ deepwell/src/models/role.rs | 67 ++++++++++++++++++ deepwell/src/models/role_permission.rs | 47 +++++++++++++ deepwell/src/models/site.rs | 11 ++- deepwell/src/models/user_role.rs | 53 +++++++++++++++ 8 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 deepwell/src/models/permission.rs create mode 100644 deepwell/src/models/role.rs create mode 100644 deepwell/src/models/role_permission.rs create mode 100644 deepwell/src/models/user_role.rs diff --git a/deepwell/migrations/20220906103252_deepwell.sql b/deepwell/migrations/20220906103252_deepwell.sql index cbc16f5785..b90cb9de95 100644 --- a/deepwell/migrations/20220906103252_deepwell.sql +++ b/deepwell/migrations/20220906103252_deepwell.sql @@ -934,6 +934,74 @@ CREATE INDEX forum_post_parent_idx ON forum_post (parent_post_id); CREATE INDEX forum_post_latest_revision_idx ON forum_post (latest_revision_id); CREATE INDEX forum_post_revision_lookup_idx ON forum_post_revision (forum_post_id, revision_number DESC); +-- +-- Role / permission system +-- + +-- Lookup table for permissions. This is shared across all sites. +CREATE TABLE permission ( + permission_id BIGSERIAL PRIMARY KEY, + description TEXT NOT NULL, + resource_type TEXT NOT NULL, + action TEXT NOT NULL, + + UNIQUE (resource_type, action) +); + +-- Roles in a site. +CREATE TABLE role ( + role_id BIGSERIAL PRIMARY KEY, + + -- A role entry denotes a unique role in a site. A user maybe an admin in one site but not in another. + site_id BIGINT NOT NULL REFERENCES site(site_id), + name TEXT NOT NULL, + description TEXT, + from_wikidot BOOLEAN NOT NULL DEFAULT false, + + -- Implicit roles are invisible to the user, granted by the system under specific conditions + -- i.e. Guest role granted when a non-member visits the site, + -- or Author role granted when a user interacts with a page they authored. + -- Implicit roles are visible to admins where they can select the role's permissions. + -- Implicit roles cannot be manually assigned.. + implicit BOOLEAN NOT NULL DEFAULT false, + + -- System roles cannot be deleted (i.e. Admin) + is_system BOOLEAN NOT NULL DEFAULT false, + + -- Rudimentary role hierarchy. + -- Roles with higher level are granted more permissions than roles with lower levels. + -- Users with role management permissions can only grant roles with lower levels and affect users of lower levels. + level INTEGER NOT NULL CHECK (level >= 0), + + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + deleted_at TIMESTAMP WITH TIME ZONE, + + UNIQUE (site_id, name) +); + +-- Role permissions (many-to-many) +CREATE TABLE role_permission ( + role_id BIGINT NOT NULL REFERENCES role(role_id), + permission_id BIGINT NOT NULL REFERENCES permission(permission_id), + PRIMARY KEY (role_id, permission_id) +); + +-- User role assignments (many-to-many) +CREATE TABLE user_role ( + user_id BIGINT NOT NULL REFERENCES "user"(user_id), + role_id BIGINT NOT NULL REFERENCES role(role_id), + + assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + assigned_by BIGINT NOT NULL REFERENCES "user"(user_id), + expires_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + PRIMARY KEY (user_id, role_id) +); + +-- Index for permission lookup. +CREATE UNIQUE INDEX permission_resource_action_idx ON permission + (resource_type, action); + -- -- Audit Log -- diff --git a/deepwell/src/models/mod.rs b/deepwell/src/models/mod.rs index 493ccf47f8..2fa1b4edb6 100644 --- a/deepwell/src/models/mod.rs +++ b/deepwell/src/models/mod.rs @@ -30,7 +30,10 @@ pub mod page_lock; pub mod page_parent; pub mod page_revision; pub mod page_vote; +pub mod permission; pub mod relation; +pub mod role; +pub mod role_permission; pub mod sea_orm_active_enums; pub mod session; pub mod site; @@ -38,3 +41,4 @@ pub mod site_domain; pub mod text; pub mod text_block; pub mod user; +pub mod user_role; diff --git a/deepwell/src/models/permission.rs b/deepwell/src/models/permission.rs new file mode 100644 index 0000000000..74c2c8505e --- /dev/null +++ b/deepwell/src/models/permission.rs @@ -0,0 +1,40 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "permission")] +pub struct Model { + #[sea_orm(primary_key)] + pub permission_id: i64, + #[sea_orm(column_type = "Text")] + pub description: String, + #[sea_orm(column_type = "Text")] + pub resource_type: String, + #[sea_orm(column_type = "Text")] + pub action: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::role_permission::Entity")] + RolePermission, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolePermission.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::role_permission::Relation::Role.def() + } + fn via() -> Option { + Some(super::role_permission::Relation::Permission.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/deepwell/src/models/prelude.rs b/deepwell/src/models/prelude.rs index 53354b2c65..e7cd8005c7 100644 --- a/deepwell/src/models/prelude.rs +++ b/deepwell/src/models/prelude.rs @@ -28,10 +28,14 @@ pub use super::page_lock::Entity as PageLock; pub use super::page_parent::Entity as PageParent; pub use super::page_revision::Entity as PageRevision; pub use super::page_vote::Entity as PageVote; +pub use super::permission::Entity as Permission; pub use super::relation::Entity as Relation; +pub use super::role::Entity as Role; +pub use super::role_permission::Entity as RolePermission; pub use super::session::Entity as Session; pub use super::site::Entity as Site; pub use super::site_domain::Entity as SiteDomain; pub use super::text::Entity as Text; pub use super::text_block::Entity as TextBlock; pub use super::user::Entity as User; +pub use super::user_role::Entity as UserRole; diff --git a/deepwell/src/models/role.rs b/deepwell/src/models/role.rs new file mode 100644 index 0000000000..846cfb187a --- /dev/null +++ b/deepwell/src/models/role.rs @@ -0,0 +1,67 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "role")] +pub struct Model { + #[sea_orm(primary_key)] + pub role_id: i64, + pub site_id: i64, + #[sea_orm(column_type = "Text")] + pub name: String, + #[sea_orm(column_type = "Text", nullable)] + pub description: Option, + pub from_wikidot: bool, + pub implicit: bool, + pub is_system: bool, + pub level: i32, + pub created_at: TimeDateTimeWithTimeZone, + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::role_permission::Entity")] + RolePermission, + #[sea_orm( + belongs_to = "super::site::Entity", + from = "Column::SiteId", + to = "super::site::Column::SiteId", + on_update = "NoAction", + on_delete = "NoAction" + )] + Site, + #[sea_orm(has_many = "super::user_role::Entity")] + UserRole, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolePermission.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Site.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRole.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::role_permission::Relation::Permission.def() + } + fn via() -> Option { + Some(super::role_permission::Relation::Role.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/deepwell/src/models/role_permission.rs b/deepwell/src/models/role_permission.rs new file mode 100644 index 0000000000..4b46a4ffd3 --- /dev/null +++ b/deepwell/src/models/role_permission.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "role_permission")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: i64, + #[sea_orm(primary_key, auto_increment = false)] + pub permission_id: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::permission::Entity", + from = "Column::PermissionId", + to = "super::permission::Column::PermissionId", + on_update = "NoAction", + on_delete = "NoAction" + )] + Permission, + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::RoleId", + on_update = "NoAction", + on_delete = "NoAction" + )] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Permission.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/deepwell/src/models/site.rs b/deepwell/src/models/site.rs index 615b2afa04..3a243e5378 100644 --- a/deepwell/src/models/site.rs +++ b/deepwell/src/models/site.rs @@ -9,11 +9,8 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key)] pub site_id: i64, - #[serde(with = "time::serde::rfc3339")] pub created_at: TimeDateTimeWithTimeZone, - #[serde(with = "time::serde::rfc3339::option")] pub updated_at: Option, - #[serde(with = "time::serde::rfc3339::option")] pub deleted_at: Option, pub from_wikidot: bool, #[sea_orm(column_type = "Text")] @@ -67,6 +64,8 @@ pub enum Relation { PageConnectionMissing, #[sea_orm(has_many = "super::page_revision::Entity")] PageRevision, + #[sea_orm(has_many = "super::role::Entity")] + Role, #[sea_orm(has_many = "super::site_domain::Entity")] SiteDomain, #[sea_orm( @@ -157,6 +156,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::SiteDomain.def() diff --git a/deepwell/src/models/user_role.rs b/deepwell/src/models/user_role.rs new file mode 100644 index 0000000000..512ca61318 --- /dev/null +++ b/deepwell/src/models/user_role.rs @@ -0,0 +1,53 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user_role")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i64, + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: i64, + pub assigned_at: TimeDateTimeWithTimeZone, + pub assigned_by: i64, + pub expires_at: Option, + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::RoleId", + on_update = "NoAction", + on_delete = "NoAction" + )] + Role, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::AssignedBy", + to = "super::user::Column::UserId", + on_update = "NoAction", + on_delete = "NoAction" + )] + User2, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::UserId", + on_update = "NoAction", + on_delete = "NoAction" + )] + User1, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} From 16da9908eec676a44d06991e5bf925a05ee39b0f Mon Sep 17 00:00:00 2001 From: Flawed Date: Fri, 6 Mar 2026 01:53:55 -0800 Subject: [PATCH 2/7] Set up role/permission services and seeding data --- deepwell/seeder/permissions.json | 42 +++ deepwell/seeder/roles.json | 56 ++++ deepwell/src/database/seeder/data.rs | 31 ++ deepwell/src/database/seeder/mod.rs | 84 +++++ deepwell/src/error/error_type.rs | 29 +- deepwell/src/services/audit/structs.rs | 111 +++++++ deepwell/src/services/mod.rs | 2 + deepwell/src/services/permission/mod.rs | 31 ++ deepwell/src/services/permission/service.rs | 192 +++++++++++ deepwell/src/services/permission/structs.rs | 34 ++ deepwell/src/services/role/mod.rs | 30 ++ deepwell/src/services/role/service.rs | 348 ++++++++++++++++++++ deepwell/src/services/role/structs.rs | 52 +++ 13 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 deepwell/seeder/permissions.json create mode 100644 deepwell/seeder/roles.json create mode 100644 deepwell/src/services/permission/mod.rs create mode 100644 deepwell/src/services/permission/service.rs create mode 100644 deepwell/src/services/permission/structs.rs create mode 100644 deepwell/src/services/role/mod.rs create mode 100644 deepwell/src/services/role/service.rs create mode 100644 deepwell/src/services/role/structs.rs diff --git a/deepwell/seeder/permissions.json b/deepwell/seeder/permissions.json new file mode 100644 index 0000000000..05678c96c4 --- /dev/null +++ b/deepwell/seeder/permissions.json @@ -0,0 +1,42 @@ +[ + { + "description": "Can view and read pages", + "resource-type": "page", + "action": "view" + }, + { + "description": "Can edit existing pages", + "resource-type": "page", + "action": "edit" + }, + { + "description": "Can create new pages", + "resource-type": "page", + "action": "create" + }, + { + "description": "Can delete pages", + "resource-type": "page", + "action": "delete" + }, + { + "description": "Can rename/move pages", + "resource-type": "page", + "action": "rename" + }, + { + "description": "Can view role assignments", + "resource-type": "role", + "action": "view" + }, + { + "description": "Can assign roles to users", + "resource-type": "role", + "action": "assign" + }, + { + "description": "Can remove roles from users", + "resource-type": "role", + "action": "remove" + } +] diff --git a/deepwell/seeder/roles.json b/deepwell/seeder/roles.json new file mode 100644 index 0000000000..e89cd3da90 --- /dev/null +++ b/deepwell/seeder/roles.json @@ -0,0 +1,56 @@ +[ + { + "name": "admin", + "description": "Site administrator with full control", + "implicit": false, + "is-system": true, + "level": 99, + "permissions": [ + "page:view", + "page:edit", + "page:create", + "page:delete", + "page:rename", + "role:view", + "role:assign", + "role:remove" + ] + }, + { + "name": "moderator", + "description": "Site moderator with full control over pages", + "implicit": false, + "is-system": true, + "level": 90, + "permissions": [ + "page:view", + "page:edit", + "page:create", + "page:delete", + "role:view" + ] + }, + { + "name": "member", + "description": "Regular site member", + "implicit": false, + "is-system": true, + "level": 10, + "permissions": [ + "page:view", + "page:edit", + "page:create" + ] + }, + { + "name": "guest", + "description": "Implicit role for all visitors (including non-members)", + "implicit": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view" + ] + } +] + diff --git a/deepwell/src/database/seeder/data.rs b/deepwell/src/database/seeder/data.rs index 7c5c404c1e..0f5ca82fe9 100644 --- a/deepwell/src/database/seeder/data.rs +++ b/deepwell/src/database/seeder/data.rs @@ -34,6 +34,8 @@ pub struct SeedData { pub pages: HashMap>, pub files: HashMap>>, pub filters: Vec, + pub permissions: Vec, + pub roles: Vec, } impl SeedData { @@ -77,6 +79,14 @@ impl SeedData { let filters: Vec = Self::load_json(&mut path, "filters").or_raise(make_error)?; + // Load permissions data (platform-wide) + let permissions: Vec = + Self::load_json(&mut path, "permissions").or_raise(make_error)?; + + // Load roles template + let roles: Vec = + Self::load_json(&mut path, "roles").or_raise(make_error)?; + // Build and return Ok(SeedData { users, @@ -84,6 +94,8 @@ impl SeedData { pages: site_pages, files, filters, + permissions, + roles, }) } @@ -214,3 +226,22 @@ pub struct File { #[serde(default)] pub deleted: bool, } + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct Permission { + pub description: String, + pub resource_type: String, + pub action: String, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct Role { + pub name: String, + pub description: String, + pub implicit: bool, + pub is_system: bool, + pub level: i32, + pub permissions: Vec, +} diff --git a/deepwell/src/database/seeder/mod.rs b/deepwell/src/database/seeder/mod.rs index efd9beaf0a..ec861d0091 100644 --- a/deepwell/src/database/seeder/mod.rs +++ b/deepwell/src/database/seeder/mod.rs @@ -50,6 +50,8 @@ use std::fs; use std::io::Read; use std::net::{IpAddr, Ipv6Addr}; use std::path::{Path, PathBuf}; +use crate::services::permission::{PermissionInput, PermissionService}; +use crate::services::role::{CreateRoleInput, GrantUserRoleInput, RoleService}; /// The IP address to record for any seeded data. /// This is just `localhost`. @@ -97,6 +99,8 @@ pub async fn seed(state: &ServerState) -> Result<()> { pages, files, filters, + permissions, + roles, } = SeedData::load(&state.config.seeder_path).or_raise(make_error)?; let mut user_aliases = Vec::new(); @@ -173,6 +177,22 @@ pub async fn seed(state: &ServerState) -> Result<()> { } } + // Seed permissions (platform-wide) + for perm in permissions { + info!("Creating permission {}.{}", perm.resource_type, perm.action); + + PermissionService::create( + &ctx, + PermissionInput { + description: perm.description, + resource_type: perm.resource_type, + action: perm.action + } + ) + .await + .or_raise(make_error)?; + } + // Seed site data let mut site_ids = HashMap::new(); for site in sites { @@ -240,6 +260,70 @@ pub async fn seed(state: &ServerState) -> Result<()> { .await .or_raise(make_error)?; + // Create system roles for site + info!("Creating roles for site '{}'", site_id); + + for role_template in &roles { + let role = RoleService::create( + &ctx, + CreateRoleInput { + site_id, + name: role_template.name.clone(), + description: Some(role_template.description.clone()), + implicit: role_template.implicit, + is_system: role_template.is_system, + level: role_template.level, + }, + SEED_IP_ADDRESS + ).await.or_raise(make_error)?; + + // Assign permissions to role + for perm_spec in &role_template.permissions { + let parts: Vec<&str> = perm_spec.split(':').collect(); + if parts.len() != 2 { + warn!("Invalid permission spec '{}', expected 'resource:action'", perm_spec); + continue; + } + + // Get permission entry + let maybe_permission = PermissionService::get_permission_from_resource_and_action( + &ctx, + &parts[0], + &parts[1] + ).await.or_raise(make_error)?; + + match maybe_permission { + Some(permission) => { + PermissionService::add_permission_to_role( + &ctx, + role.role_id, + permission.permission_id, + ).await.or_raise(make_error)?; + } + _ => {} + }; + } + + // Make test user admin + // TODO: remove in prod + if role_template.name == "admin" { + let user = UserService::get(&ctx, Reference::from(ADMIN_USER_ID)) + .await + .or_raise(make_error)?; + + RoleService::grant_role_to_user( + &ctx, + GrantUserRoleInput { + user_id: user.user_id, + role_id: role.role_id, + assigning_user_id: SYSTEM_USER_ID, + expires_at: None, + }, + SEED_IP_ADDRESS + ).await.or_raise(make_error)?; + } + } + site_ids.insert(slug, site_id); } diff --git a/deepwell/src/error/error_type.rs b/deepwell/src/error/error_type.rs index 1a4272e43b..5bd80cd725 100644 --- a/deepwell/src/error/error_type.rs +++ b/deepwell/src/error/error_type.rs @@ -129,6 +129,16 @@ pub enum ErrorType { session_user_id: i64, }, EmptyPassword, + + // 3100 + Permission, + Role, + AddRolePermission, + RemoveRolePermission, + GrantUserRole, + RevokeUserRole, + PermissionNotFound, + RoleNotFound, // 4000 BadRequest, @@ -376,7 +386,14 @@ impl ErrorType { ErrorType::EmptyPassword => 3005, // 3100 - Permissions - // TODO + ErrorType::Permission => 3100, + ErrorType::Role => 3101, + ErrorType::AddRolePermission => 3102, + ErrorType::RemoveRolePermission => 3103, + ErrorType::GrantUserRole => 3104, + ErrorType::RevokeUserRole => 3105, + ErrorType::PermissionNotFound => 3106, + ErrorType::RoleNotFound => 3107, // // 4000, 5000, 6000 -- Client / Request Errors @@ -597,6 +614,16 @@ impl ErrorType { "User associated with the session does not match the active user" } ErrorType::EmptyPassword => "A password was required, but not provided", + + // 3100 + ErrorType::Permission => "Failed to act on a permission", + ErrorType::Role => "Failed to act on a role", + ErrorType::AddRolePermission => "Failed to add a permission to a role", + ErrorType::RemoveRolePermission => "Failed to remove a permission from a role", + ErrorType::GrantUserRole => "Failed to grant a role to a user", + ErrorType::RevokeUserRole => "Failed to revoke a role from a user", + ErrorType::PermissionNotFound => "Permission not found", + ErrorType::RoleNotFound => "Role not found", // 4000 ErrorType::BadRequest => "The request is in some way malformed or incorrect", diff --git a/deepwell/src/services/audit/structs.rs b/deepwell/src/services/audit/structs.rs index 342e371bc7..6f85794624 100644 --- a/deepwell/src/services/audit/structs.rs +++ b/deepwell/src/services/audit/structs.rs @@ -23,6 +23,7 @@ use crate::license::License; use ftml::layout::Layout; use std::borrow::Cow; use std::net::IpAddr; +use sea_orm::prelude::TimeDateTimeWithTimeZone; use time::Date; // Main structs @@ -102,6 +103,34 @@ pub enum AuditEvent<'a> { page_id: i64, layout: Option, }, + RoleCreate { + site_id: i64, + role_id: i64, + }, + RoleUpdate { + role_id: i64, + updating_user_id: i64, + name: String, + description: Option, + level: i32, + old_permissions: Vec, + new_permissions: Vec + }, + RoleDelete { + role_id: i64, + deleting_user_id: i64 + }, + GrantUserRole { + user_id: i64, + role_id: i64, + assigning_user_id: i64, + expires_at: Option + }, + RevokeUserRole { + user_id: i64, + role_id: i64, + revoking_user_id: i64 + }, } impl<'a> AuditEvent<'a> { @@ -325,6 +354,88 @@ impl<'a> AuditEvent<'a> { extra_string_2: None, extra_number: None, }, + AuditEvent::RoleCreate { site_id, role_id } => RawAuditEvent { + event_type: "role.create", + ip_address, + user_id: None, + site_id: Some(site_id), + page_id: None, + extra_id_1: Some(role_id), + extra_id_2: None, + extra_string_1: None, + extra_string_2: None, + extra_number: None, + }, + AuditEvent::RoleUpdate { + role_id, + updating_user_id, + ref name, + ref description, + level, + ref old_permissions, + ref new_permissions, + } => { + let old_perms_json = serde_json::to_string(old_permissions).or_raise(make_error)?; + let new_perms_json = serde_json::to_string(new_permissions).or_raise(make_error)?; + + RawAuditEvent { + event_type: "role.update", + ip_address, + user_id: Some(updating_user_id), + site_id: None, + page_id: None, + extra_id_1: Some(role_id), + extra_id_2: None, + extra_string_1: Some(Cow::Owned(old_perms_json)), + extra_string_2: Some(Cow::Owned(new_perms_json)), + extra_number: Some(level), + } + }, + AuditEvent::RoleDelete { role_id, deleting_user_id } => RawAuditEvent { + event_type: "role.delete", + ip_address, + user_id: Some(deleting_user_id), + site_id: None, + page_id: None, + extra_id_1: Some(role_id), + extra_id_2: None, + extra_string_1: None, + extra_string_2: None, + extra_number: None, + }, + AuditEvent::GrantUserRole { + user_id, + role_id, + assigning_user_id, + expires_at, + } => RawAuditEvent { + event_type: "user_role.grant", + ip_address, + user_id: Some(assigning_user_id), + site_id: None, + page_id: None, + extra_id_1: Some(user_id), + extra_id_2: Some(role_id), + extra_string_1: expires_at.map(|dt| Cow::Owned(dt.to_string())), + extra_string_2: None, + extra_number: None, + }, + AuditEvent::RevokeUserRole { + user_id, + role_id, + revoking_user_id, + } => RawAuditEvent { + event_type: "user_role.revoke", + ip_address, + user_id: Some(revoking_user_id), + site_id: None, + page_id: None, + extra_id_1: Some(user_id), + extra_id_2: Some(role_id), + extra_string_1: None, + extra_string_2: None, + extra_number: None, + }, }; Ok(raw_event) diff --git a/deepwell/src/services/mod.rs b/deepwell/src/services/mod.rs index d9b29d2e97..ee956a6aaf 100644 --- a/deepwell/src/services/mod.rs +++ b/deepwell/src/services/mod.rs @@ -82,8 +82,10 @@ pub mod page_query; pub mod page_revision; pub mod parent; pub mod password; +pub mod permission; pub mod relation; pub mod render; +pub mod role; pub mod score; pub mod session; pub mod settings; diff --git a/deepwell/src/services/permission/mod.rs b/deepwell/src/services/permission/mod.rs new file mode 100644 index 0000000000..abe19e39e8 --- /dev/null +++ b/deepwell/src/services/permission/mod.rs @@ -0,0 +1,31 @@ +/* + * services/permission/mod.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +#[allow(unused_imports)] +mod prelude { + pub use super::super::prelude::*; + pub use super::structs::*; +} + +mod service; +mod structs; + +pub use self::service::PermissionService; +pub use self::structs::*; diff --git a/deepwell/src/services/permission/service.rs b/deepwell/src/services/permission/service.rs new file mode 100644 index 0000000000..41f669cede --- /dev/null +++ b/deepwell/src/services/permission/service.rs @@ -0,0 +1,192 @@ +/* + * services/permission/service.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use super::prelude::*; +use crate::models::permission::{self, Model as PermissionModel}; +use crate::models::role::Model as RoleModel; +use crate::models::role_permission::Model as RolePermissionModel; +use crate::error::{Error, ErrorType}; +use crate::error::ErrorType::Permission; +use crate::models::prelude::RolePermission; +use crate::models::role_permission; +use crate::services::audit::{AuditEvent, AuditService}; +use crate::services::ServiceContext; + +#[derive(Debug)] +pub struct PermissionService; + +impl PermissionService { + pub async fn create( + ctx: &ServiceContext<'_>, + PermissionInput { + description, + resource_type, + action + }: PermissionInput, + ) -> Result { + let txn = ctx.transaction(); + + // Insert permission + let model = permission::ActiveModel { + description: Set(description.clone()), + resource_type: Set(resource_type.clone()), + action: Set(action.clone()), + ..Default::default() + }; + + let make_error = || { + Error::new( + format!( + "failed to create permission for action {} on resource type {}", + action, resource_type + ), + ErrorType::Permission, + ) + }; + + let PermissionModel { permission_id, .. } = + model.insert(txn).await.or_raise(make_error)?; + + Ok(PermissionOutput { + permission_id, + description, + resource_type, + action, + }) + } + + pub async fn add_permission_to_role( + ctx: &ServiceContext<'_>, + role_id: i64, + permission_id: i64, + ) -> Result<()> { + let txn = ctx.transaction(); + + let make_error = || { + Error::new( + format!( + "failed to add permission ID {} to role ID {}", + permission_id, role_id + ), + ErrorType::AddRolePermission, + ) + }; + + role_permission::ActiveModel { + role_id: Set(role_id), + permission_id: Set(permission_id), + }.insert(txn).await.or_raise(make_error)?; + + Ok(()) + } + + pub async fn remove_permission_from_role( + ctx: &ServiceContext<'_>, + role_id: i64, + permission_id: i64, + ) -> Result<()> { + let txn = ctx.transaction(); + + let make_error = || { + Error::new( + format!( + "failed to remove permission ID {} from role ID {}", + permission_id, role_id + ), + ErrorType::RemoveRolePermission, + ) + }; + + role_permission::ActiveModel { + role_id: Set(role_id), + permission_id: Set(permission_id), + }.delete(txn).await.or_raise(make_error)?; + + Ok(()) + } + + pub async fn get_permission_ids_for_role( + ctx: &ServiceContext<'_>, + role_id: i64, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || Error::new( + format!("failed to get permissions for role ID {}", role_id), + ErrorType::Role + ); + + let role_permissions = RolePermission::find() + .filter( + role_permission::Column::RoleId.eq(role_id) + ) + .all(txn).await.or_raise(make_error)?; + + let permission_ids = role_permissions.iter().map(|perm| perm.permission_id).collect(); + + Ok(permission_ids) + } + + pub async fn get_optional( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || Error::new("failed to fetch permission", ErrorType::Permission); + + let permission = match reference { + Reference::Id(id) => { + permission::Entity::find_by_id(id).one(txn).await.or_raise(make_error)? + } + _ => None + }; + + Ok(permission) + } + + #[inline] + pub async fn get( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + ) -> Result { + find_or_error!(Self::get_optional(ctx, reference), "permission", Permission) + } + + pub async fn get_permission_from_resource_and_action( + ctx: &ServiceContext<'_>, + resource_type: &str, + action: &str, + ) -> Result> { + let txn = ctx.transaction(); + let make_error = || Error::new( + format!("failed to fetch permission for resource type {} and action {}", resource_type, action), + ErrorType::Permission + ); + + let permission = permission::Entity::find() + .filter( + permission::Column::ResourceType.eq(resource_type) + .and(permission::Column::Action.eq(action)) + ) + .one(txn).await.or_raise(make_error)?; + + Ok(permission) + } +} \ No newline at end of file diff --git a/deepwell/src/services/permission/structs.rs b/deepwell/src/services/permission/structs.rs new file mode 100644 index 0000000000..3eb6df1410 --- /dev/null +++ b/deepwell/src/services/permission/structs.rs @@ -0,0 +1,34 @@ +/* + * services/permission/struct.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +#[derive(Deserialize, Debug, Clone)] +pub struct PermissionInput { + pub description: String, + pub resource_type: String, + pub action: String, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct PermissionOutput { + pub permission_id: i64, + pub description: String, + pub resource_type: String, + pub action: String, +} \ No newline at end of file diff --git a/deepwell/src/services/role/mod.rs b/deepwell/src/services/role/mod.rs new file mode 100644 index 0000000000..decc5f8ae7 --- /dev/null +++ b/deepwell/src/services/role/mod.rs @@ -0,0 +1,30 @@ +/* + * services/role/mod.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#[allow(unused_imports)] +mod prelude { + pub use super::super::prelude::*; + pub use super::structs::*; +} + +mod service; +mod structs; + +pub use self::service::RoleService; +pub use self::structs::*; diff --git a/deepwell/src/services/role/service.rs b/deepwell/src/services/role/service.rs new file mode 100644 index 0000000000..3baaa24afc --- /dev/null +++ b/deepwell/src/services/role/service.rs @@ -0,0 +1,348 @@ +/* + * services/role/service.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use std::net::IpAddr; +use sea_orm::prelude::TimeDateTimeWithTimeZone; +use super::prelude::*; +use crate::models::role::{self, Entity as Role, Model as RoleModel}; +use crate::models::permission::Model as PermissionModel; +use crate::models::user_role::Model as UserRoleModel; +use crate::utils::now; +use crate::error::{Error, ErrorType}; +use crate::models::user_role; +use crate::services::audit::{AuditEvent, AuditService}; +use crate::services::permission::PermissionService; +use crate::services::ServiceContext; + +#[derive(Debug)] +pub struct RoleService; + +impl RoleService { + pub async fn create( + ctx: &ServiceContext<'_>, + CreateRoleInput { + site_id, + name, + description, + implicit, + is_system, + level, + }: CreateRoleInput, + ip_address: IpAddr, + ) -> Result { + let txn = ctx.transaction(); + + let make_error = || { + Error::new( + format!( + "failed to create role '{}' in site ID {}", + name, site_id, + ), + ErrorType::Role, + ) + }; + + // Insert role + let now = now(); + + let model = role::ActiveModel { + site_id: Set(site_id), + name: Set(name.clone()), + description: Set(description.clone()), + implicit: Set(implicit), + is_system: Set(is_system), + level: Set(level), + created_at: Set(now), + ..Default::default() + }; + + let RoleModel { role_id, .. } = model.insert(txn).await.or_raise(make_error)?; + + AuditService::log( + ctx, + ip_address, + AuditEvent::RoleCreate { + site_id, + role_id, + } + ) + .await + .or_raise(make_error)?; + + Ok(CreateRoleOutput { + role_id, + }) + } + + pub async fn update( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + UpdateRoleInput { + name, + description, + level, + permission_ids, + }: UpdateRoleInput, + updating_user_id: i64, + ip_address: IpAddr, + ) -> Result { + let txn = ctx.transaction(); + + let role = Self::get(ctx, reference) + .await + .or_raise(|| Error::new("failed to update role data", ErrorType::Role))?; + + let mut model = role::ActiveModel { + role_id: Set(role.role_id), + ..Default::default() + }; + + let make_error = || { + Error::new( + format!( + "failed to update role ID {}, changed by user ID {}", + role.role_id, updating_user_id, + ), + ErrorType::Role, + ) + }; + + // Update fields + if let Maybe::Set(name_val) = &name { + model.name = Set(name_val.clone()); + } + + if let Maybe::Set(description_val) = &description { + model.description = Set(description_val.clone().into()); + } + + if let Maybe::Set(level_val) = level { + model.level = Set(level_val); + } + + // Update permissions + let current_permissions = PermissionService::get_permission_ids_for_role( + ctx, + role.role_id, + ).await.or_raise(make_error)?; + + // TODO: Make this more efficient + for permission_id in ¤t_permissions { + PermissionService::remove_permission_from_role( + ctx, + role.role_id, + *permission_id, + ) + .await + .or_raise(make_error)?; + } + + let new_permissions = if let Maybe::Set(permission_ids) = &permission_ids { + for permission_id in permission_ids { + PermissionService::add_permission_to_role( + ctx, + role.role_id, + *permission_id, + ) + .await + .or_raise(make_error)?; + } + permission_ids.clone() + } else { + Vec::new() + }; + + let updated_role = model.update(txn).await.or_raise(make_error)?; + + AuditService::log( + ctx, + ip_address, + AuditEvent::RoleUpdate { + role_id: role.role_id, + updating_user_id, + name: updated_role.name.clone(), + description: updated_role.description.clone(), + level: updated_role.level, + old_permissions: current_permissions, + new_permissions, + } + ) + .await + .or_raise(make_error)?; + + Ok(updated_role) + } + + pub async fn delete( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + deleting_user_id: i64, + ip_address: IpAddr, + ) -> Result<()> { + let txn = ctx.transaction(); + + let role = Self::get(ctx, reference) + .await + .or_raise(|| Error::new("failed to delete role", ErrorType::Role))?; + + let make_error = || { + Error::new( + format!( + "failed to delete role ID {} by user ID {}", + role.role_id, deleting_user_id, + ), + ErrorType::Role, + ) + }; + + role::ActiveModel { + role_id: Set(role.role_id), + deleted_at: Set(Some(now())), + ..Default::default() + }.update(txn).await.or_raise(make_error)?; + + AuditService::log( + &ctx, + ip_address, + AuditEvent::RoleDelete { + role_id: role.role_id, + deleting_user_id, + }, + ).await.or_raise(make_error)?; + + Ok(()) + } + + pub async fn get_optional( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || Error::new("failed to get role", ErrorType::Role); + + let role = match reference { + Reference::Id(id) => { + Role::find_by_id(id).one(txn).await.or_raise(make_error)? + } + _ => None + }; + + Ok(role) + } + + #[inline] + pub async fn get( + ctx: &ServiceContext<'_>, + reference: Reference<'_>, + ) -> Result { + find_or_error!(Self::get_optional(ctx, reference), "role", Role) + } + + pub async fn grant_role_to_user( + ctx: &ServiceContext<'_>, + GrantUserRoleInput { + user_id, + role_id, + assigning_user_id, + expires_at, + }: GrantUserRoleInput, + ip_address: IpAddr, + ) -> Result { + let txn = ctx.transaction(); + + let make_error = || { + Error::new( + format!( + "failed to grant role ID {} to user ID {}", + role_id, user_id, + ), + ErrorType::GrantUserRole, + ) + }; + + let user_role = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role_id), + assigned_at: Set(now()), + assigned_by: Set(assigning_user_id), + expires_at: Set(expires_at), + ..Default::default() + }.insert(txn).await.or_raise(make_error)?; + + AuditService::log( + &ctx, + ip_address, + AuditEvent::GrantUserRole { + user_id, + role_id, + assigning_user_id, + expires_at, + } + ).await.or_raise(make_error)?; + + Ok(user_role) + } + + pub async fn revoke_role_from_user( + ctx: &ServiceContext<'_>, + user_id: i64, + role_id: i64, + revoking_user_id: i64, + ip_address: IpAddr, + ) -> Result { + let txn = ctx.transaction(); + + let make_error = || Error::new( + format!( + "failed to revoke role ID {} from user ID {}", + role_id, user_id, + ), + ErrorType::RevokeUserRole, + ); + + let _user_role = user_role::Entity::find() + .filter( + user_role::Column::UserId.eq(user_id) + .and(user_role::Column::RoleId.eq(role_id)) + ) + .one(txn) + .await + .or_raise(make_error)?; + + let deleted_user_role = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role_id), + deleted_at: Set(Option::from(now())), + ..Default::default() + }.update(txn).await.or_raise(make_error)?; + + AuditService::log( + &ctx, + ip_address, + AuditEvent::RevokeUserRole { + user_id, + role_id, + revoking_user_id, + } + ).await.or_raise(make_error)?; + + Ok(deleted_user_role) + } +} \ No newline at end of file diff --git a/deepwell/src/services/role/structs.rs b/deepwell/src/services/role/structs.rs new file mode 100644 index 0000000000..8e6e34cd19 --- /dev/null +++ b/deepwell/src/services/role/structs.rs @@ -0,0 +1,52 @@ +/* + * services/role/structs.rs + * + * DEEPWELL - Wikijump API provider and database manager + * Copyright (C) 2019-2026 Wikijump Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use sea_orm::prelude::TimeDateTimeWithTimeZone; +use crate::types::Maybe; + +#[derive(Deserialize, Debug, Clone)] +pub struct CreateRoleInput { + pub site_id: i64, + pub name: String, + pub description: Option, + pub implicit: bool, + pub is_system: bool, + pub level: i32, +} + +#[derive(Serialize, Debug, Clone)] +pub struct CreateRoleOutput { + pub role_id: i64, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct UpdateRoleInput { + pub name: Maybe, + pub description: Maybe, + pub level: Maybe, + pub permission_ids: Maybe>, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GrantUserRoleInput { + pub user_id: i64, + pub role_id: i64, + pub assigning_user_id: i64, + pub expires_at: Option, +} \ No newline at end of file From 4e1f9ffba4b943955a35f68f1333330e73a44226 Mon Sep 17 00:00:00 2001 From: Flawed Date: Fri, 6 Mar 2026 01:53:55 -0800 Subject: [PATCH 3/7] Implement page edit permission check --- deepwell/seeder/users.json | 17 ++++ deepwell/src/api.rs | 1 + deepwell/src/endpoints/page.rs | 21 +++++ deepwell/src/services/page/service.rs | 13 +++ deepwell/src/services/page/structs.rs | 12 +++ deepwell/src/services/permission/service.rs | 94 +++++++++++++++++++ deepwell/src/services/role/mod.rs | 5 + deepwell/src/services/role/service.rs | 17 ++++ framerail/src/lib/server/deepwell/page.ts | 13 +++ .../src/routes/[slug]/[...extra]/+server.ts | 3 + .../src/routes/[slug]/[...extra]/page.svelte | 24 ++++- 11 files changed, 218 insertions(+), 2 deletions(-) diff --git a/deepwell/seeder/users.json b/deepwell/seeder/users.json index 42b8207f13..018e8a8905 100644 --- a/deepwell/seeder/users.json +++ b/deepwell/seeder/users.json @@ -104,5 +104,22 @@ "demo", "demo-user" ] + }, + { + "id": 6, + "type": "regular", + "name": "Guest", + "slug": "guest", + "email": "guest@wikijump", + "password": "guestuser1", + "locales": ["en"], + "real_name": "Guest", + "gender": null, + "birthday": null, + "location": "Everywhere and nowhere", + "biography": "Wikijump guest user", + "user_page": null, + "aliases": [ + ] } ] diff --git a/deepwell/src/api.rs b/deepwell/src/api.rs index 71943735e9..e8c7813ed3 100644 --- a/deepwell/src/api.rs +++ b/deepwell/src/api.rs @@ -305,6 +305,7 @@ async fn build_module(app_state: ServerState) -> Result> register!("page_get_score", page_get_score); register!("page_get_files", page_get_files); register!("page_edit", page_edit); + register!("page_edit_permission", page_edit_permission); register!("page_delete", page_delete); register!("page_move", page_move); register!("page_rollback", page_rollback); diff --git a/deepwell/src/endpoints/page.rs b/deepwell/src/endpoints/page.rs index 5213ac54b4..b95f2b367f 100644 --- a/deepwell/src/endpoints/page.rs +++ b/deepwell/src/endpoints/page.rs @@ -28,6 +28,7 @@ use crate::services::page::{ GetDeletedPageOutput, GetPageAnyDetails, GetPageOutput, GetPageReference, GetPageReferenceDetails, GetPageScoreOutput, GetPageSlug, MovePage, MovePageOutput, RestorePage, RestorePageOutput, RollbackPage, SetPageLayout, + PageEditPermission, PageEditPermissionOutput, }; use crate::services::page_revision::RerenderType; use crate::types::{Bytes, FileOrder, PageDetails, PageId, Reference, RerenderDepth}; @@ -207,6 +208,26 @@ pub async fn page_edit( .or_raise(|| Error::new("failed to edit page", ErrorType::Page)) } +pub async fn page_edit_permission( + ctx: &ServiceContext<'_>, + params: Params<'static>, +) -> Result { + let input: PageEditPermission = parse!(params, Page); + info!( + "Checking edit permission for page {:?} in site ID {}", + input.page, input.site_id, + ); + + let can_edit = PageService::check_user_permission( + &ctx, + input.site_id, + input.user_id, + "edit" + ).await.or_raise(|| Error::new("failed to check edit permission", ErrorType::Page))?; + + Ok(PageEditPermissionOutput { can_edit }) +} + pub async fn page_delete( ctx: &ServiceContext<'_>, params: Params<'static>, diff --git a/deepwell/src/services/page/service.rs b/deepwell/src/services/page/service.rs index 0f12158077..e4e5af70cd 100644 --- a/deepwell/src/services/page/service.rs +++ b/deepwell/src/services/page/service.rs @@ -40,6 +40,7 @@ use ftml::layout::Layout; use ref_map::*; use sea_orm::ActiveValue; use wikidot_normalize::normalize; +use crate::services::permission::PermissionService; #[derive(Debug)] pub struct PageService; @@ -1237,6 +1238,18 @@ impl PageService { Ok(()) } + + pub async fn check_user_permission( + ctx: &ServiceContext<'_>, + site_id: i64, + user_id: i64, + action: &str, + ) -> Result { + // TODO: Additional logic for per-category permissions here... + PermissionService::check_user_can(ctx, user_id, site_id, "page", action) + .await + .or_raise(|| Error::new("permission check failed", ErrorType::Page)) + } } /// Verifies that a `last_revision_id` passed into this function is actually the latest. diff --git a/deepwell/src/services/page/structs.rs b/deepwell/src/services/page/structs.rs index 703522a907..c18679f0a2 100644 --- a/deepwell/src/services/page/structs.rs +++ b/deepwell/src/services/page/structs.rs @@ -304,3 +304,15 @@ impl From<(CreatePageRevisionOutput, String)> for RestorePageOutput { } } } + +#[derive(Deserialize, Debug, Clone)] +pub struct PageEditPermission<'a> { + pub site_id: i64, + pub page: Reference<'a>, + pub user_id: i64, +} + +#[derive(Serialize, Debug, Clone)] +pub struct PageEditPermissionOutput { + pub can_edit: bool, +} diff --git a/deepwell/src/services/permission/service.rs b/deepwell/src/services/permission/service.rs index 41f669cede..eca33613eb 100644 --- a/deepwell/src/services/permission/service.rs +++ b/deepwell/src/services/permission/service.rs @@ -21,11 +21,14 @@ use super::prelude::*; use crate::models::permission::{self, Model as PermissionModel}; use crate::models::role::Model as RoleModel; use crate::models::role_permission::Model as RolePermissionModel; +use crate::models::{role, user_role}; +use crate::models::prelude::UserRole; use crate::error::{Error, ErrorType}; use crate::error::ErrorType::Permission; use crate::models::prelude::RolePermission; use crate::models::role_permission; use crate::services::audit::{AuditEvent, AuditService}; +use crate::services::role::RoleService; use crate::services::ServiceContext; #[derive(Debug)] @@ -143,6 +146,97 @@ impl PermissionService { Ok(permission_ids) } + pub async fn check_user_has_permission( + ctx: &ServiceContext<'_>, + user_id: i64, + site_id: i64, + permission_id: i64, + ) -> Result { + let txn = ctx.transaction(); + + let make_error = || Error::new( + format!("failed to check user ID {} for permission ID {}", user_id, permission_id), + ErrorType::Permission, + ); + + // Get all the roles the user has for this site + let mut role_ids: Vec = UserRole::find() + .join( + JoinType::InnerJoin, + user_role::Relation::Role.def(), + ) + .filter( + Condition::all() + .add(user_role::Column::UserId.eq(user_id)) + .add(role::Column::SiteId.eq(site_id)) + ) + .all(txn) + .await + .or_raise(make_error)? + .into_iter() + .map(|ur| ur.role_id) + .collect(); + + // If the user has no roles, apply implicit "guest" role + if role_ids.is_empty() { + let guest_role = RoleService::get_guest_role_for_site( + ctx, + site_id, + ).await.or_raise(make_error)?; + + role_ids.push(guest_role.role_id); + } + + // Check if any of those roles have the permission + let exists = RolePermission::find() + .filter(role_permission::Column::RoleId.is_in(role_ids)) + .filter(role_permission::Column::PermissionId.eq(permission_id)) + .one(txn) + .await + .or_raise(make_error)? + .is_some(); + + Ok(exists) + } + + pub async fn check_user_can( + ctx: &ServiceContext<'_>, + user_id: i64, + site_id: i64, + resource_type: &str, + action: &str, + ) -> Result { + let make_error = || Error::new( + format!( + "failed to check if user ID {} can {} {}", + user_id, action, resource_type + ), + ErrorType::Permission, + ); + + let maybe_permission = Self::get_permission_from_resource_and_action( + ctx, + resource_type, + action, + ) + .await + .or_raise(make_error)?; + + match maybe_permission { + Some(permission) => { + Self::check_user_has_permission( + ctx, + user_id, + site_id, + permission.permission_id, + ) + .await + .or_raise(make_error) + } + None => Ok(false), + } + } + pub async fn get_optional( ctx: &ServiceContext<'_>, reference: Reference<'_>, diff --git a/deepwell/src/services/role/mod.rs b/deepwell/src/services/role/mod.rs index decc5f8ae7..3855ea6aab 100644 --- a/deepwell/src/services/role/mod.rs +++ b/deepwell/src/services/role/mod.rs @@ -23,6 +23,11 @@ mod prelude { pub use super::structs::*; } +pub const ADMIN_ROLE_NAME: &str = "admin"; +pub const MODERATOR_ROLE_NAME: &str = "moderator"; +pub const MEMBER_ROLE_NAME: &str = "member"; +pub const GUEST_ROLE_NAME: &str = "guest"; + mod service; mod structs; diff --git a/deepwell/src/services/role/service.rs b/deepwell/src/services/role/service.rs index 3baaa24afc..83bed8c722 100644 --- a/deepwell/src/services/role/service.rs +++ b/deepwell/src/services/role/service.rs @@ -28,6 +28,7 @@ use crate::error::{Error, ErrorType}; use crate::models::user_role; use crate::services::audit::{AuditEvent, AuditService}; use crate::services::permission::PermissionService; +use crate::services::role::GUEST_ROLE_NAME; use crate::services::ServiceContext; #[derive(Debug)] @@ -345,4 +346,20 @@ impl RoleService { Ok(deleted_user_role) } + + pub async fn get_guest_role_for_site( + ctx: &ServiceContext<'_>, + site_id: i64, + ) -> Result { + let txn = ctx.transaction(); + + let make_error = || Error::new("failed to get guest role", ErrorType::Role); + + let role = Role::find() + .filter(role::Column::SiteId.eq(site_id).and(role::Column::Name.eq(GUEST_ROLE_NAME))) + .one(txn) + .await.or_raise(make_error)?; + + Ok(role.unwrap()) + } } \ No newline at end of file diff --git a/framerail/src/lib/server/deepwell/page.ts b/framerail/src/lib/server/deepwell/page.ts index 004b1a762f..06e3222a7b 100644 --- a/framerail/src/lib/server/deepwell/page.ts +++ b/framerail/src/lib/server/deepwell/page.ts @@ -55,6 +55,19 @@ export async function pageEdit( }) } +export async function pageEditPermission( + siteId: number, + pageId: Optional, + slug: string, + userId: number, +): Promise<{ can_edit: boolean }> { + return client.request("page_edit_permission", { + site_id: siteId, + page: pageId ?? slug, + user_id: userId + }) +} + export async function pageHistory( siteId: number, pageId: Optional, diff --git a/framerail/src/routes/[slug]/[...extra]/+server.ts b/framerail/src/routes/[slug]/[...extra]/+server.ts index 06cbc0c644..7aa3b02ef2 100644 --- a/framerail/src/routes/[slug]/[...extra]/+server.ts +++ b/framerail/src/routes/[slug]/[...extra]/+server.ts @@ -54,6 +54,9 @@ export async function POST(event) { tags, layout ) + } else if (extra.includes("check-edit-permission")) { + /** Check if user has permission to edit the page. */ + res = await page.pageEditPermission(siteId, pageId, slug, session?.user_id) } else if (extra.includes("history")) { /** Retrieve page revision list. */ const revisionNumberStr = data.get("revision-number")?.toString() diff --git a/framerail/src/routes/[slug]/[...extra]/page.svelte b/framerail/src/routes/[slug]/[...extra]/page.svelte index 0d2bcab18c..39071bbd76 100644 --- a/framerail/src/routes/[slug]/[...extra]/page.svelte +++ b/framerail/src/routes/[slug]/[...extra]/page.svelte @@ -1,7 +1,7 @@