Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,5 +226,5 @@ DATABASE_URL=postgres://refactor:password@localhost:5432/refactor_platform sea-o
Note that to generate a new Entity using the CLI you must ignore all other tables using the `--ignore-tables` option. You must add the option for _each_ table you are ignoring.

```bash
DATABASE_URL=postgres://refactor:password@localhost:5432/refactor_platform sea-orm-cli generate entity -s refactor_platform -o entity/src -v --with-serde both --serde-skip-deserializing-primary-key --ignore-tables {table to ignore} --ignore-tables {other table to ignore}
DATABASE_URL=postgres://refactor:password@localhost:5432/refactor sea-orm-cli generate entity -s refactor_platform -o entity/src -v --with-serde both --serde-skip-deserializing-primary-key --ignore-tables {table to ignore} --ignore-tables {other table to ignore}
```
19 changes: 19 additions & 0 deletions docs/db/refactor_platform_rs.dbml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ Table refactor_platform.users {
updated_at timestamptz [not null, default: `now()`, note: 'The last date and time fields were changed']
}

Table refactor_platform.user_roles {
id uuid [primary key, unique, not null, default: `gen_random_uuid()`]
role refactor_platform.role [not null]
organization_id uuid [note: 'The organization joined to the user']
user_id uuid [not null, note: 'The user joined to the organization']
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`, note: 'The last date and time fields were changed']
}

Table refactor_platform.organizations_users {
id uuid [primary key, unique, not null, default: `gen_random_uuid()`]
organization_id uuid [not null, note: 'The organization joined to the user']
Expand Down Expand Up @@ -121,6 +130,12 @@ enum refactor_platform.status {
wont_do
}

enum refactor_platform.role {
user
admin
super_admin
}

// coaching_relationships relationships
Ref: refactor_platform.coaching_relationships.organization_id > refactor_platform.organizations.id
Ref: refactor_platform.coaching_relationships.coachee_id > refactor_platform.users.id
Expand All @@ -146,3 +161,7 @@ Ref: refactor_platform.actions.coaching_session_id > refactor_platform.coaching_
// organizations_users relationships
Ref: refactor_platform.organizations_users.organization_id > refactor_platform.organizations.id
Ref: refactor_platform.organizations_users.user_id > refactor_platform.users.id

//user_roles relationships
Ref: refactor_platform.user_roles.organization_id > refactor_platform.organizations.id
Ref: refactor_platform.user_roles.user_id > refactor_platform.users.id
Binary file modified docs/db/refactor_platform_rs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions entity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod organizations_users;
pub mod overarching_goals;
pub mod roles;
pub mod status;
pub mod user_roles;
pub mod users;

/// A type alias that represents any Entity's internal id field data type.
Expand Down
3 changes: 3 additions & 0 deletions entity/src/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ pub enum Role {
User,
#[sea_orm(string_value = "admin")]
Admin,
#[sea_orm(string_value = "super_admin")]
SuperAdmin,
}

impl std::fmt::Display for Role {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(fmt, "user"),
Role::Admin => write!(fmt, "admin"),
Role::SuperAdmin => write!(fmt, "super_admin"),
}
}
}
52 changes: 52 additions & 0 deletions entity/src/user_roles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.13
use super::roles::Role;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(schema_name = "refactor_platform", table_name = "user_roles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
#[serde(skip_deserializing)]
pub id: Uuid,
pub role: Role,
pub organization_id: Option<Uuid>,
pub user_id: Uuid,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
Comment on lines +9 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing Validation:

  • No application-level constraints ensuring SuperAdmin roles have organization_id = NULL
  • No validation that organization-scoped roles have non-NULL organization_id

Suggestion:

  • Add validation logic in the model or service layer:
impl Model {
  pub fn validate(&self) -> Result<(), ValidationError> {
      match self.role {
          Role::SuperAdmin if self.organization_id.is_some() => {
              Err(ValidationError::new("SuperAdmin cannot be scoped to organization"))
          }
          Role::User | Role::Admin if self.organization_id.is_none() => {
              Err(ValidationError::new("User/Admin roles must be scoped to organization"))
          }
          _ => Ok(())
      }
  }
}

More Detailed Explanation:
-- ❌ BAD: SuperAdmin incorrectly scoped to an organization

INSERT INTO user_roles (user_id, organization_id, role) VALUES
  ('user-123', 'some-org-uuid', 'super_admin');

-- ❌ BAD: Admin role with no organization (global admin?)

INSERT INTO user_roles (user_id, organization_id, role) VALUES
  ('user-456', NULL, 'admin');

The database schema allows these invalid combinations because:

  • organization_id is Option<Uuid> (nullable)
  • There's no constraint enforcing the business rule

What the Validation Does

impl Model {
    pub fn validate(&self) -> Result<(), ValidationError> {
        match self.role {
            // Enforce: SuperAdmin MUST be global (organization_id = NULL)
            Role::SuperAdmin if self.organization_id.is_some() => {
                Err(ValidationError::new("SuperAdmin cannot be scoped to organization"))
            }
            // Enforce: User/Admin MUST be org-scoped (organization_id != NULL)
            Role::User | Role::Admin if self.organization_id.is_none() => {
                Err(ValidationError::new("User/Admin roles must be scoped to organization"))
            }
            _ => Ok(())
        }
    }
}

Perhaps adding some unit tests around this also makes sense?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I plan to add validation in the next iteration when I add access to the data via web API

Copy link
Member

@jhodapp jhodapp Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll leave this here unresolved as a placeholder guide then.


#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::organizations::Entity",
from = "Column::OrganizationId",
to = "super::organizations::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Organizations,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}

impl Related<super::organizations::Entity> for Entity {
fn to() -> RelationDef {
Relation::Organizations.def()
}
}

impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

impl ActiveModelBehavior for ActiveModel {}
2 changes: 1 addition & 1 deletion migration/src/base_refactor_platform_rs.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-- SQL dump generated using DBML (dbml.dbdiagram.io)
-- Database: PostgreSQL
-- Generated at: 2025-07-11T12:54:19.417Z
-- Generated at: 2025-09-18T01:56:35.997Z


CREATE TYPE "refactor_platform"."status" AS ENUM (
Expand Down
2 changes: 2 additions & 0 deletions migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod m20250611_115337_promote_admin_user_to_admin_role;
mod m20250705_200000_add_timezone_to_users;
mod m20250730_000000_add_coaching_sessions_sorting_indexes;
mod m20250801_000000_add_sorting_indexes;
mod m20250916_060419_add_user_roles_table_and_super_admin;

pub struct Migrator;

Expand All @@ -23,6 +24,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250705_200000_add_timezone_to_users::Migration),
Box::new(m20250730_000000_add_coaching_sessions_sorting_indexes::Migration),
Box::new(m20250801_000000_add_sorting_indexes::Migration),
Box::new(m20250916_060419_add_user_roles_table_and_super_admin::Migration),
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 1. Add super_admin variant to the existing role enum
// Note: We use execute_unprepared() instead of SeaORM's schema builder because:
// - PostgreSQL's ALTER TYPE ... ADD VALUE has special transaction restrictions
// - It cannot be executed in a transaction block that has other commands
// - execute_unprepared() gives us direct control over the SQL execution
// - The IF NOT EXISTS clause makes it safe for re-runs
manager
.get_connection()
.execute_unprepared(
"ALTER TYPE refactor_platform.role ADD VALUE IF NOT EXISTS 'super_admin'",
)
.await?;

// 2. Create the user_roles table
// We continue using execute_unprepared() for consistency and to ensure
// proper PostgreSQL schema qualification (refactor_platform.user_roles)
let create_table_sql = "CREATE TABLE IF NOT EXISTS refactor_platform.user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role refactor_platform.role NOT NULL,
organization_id UUID,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT fk_user_roles_organization
FOREIGN KEY (organization_id)
REFERENCES refactor_platform.organizations(id)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT fk_user_roles_user
FOREIGN KEY (user_id)
REFERENCES refactor_platform.users(id)
ON DELETE CASCADE
ON UPDATE CASCADE
)";

manager
.get_connection()
.execute_unprepared(create_table_sql)
.await?;

// 3. Create partial unique indexes to prevent duplicate role assignments
// This handles NULL organization_id properly (for super_admin roles)

// Partial index for organization-scoped roles (where organization_id is NOT NULL)
let create_org_index_sql =
"CREATE UNIQUE INDEX IF NOT EXISTS user_roles_user_org_role_unique
ON refactor_platform.user_roles(user_id, organization_id, role)
WHERE organization_id IS NOT NULL";

manager
.get_connection()
.execute_unprepared(create_org_index_sql)
.await?;

// Partial index for global roles (prevents duplicate global roles for same user)
// This includes super_admin and any other future global roles
let create_global_role_index_sql =
"CREATE UNIQUE INDEX IF NOT EXISTS user_roles_user_global_role_unique
ON refactor_platform.user_roles(user_id, role)
WHERE organization_id IS NULL";

manager
.get_connection()
.execute_unprepared(create_global_role_index_sql)
.await?;

Ok(())
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Drop the user_roles table (this will also drop the indexes and foreign keys)
manager
.get_connection()
.execute_unprepared("DROP TABLE IF EXISTS refactor_platform.user_roles")
.await?;

// Note: We cannot remove the 'super_admin' value from the enum in PostgreSQL
// once it has been added. This is a PostgreSQL limitation.
// If you need to truly remove it, you would need to:
// 1. Create a new enum type without 'super_admin'
// 2. Update all columns using the old type to use the new type
// 3. Drop the old enum type
// This is complex and risky, so we'll leave the enum value in place.

Ok(())
}
}
15 changes: 15 additions & 0 deletions web/src/protect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ impl Check for UserIsAdmin {
authenticated_user.role == domain::users::Role::Admin
}
}
// Not used yet
#[allow(dead_code)]
pub struct UserIsSuperAdmin;

#[async_trait]
impl Check for UserIsSuperAdmin {
async fn eval(
&self,
_app_state: &AppState,
authenticated_user: &domain::users::Model,
_args: Vec<Id>,
) -> bool {
authenticated_user.role == domain::users::Role::SuperAdmin
}
}

pub struct UserCanAccessCoachingSession;

Expand Down