Project Breakdown: Ark (Repost)

I originally posted this on substack, but I decided to switch to this platform.

What is Ark?

notpointless/ark is a full-stack monolithic application that heavily emphasizes its (IAM) solution. Initially, it was supposed to be closed source because it was going to be used for a side project, but I decided to release it to the public for personal reasons.

Technical Breakdown

My thought process and the reasoning behind my decisions during the construction of this project. I will also detail the pros and cons of each aspect of the project, what I could have done better, and alternative approaches.

Task System

The idea behind this is that instead of re-instating the desired database instance to create or update a specific field within a database, we can queue the desired task without calling the database every single time through the function. This ends up streamlining the function.

so instead of this:

pub fn create_permission(database: PostgresDatabase, permission: Permission) -> Result<T> {/* perform operation here */}

we have this:

pub fn create_permission(permission: Permission) -> TaskResult<T> {/*queue message here*/}

So, instead of instantiating it through a CRUD function, the Task System provides the instantiation when it receives the task.

Due to the way the Task System was built, you need to specify what type of task you would like to use when composing a message so it knows what handler to use.

In order to successfully compose and send a task to the Task System to handle; A few things need to be done, you need three things: TaskType, TaskHandler<D>, and Task<D, R, P>.

  • TaskType, will help identify what handler to use.

  • TaskHandler<D> will assign a specific “action” to Task<D, R, P>

    • D: Database

  • Task<D, R, P> will perform the actual task.

    • D: Database

    • R: TaskRequest

    • P: The Type

After all those things are satisified you need to ensure the TaskType is registered with the corresponding listener. You can look at how to register a listener here.

Now you can create a function that sends a request to the task channel.

pub fn create_role(role: Role) -> TaskResult<TaskStatus> {
      let task_request = Self::create_role_request(role);
      TaskManager::process_task(task_request)
}
fn create_role_request(role: Role) -> TaskRequest {
      TaskRequest::compose_request(RoleCreateTask::from(role), TaskType::Role, "role_create")
}

TaskRequest will compose a request that the Task System can interpret then we can call TaskManager::process_task(task_request); this will return a Completed or Failed status. If you’re expecting a custom type to be returned then you can instead call TaskManager::process_task_with_result::<T>(request); instead of process_task.

Channeling

When the Task System recieves a request it first gets sent to the INBOUND channel then it sends its results to the OUTBOUND channel. This creates a bidirectional channels and this is how we can retrieve the results.

Advantages/Disadvantages

The amount of complexity that gets added is astonishing and it’s not a good thing. Also the inability to actually sends tasks within another task creates another issue on its own.

Advantages

  • Function Simplicity

  • Granular Control

Disadvantages

  • Complexity

  • No Nested Tasks

Alternative Approaches (Possible)

  • Use a messaging broker instead of using channels.

  • Use Redis pub/sub instead of channels.

  • Add the tasks to a hashmap first, then have the task system pull directly from the hashmap. The only con is I will need multiple hashmaps for each type. And additional complexity. Not entirely sure if this would solve being unable to call nested tasks.

  • Using serialization to prevent the overuse of generic types.

Cache System

The same as the Task System, but instead of using PostgreSQL, Redis is used. I separated them because I can’t call a task within a task, meaning I cannot call a channel within a channel unless it comes from a different instance. So, instead of calling a task within a task, it is being called from a cache within a task because cache uses a separate instance of channel than the task.

IAM (Identity And Access Management)

The IAM I built for this project is relatively rudimentary. It doesn’t contain effects, resources, or policies. It’s based on the user’s role and permission. So, I guess you could still call it an IAM?

Permission

I integrated permissions entirely because I was looking for something flexible. The permissions are stored inside PostgreSQL but are cached quite differently. You are not supposed to use the Cache System to cache these because they’re not stored inside Redis but locally inside a HashMap. The reason I did was for quantity. You might only expect a couple hundred permissions, but you will expect a couple hundred thousand users.

Caching:

Like I mentioned above the caching mechanism is different. But it is pretty straight forward. If someone wants to use a local cache then can they skip over the Cache System and use LocalizedCache<T> T signifies what type you want to cache. For example: Permission. So you would use impl LocalizedCache<Permission> for PermissionCache then implement the required types.

Whenever you call fn add(item: T), it will add three keys: permission_id, permission_name, and permission_key. I chose to add three other keys because all these fields are unique inside the database; therefore, I want permissions to be retrievable by their ID, name, or key.

Also, the values for all the keys all use an Arc<T> T being the same T for LocalizedCache<T>; therefore, I could implement an fn update() and update a single field, and it will automatically update the other two because they’re a shared state. I just wanted to say this because I’m well aware.

Schema:

  • permission_id: the uuid of the permission (randomly generated using the uuid crate)

  • permission_name: the name of the permission. Example: “Ban User”

  • permission_key: the key of the permission. Example: “admin.ban.user“

The permission_name and permission_key can be in any format. This is just the ideal example of what it should look like.

Relation

The roles and permissions are connected, and to ensure consistency among them, the roles type takes a field called pub role_permissions: Vec<String>. This contains only the id of the permission entirely because that is the only immutable field among a permission.

Role

I think of roles as just a bunch of permissions grouped into one place, so I added that. I wanted predefined permissions for specific roles. So I wouldn’t have to assign, for example, the ban permission for every user; instead, I could assign them the role and adjust the permissions accordingly. It ensures consistently among roles.

Schema:

  • role_id: the uuid of the permission (randomly generated using the uuid crate)

  • role_name: the name of the permission. Example: “Moderator”

The role_name and role_id can be in any format. This is just the ideal example of what it should look like.

User

If you look through the schema.sql and the iam_users table, you will notice that they do not contain passwords because users can only log in through an OAuth2 provider such as Google and Discord.

Resetting Passwords, Emails etc;

If you look at the iam_users table again, you will notice a security_token and security_stamp field. ASP.NET CORE heavily inspired this. I wasn’t entirely sure how they did it, but I know whenever the security_stamp gets changed, the security_token gets invalidated. Right now I’m still trying to figure out how I would go about verifying it, but I have an idea, but before I tell you that I should break down how it exactly works at this current moment.

security_stamp

The model for the security_stamp doesn’t have a model but instead just takes String type.

In order to generate a valid security_stamp the function fn generate_security_stamp(security_stamp: String, action: &str);

This function simply gets the current time in milliseconds then hashes it with Sha256 then turns it into a hexadecimal string.

security_token

The model for the security_token is

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
// security token for the user...
pub struct SecurityToken {
    pub token: String,
    pub expiry: u128,
    pub action: String,
}
  1. First we call SecurityToken::new(security_stamp: String, action: &str);

  2. Then we serialize with the model with serde_json then we convert the serialization into a hexadecimal.

  3. After we have the hexadecimal of the given model then we can store that into the database.

In order to grab the value we need to call decode_then_deserialize(security_token: Option<String>

This function decodes the hexadecimal string, then deserializes it and casts the generic type SecurityToken. If the token is empty, it returns None.

Shorthand

The function you would use to generate the actual token with the specified action is

UserSecurity::create(action); so if you would like to create an email reset you would do UserSecurity::create(“email_reset”); then in your route /auth/email_reset you would check if the SecurityToken exists and check if action is set to “email_reset” if not, deny access.

This is simple technical breakdown of the current status of my project.