diff --git a/deepwell/migrations/20220906103252_deepwell.sql b/deepwell/migrations/20220906103252_deepwell.sql index cbc16f5785..89fb1e46df 100644 --- a/deepwell/migrations/20220906103252_deepwell.sql +++ b/deepwell/migrations/20220906103252_deepwell.sql @@ -934,6 +934,77 @@ 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, + + -- Denotes a unique role in a site. A user may be an admin in one site but a regular site member in another. + site_id BIGINT NOT NULL REFERENCES site(site_id), + name TEXT NOT NULL, + description TEXT NOT NULL, + from_wikidot BOOLEAN NOT NULL DEFAULT false, + + -- Virtual 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. + -- Virtual roles are visible to admins where they can select the role's permissions. + -- Virtual roles cannot be manually assigned. + is_virtual 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(), + updated_at TIMESTAMP WITH TIME ZONE, + 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), + -- Denormalized FK to avoid a join. + site_id BIGINT NOT NULL REFERENCES site(site_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/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..94da44286b --- /dev/null +++ b/deepwell/seeder/roles.json @@ -0,0 +1,117 @@ +[ + { + "name": "root", + "description": "Root user with full site permissions", + "is-virtual": false, + "is-system": true, + "level": 100, + "permissions": [ + "page:view", + "page:edit", + "page:create", + "page:delete", + "page:rename", + "role:view", + "role:assign", + "role:remove" + ] + }, + { + "name": "admin", + "description": "Site administrator with full control", + "is-virtual": false, + "is-system": false, + "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", + "is-virtual": false, + "is-system": false, + "level": 90, + "permissions": [ + "page:view", + "page:edit", + "page:create", + "page:delete", + "role:view" + ] + }, + { + "name": "member", + "description": "Regular site member", + "is-virtual": false, + "is-system": true, + "level": 10, + "permissions": [ + "page:view", + "page:edit", + "page:create" + ] + }, + { + "name": "guest", + "description": "Virtual role for all non-members, including registered and anonymous users.", + "is-virtual": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view" + ] + }, + { + "name": "registered", + "description": "Virtual role for registered and logged-in users.", + "is-virtual": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view" + ] + }, + { + "name": "anonymous", + "description": "Virtual role for unregistered or logged out users.", + "is-virtual": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view" + ] + }, + { + "name": "everyone", + "description": "Virtual role for all users, regardless of membership or registration status.", + "is-virtual": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view" + ] + }, + { + "name": "page-author", + "description": "Virtual role granted to the user when they visit a page that they authored, based on the page attribution metadata.", + "is-virtual": true, + "is-system": true, + "level": 1, + "permissions": [ + "page:view", + "page:edit", + "page:create", + "page:delete", + "page:rename" + ] + } +] + 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/database/seeder/data.rs b/deepwell/src/database/seeder/data.rs index 7c5c404c1e..1a49d3032e 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 is_virtual: 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 fdfe12c5f9..9cccbbd0ed 100644 --- a/deepwell/src/database/seeder/mod.rs +++ b/deepwell/src/database/seeder/mod.rs @@ -33,13 +33,15 @@ use crate::services::file::{ }; use crate::services::filter::{CreateFilter, FilterService}; use crate::services::page::{CreatePage, PageService}; +use crate::services::permission::{PermissionInput, PermissionService}; use crate::services::relation::{ PageAttributionEntry, PageAttributionKind, PageAttributionMetadata, RelationService, SetPageAttributions, }; +use crate::services::role::{CreateRoleInput, GrantUserRoleInput, RoleService}; use crate::services::site::{CreateSite, CreateSiteOutput, SiteService, UpdateSiteBody}; use crate::services::user::{CreateUser, CreateUserOutput, UpdateUserBody, UserService}; -use crate::types::{Maybe, Reference}; +use crate::types::{Maybe, PermissionKey, PermissionReference, Reference}; use crate::utils::now; use sea_orm::{ ConnectionTrait, DatabaseBackend, DatabaseTransaction, Statement, TransactionTrait, @@ -96,6 +98,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(); @@ -172,6 +176,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 { @@ -239,6 +259,74 @@ 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()), + is_virtual: role_template.is_virtual, + 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 permission = perm_spec + .parse::() + .map_err(|_| make_error())?; + + // Get permission entry + let permission = PermissionService::get( + &ctx, + PermissionReference::ResourceAction( + permission.resource, + permission.action, + ), + ) + .await + .or_raise(make_error)?; + + 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, + site_id, + 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/endpoints/page.rs b/deepwell/src/endpoints/page.rs index 5213ac54b4..10b8aff6e6 100644 --- a/deepwell/src/endpoints/page.rs +++ b/deepwell/src/endpoints/page.rs @@ -27,10 +27,13 @@ use crate::services::page::{ CreatePage, CreatePageOutput, DeletePage, DeletePageOutput, EditPage, EditPageOutput, GetDeletedPageOutput, GetPageAnyDetails, GetPageOutput, GetPageReference, GetPageReferenceDetails, GetPageScoreOutput, GetPageSlug, MovePage, MovePageOutput, - RestorePage, RestorePageOutput, RollbackPage, SetPageLayout, + PageEditPermission, PageEditPermissionOutput, RestorePage, RestorePageOutput, + RollbackPage, SetPageLayout, }; use crate::services::page_revision::RerenderType; -use crate::types::{Bytes, FileOrder, PageDetails, PageId, Reference, RerenderDepth}; +use crate::types::{ + Action, Bytes, FileOrder, PageDetails, PageId, Reference, RerenderDepth, +}; use futures::future::try_join_all; pub async fn page_create( @@ -207,6 +210,28 @@ 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, + Action::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/error/error_type.rs b/deepwell/src/error/error_type.rs index 1a4272e43b..c698c633c5 100644 --- a/deepwell/src/error/error_type.rs +++ b/deepwell/src/error/error_type.rs @@ -130,6 +130,19 @@ pub enum ErrorType { }, EmptyPassword, + // 3100 + Permission, + Role, + AddRolePermission, + #[allow(unused_variables)] + RemoveRolePermission, + GrantUserRole, + #[allow(unused_variables)] + RevokeUserRole, + PermissionNotFound, + #[allow(unused_variables)] + RoleNotFound, + // 4000 BadRequest, @@ -376,7 +389,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 @@ -598,6 +618,18 @@ impl ErrorType { } 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/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..68b2592f3b --- /dev/null +++ b/deepwell/src/models/role.rs @@ -0,0 +1,71 @@ +//! `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")] + pub description: String, + pub from_wikidot: bool, + pub is_virtual: bool, + pub is_system: bool, + pub level: i32, + #[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, +} + +#[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..c6cee93a6a 100644 --- a/deepwell/src/models/site.rs +++ b/deepwell/src/models/site.rs @@ -67,6 +67,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( @@ -77,6 +79,8 @@ pub enum Relation { on_delete = "NoAction" )] SiteDomainPreferredDomain, + #[sea_orm(has_many = "super::user_role::Entity")] + UserRole, } impl Related for Entity { @@ -157,12 +161,24 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::SiteDomain.def() } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRole.def() + } +} + impl Related for Entity { fn to() -> RelationDef { super::message_report::Relation::Message.def() diff --git a/deepwell/src/models/user_role.rs b/deepwell/src/models/user_role.rs new file mode 100644 index 0000000000..1a082944cb --- /dev/null +++ b/deepwell/src/models/user_role.rs @@ -0,0 +1,71 @@ +//! `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 site_id: i64, + #[serde(with = "time::serde::rfc3339")] + pub assigned_at: TimeDateTimeWithTimeZone, + pub assigned_by: i64, + #[serde(with = "time::serde::rfc3339::option")] + pub expires_at: Option, + #[serde(with = "time::serde::rfc3339::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::site::Entity", + from = "Column::SiteId", + to = "super::site::Column::SiteId", + on_update = "NoAction", + on_delete = "NoAction" + )] + Site, + #[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 Related for Entity { + fn to() -> RelationDef { + Relation::Site.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/deepwell/src/services/audit/structs.rs b/deepwell/src/services/audit/structs.rs index 342e371bc7..6db8f7faab 100644 --- a/deepwell/src/services/audit/structs.rs +++ b/deepwell/src/services/audit/structs.rs @@ -21,6 +21,7 @@ use super::prelude::*; use crate::license::License; use ftml::layout::Layout; +use sea_orm::prelude::TimeDateTimeWithTimeZone; use std::borrow::Cow; use std::net::IpAddr; use time::Date; @@ -102,6 +103,35 @@ pub enum AuditEvent<'a> { page_id: i64, layout: Option, }, + RoleCreate { + site_id: i64, + role_id: i64, + }, + #[allow(dead_code)] + RoleUpdate { + role_id: i64, + updating_user_id: i64, + level: i32, + old_permissions: Vec, + new_permissions: Vec, + }, + #[allow(dead_code)] + RoleDelete { + role_id: i64, + deleting_user_id: i64, + }, + GrantUserRole { + user_id: i64, + role_id: i64, + assigning_user_id: i64, + expires_at: Option, + }, + #[allow(dead_code)] + RevokeUserRole { + user_id: i64, + role_id: i64, + revoking_user_id: i64, + }, } impl<'a> AuditEvent<'a> { @@ -325,6 +355,91 @@ 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, + 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/page/service.rs b/deepwell/src/services/page/service.rs index 0f12158077..8c78d164a9 100644 --- a/deepwell/src/services/page/service.rs +++ b/deepwell/src/services/page/service.rs @@ -30,11 +30,12 @@ use crate::services::page_revision::{ CreatePageRevisionBody, CreatePageRevisionOutput, CreateResurrectionPageRevision, CreateTombstonePageRevision, }; +use crate::services::permission::PermissionService; use crate::services::{ CategoryService, FilterService, PageRevisionService, SiteService, TextBlockService, TextService, }; -use crate::types::{PageId, PageOrder}; +use crate::types::{Action, PageId, PageOrder, Resource}; use crate::utils::{get_category_name, trim_default}; use ftml::layout::Layout; use ref_map::*; @@ -1237,6 +1238,18 @@ impl PageService { Ok(()) } + + pub async fn check_user_permission( + ctx: &ServiceContext<'_>, + site_id: i64, + user_id: i64, + action: Action, + ) -> Result { + // TODO: Additional logic for per-category permissions here... + PermissionService::check_user_can(ctx, user_id, site_id, Resource::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/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..648ec8c555 --- /dev/null +++ b/deepwell/src/services/permission/service.rs @@ -0,0 +1,275 @@ +/* + * 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::error::{Error, ErrorType}; +use crate::models::permission::{self, Entity as Permission, Model as PermissionModel}; +use crate::models::prelude::UserRole; +use crate::models::prelude::{Role, RolePermission}; +use crate::models::role::Model as RoleModel; +use crate::models::role_permission; +use crate::models::role_permission::Model as RolePermissionModel; +use crate::models::{role, user_role}; +use crate::services::ServiceContext; +use crate::services::audit::{AuditEvent, AuditService}; +use crate::services::role::{GetUserRolesInput, RoleService}; +use crate::types::{Action, PermissionReference, Resource}; + +#[derive(Debug)] +pub struct PermissionService; + +#[allow(dead_code)] // TEMP +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 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 role_ids: Vec = RoleService::get_all_roles_for_user_and_site( + ctx, + GetUserRolesInput { + user_id: Some(user_id), + site_id, + page_reference: None, + }, + ) + .await + .or_raise(make_error)? + .into_iter() + .map(|ur| ur.role_id) + .collect(); + + // 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: Resource, + action: Action, + ) -> Result { + let make_error = || { + Error::new( + format!( + "failed to check if user ID {} can {} {}", + user_id, action, resource_type + ), + ErrorType::Permission, + ) + }; + + let permission: PermissionModel = Self::get( + ctx, + PermissionReference::ResourceAction(resource_type, action), + ) + .await?; + + let has_permission = Self::check_user_has_permission( + ctx, + user_id, + site_id, + permission.permission_id, + ) + .await + .or_raise(make_error)?; + + Ok(has_permission) + } + + pub async fn get_optional( + ctx: &ServiceContext<'_>, + reference: PermissionReference, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = + || Error::new("failed to fetch permission", ErrorType::Permission); + + let condition = match reference { + PermissionReference::Id(id) => permission::Column::PermissionId.eq(id), + PermissionReference::ResourceAction(resource, action) => { + permission::Column::ResourceType + .eq(resource.to_string()) + .and(permission::Column::Action.eq(action.to_string())) + } + }; + + let permission = Permission::find() + .filter(condition) + .one(txn) + .await + .or_raise(make_error)?; + + Ok(permission) + } + + #[inline] + pub async fn get( + ctx: &ServiceContext<'_>, + reference: PermissionReference, + ) -> Result { + find_or_error!(Self::get_optional(ctx, reference), "permission", Permission) + } +} diff --git a/deepwell/src/services/permission/structs.rs b/deepwell/src/services/permission/structs.rs new file mode 100644 index 0000000000..ebe36af4e7 --- /dev/null +++ b/deepwell/src/services/permission/structs.rs @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +use crate::types::{Action, Resource}; + +#[derive(Deserialize, Debug, Clone)] +pub struct PermissionInput { + pub description: String, + pub resource_type: String, + pub action: String, +} + +#[derive(Deserialize, Debug, Clone)] +#[allow(dead_code)] +pub struct PermissionOutput { + pub permission_id: i64, + pub description: String, + pub resource_type: String, + pub action: String, +} diff --git a/deepwell/src/services/role/mod.rs b/deepwell/src/services/role/mod.rs new file mode 100644 index 0000000000..853b5f16fa --- /dev/null +++ b/deepwell/src/services/role/mod.rs @@ -0,0 +1,45 @@ +/* + * 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 . + */ +use strum_macros::{Display, EnumString}; + +#[allow(unused_imports)] +mod prelude { + pub use super::super::prelude::*; + pub use super::structs::*; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, Display)] +#[strum(serialize_all = "kebab_case", ascii_case_insensitive)] +#[allow(dead_code)] +pub enum SystemRole { + Root, + Member, + Guest, + Registered, + Anonymous, + Everyone, + PageAuthor, +} + +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..cee79c64c2 --- /dev/null +++ b/deepwell/src/services/role/service.rs @@ -0,0 +1,545 @@ +/* + * 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 super::prelude::*; +use crate::endpoints::user; +use crate::error::{Error, ErrorType}; +use crate::models::permission::{Entity as Permission, Model as PermissionModel}; +use crate::models::prelude::Page; +use crate::models::role::{self, Entity as Role, Model as RoleModel}; +use crate::models::role_permission::{self, Entity as RolePermission}; +use crate::models::user_role::{Entity as UserRole, Model as UserRoleModel}; +use crate::models::{page, user_role}; +use crate::services::audit::{AuditEvent, AuditService}; +use crate::services::permission::PermissionService; +use crate::services::relation::{GetPageAttributions, GetSiteMember, SiteMemberAccepted}; +use crate::services::role::SystemRole; +use crate::services::{PageService, RelationService, ServiceContext}; +use crate::utils::{now, trim_default}; +use sea_orm::prelude::Expr; +use std::net::IpAddr; + +#[derive(Debug)] +pub struct RoleService; + +#[allow(dead_code)] // Temp +impl RoleService { + pub async fn create( + ctx: &ServiceContext<'_>, + CreateRoleInput { + site_id, + name, + description, + is_virtual, + 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().unwrap_or_default()), + is_virtual: Set(is_virtual), + is_system: Set(is_system), + level: Set(level), + created_at: Set(now), + updated_at: Set(Some(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<'_>, + site_id: i64, + 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, site_id, reference) + .await + .or_raise(|| Error::new("failed to update role data", ErrorType::Role))?; + + let mut model = role::ActiveModel { + role_id: Set(role.role_id), + updated_at: Set(Some(now())), + ..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()); + } + + 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 + + // Remove all existing permissions for this role + RolePermission::delete_many() + .filter(role_permission::Column::RoleId.eq(role.role_id)) + .exec(txn) + .await + .or_raise(make_error)?; + + // Add new permissions for this role + let new_permissions = match &permission_ids { + Maybe::Set(permission_ids) => { + let models: Vec = permission_ids + .iter() + .map(|&permission_id| role_permission::ActiveModel { + role_id: Set(role.role_id), + permission_id: Set(permission_id), + }) + .collect(); + + RolePermission::insert_many(models) + .exec(txn) + .await + .or_raise(make_error)?; + + permission_ids.clone() + } + _ => 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, + level: updated_role.level, + old_permissions: current_permissions, + new_permissions, + }, + ) + .await + .or_raise(make_error)?; + + Ok(updated_role) + } + + pub async fn delete( + ctx: &ServiceContext<'_>, + site_id: i64, + reference: Reference<'_>, + deleting_user_id: i64, + ip_address: IpAddr, + ) -> Result<()> { + let txn = ctx.transaction(); + + let role = Self::get(ctx, site_id, reference) + .await + .or_raise(|| Error::new("failed to delete role", ErrorType::Role))?; + + // Remove this role from all users who actively have it + UserRole::update_many() + .col_expr(user_role::Column::DeletedAt, Expr::value(Some(now()))) + .filter( + user_role::Column::RoleId + .eq(role.role_id) + .and(user_role::Column::DeletedAt.is_null()), + ) + .exec(txn) + .await + .or_raise(|| { + Error::new("failed to remove role from users", 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<'_>, + site_id: i64, + reference: Reference<'_>, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || Error::new("failed to get role", ErrorType::Role); + + let role = { + let condition = match reference { + Reference::Id(id) => role::Column::RoleId.eq(id), + Reference::Slug(slug) => { + // Get role by role name + role::Column::Name + .eq(slug) + .and(role::Column::DeletedAt.is_null()) + } + }; + + Role::find() + .filter( + Condition::all() + .add(condition) + .add(role::Column::SiteId.eq(site_id)), + ) + .one(txn) + .await + .or_raise(make_error)? + }; + + Ok(role) + } + + #[inline] + pub async fn get( + ctx: &ServiceContext<'_>, + site_id: i64, + reference: Reference<'_>, + ) -> Result { + find_or_error!(Self::get_optional(ctx, site_id, reference), "role", Role) + } + + pub async fn grant_role_to_user( + ctx: &ServiceContext<'_>, + site_id: i64, + 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 role = Self::get(ctx, site_id, role_id.into()).await?; + + let user_role = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role.role_id), + site_id: Set(role.site_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) + } + + pub async fn get_all_roles_for_user_and_site( + ctx: &ServiceContext<'_>, + GetUserRolesInput { + user_id, + site_id, + page_reference, + }: GetUserRolesInput, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || { + Error::new( + format!( + "failed to get roles for user ID {} in site ID {}", + user_id.unwrap_or(-1), + site_id + ), + ErrorType::Role, + ) + }; + + let mut roles = match user_id { + Some(id) => Role::find() + .join(JoinType::InnerJoin, role::Relation::UserRole.def()) + .filter( + user_role::Column::UserId + .eq(id) + .and(role::Column::SiteId.eq(site_id)) + .and(role::Column::DeletedAt.is_null()), + ) + .all(txn) + .await + .or_raise(make_error)?, + None => Vec::new(), + }; + + let virtual_roles = Self::get_virtual_roles_for_user( + ctx, + GetUserRolesInput { + user_id, + site_id, + page_reference, + }, + ) + .await + .or_raise(make_error)?; + + roles.extend(virtual_roles); + + Ok(roles) + } + + pub async fn get_virtual_roles_for_user( + ctx: &ServiceContext<'_>, + GetUserRolesInput { + user_id, + site_id, + page_reference, + }: GetUserRolesInput, + ) -> Result> { + let txn = ctx.transaction(); + + let make_error = || Error::new("failed to apply virtual roles", ErrorType::Role); + + let virtual_roles = Role::find() + .filter( + Condition::all() + .add(role::Column::SiteId.eq(site_id)) + .add(role::Column::IsVirtual.eq(true)), // Virtual roles are never deleted, so we don't need to check DeletedAt + ) + .all(txn) + .await + .or_raise(make_error)?; + + let virtual_role_name_map = virtual_roles + .into_iter() + .map(|role| (role.name.clone(), role)) + .collect::>(); + + // Compute user state flags + let is_logged_in = user_id.is_some(); + let is_member = if is_logged_in { + let membership = RelationService::get_optional_site_member( + ctx, + GetSiteMember { + site_id, + user_id: user_id.unwrap(), + }, + ) + .await + .or_raise(make_error)?; + + // TODO: Add invitation acceptance logic here + membership.is_some() + } else { + false + }; + let is_page_author = if is_member && let Some(page_ref) = page_reference { + let attributions = RelationService::get_page_attributions( + ctx, + GetPageAttributions { + site_id, + page: page_ref, + }, + ) + .await + .or_raise(make_error)?; + attributions + .iter() + .any(|attr| attr.user_id == user_id.unwrap()) + } else { + false + }; + + // Collect role names to add based on flags + let mut applied_virtual_roles = Vec::new(); + if is_logged_in { + applied_virtual_roles.push(SystemRole::Registered.to_string()); + } + if is_member { + applied_virtual_roles.push(SystemRole::Member.to_string()); + } + if is_page_author { + applied_virtual_roles.push(SystemRole::PageAuthor.to_string()); + } + if !is_logged_in { + applied_virtual_roles.push(SystemRole::Anonymous.to_string()); + applied_virtual_roles.push(SystemRole::Guest.to_string()); + } else if !is_member { + applied_virtual_roles.push(SystemRole::Guest.to_string()); + } + applied_virtual_roles.push(SystemRole::Everyone.to_string()); + + info!( + "Applying these virtual roles for user ID {:?} in site ID {}: {:?}", + user_id, site_id, applied_virtual_roles + ); + + // Build the final list of applicable roles + let applicable_virtual_roles = applied_virtual_roles + .into_iter() + .filter_map(|name| virtual_role_name_map.get(&name).cloned()) + .collect(); + + Ok(applicable_virtual_roles) + } +} diff --git a/deepwell/src/services/role/structs.rs b/deepwell/src/services/role/structs.rs new file mode 100644 index 0000000000..cba7f5c317 --- /dev/null +++ b/deepwell/src/services/role/structs.rs @@ -0,0 +1,60 @@ +/* + * 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 crate::types::{Maybe, Reference}; +use time::OffsetDateTime; + +#[derive(Deserialize, Debug, Clone)] +pub struct CreateRoleInput { + pub site_id: i64, + pub name: String, + pub description: Option, + pub is_virtual: bool, + pub is_system: bool, + pub level: i32, +} + +#[derive(Serialize, Debug, Clone)] +pub struct CreateRoleOutput { + pub role_id: i64, +} + +#[derive(Deserialize, Debug, Clone)] +#[allow(dead_code)] +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, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GetUserRolesInput { + pub site_id: i64, + pub user_id: Option, + pub page_reference: Option>, +} diff --git a/deepwell/src/types/mod.rs b/deepwell/src/types/mod.rs index e89beb57ad..e3219c2175 100644 --- a/deepwell/src/types/mod.rs +++ b/deepwell/src/types/mod.rs @@ -30,6 +30,7 @@ mod maybe; mod page_details; mod page_id; mod page_order; +mod permissions; mod reference; mod rerender_depth; @@ -38,10 +39,11 @@ pub use self::connection_type::ConnectionType; pub use self::conversion_error::{EnumConversionError, parse_layout}; pub use self::fetch_direction::FetchDirection; pub use self::file_details::FileDetails; -pub use self::file_order::{FileOrder, FileOrderColumn}; +pub use self::file_order::FileOrder; pub use self::maybe::Maybe; pub use self::page_details::PageDetails; pub use self::page_id::PageId; -pub use self::page_order::{PageOrder, PageOrderColumn}; +pub use self::page_order::PageOrder; +pub use self::permissions::{Action, PermissionKey, PermissionReference, Resource}; pub use self::reference::Reference; pub use self::rerender_depth::RerenderDepth; diff --git a/deepwell/src/types/permissions.rs b/deepwell/src/types/permissions.rs new file mode 100644 index 0000000000..483630d42e --- /dev/null +++ b/deepwell/src/types/permissions.rs @@ -0,0 +1,67 @@ +/* + * services/permission/permissions + * + * 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::str::FromStr; +use strum_macros::{Display, EnumString}; + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, Display)] +#[strum(serialize_all = "kebab_case", ascii_case_insensitive)] +pub enum Resource { + Page, + Role, +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, Display)] +#[strum(serialize_all = "kebab_case", ascii_case_insensitive)] +pub enum Action { + View, + Edit, + Create, + Delete, + Rename, + Assign, + Remove, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PermissionKey { + pub resource: Resource, + pub action: Action, +} + +impl FromStr for PermissionKey { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let (resource, action) = s.split_once(':').ok_or("invalid permission format")?; + + Ok(Self { + resource: Resource::from_str(resource) + .map_err(|_| "invalid resource type")?, + action: Action::from_str(action).map_err(|_| "invalid action type")?, + }) + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum PermissionReference { + Id(i64), + ResourceAction(Resource, Action), +} diff --git a/framerail/src/lib/server/deepwell/page.ts b/framerail/src/lib/server/deepwell/page.ts index 004b1a762f..e0f1822366 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..6a288453f5 100644 --- a/framerail/src/routes/[slug]/[...extra]/page.svelte +++ b/framerail/src/routes/[slug]/[...extra]/page.svelte @@ -1,7 +1,7 @@