Skip to content

Architecture

Kyle Morel edited this page Jul 12, 2024 · 20 revisions

This page outlines the general architecture and design principles of the Permit Connect Navigator Service (PCNS). It is mainly intended for a technical audience, and for people who want to have a better understanding of how the system works.

Table of Contents

Infrastructure

image

Figure 1 – The general infrastructure and network topology of PCNS

We receive data from an external service Common Hosted Forms Service (CHEFS) and from our PCNS client written in VueJs, managed by our PCNS NodeJs application. File upload/download is managed by the external service Common Object Management Service (COMS). The NodeJs application interfaces with our PCNS Postgres database.

Database structure

The PostgreSQL database is written and handled via managed, code-first migrations. We generally store tables containing activities, initiatives, enquiries, permits, submissions, users, and how they relate to each other.

PCNS is a has a mono-repository architecture containing both a frontend and backend. The following figures depict the database schema structure as of July 2024.

image

Figure 2 – The public schema for a PCNS database

The database tracks activities, initiatives, enquiries, permits, submissions, users, and a few other tables and how they relate to each other. We enforce foreign key integrity by invoking onUpdate and onDelete cascades in Postgres. This ensures that we do not have dangling references when entries are removed from the system.

image

Figure 3 – The audit schema for a PCNS database

We use a generic audit schema table to track any update and delete operations done on the database. This table is only modified by database via table triggers, and is not accessible by the PCNS application itself. This meets most general security, tracking and auditing requirements.

Code Design

The code structure in PCNS follows a simple, layered structure following best practice recommendations from Express, Node, ES6, and Typescript coding styles and utilize Eslint and Prettier to enforce those recommendations.

Organization – Backend

The backend is an ExpressJs application managing a PostgresDB. We utilize the KnexJs package for database migration management and configuration and PrismaJs for database object-relation management.

The codebase has the following discrete layers:

Layer Purpose
Controller Contains controller express logic for determining what services to invoke and in what order
DB Contains the direct database table model definitions and typical modification queries
Middleware Contains middleware functions for handling authentication, authorization and feature toggles
Routes Contains defined Express routes for defining the PCNS API shape and invokes controllers
Services Contains logic for interacting with the Database, COMS API, or other external APIs for specific tasks
Validators Contains logic which examines and enforces incoming request shapes and patterns

Each layer is designed to focus on one specific aspect of business logic. Calls between layers are designed to be deliberate, scoped, and contained. This makes it easier to tell what each piece of code is doing and what it depends on. For example, the validation layer sits between the routes and controllers. It ensures that incoming network calls are properly formatted before proceeding with execution.

Middleware

PCNS middleware focuses on ensuring that the appropriate business logic filters are applied as early as possible. Concerns such as feature toggles, authentication and authorization are handled here. Express executes middleware in the order of introduction. It will sequentially execute and then invoke the next callback as a part of its call stack. Because of this, we must ensure that the order we introduce and execute our middleware adhere to the following pattern:

  1. Validation and structural checks
  2. Permission and authorization checks
  3. Any remaining middleware hooks before invoking the controller

Organization – Frontend

The frontend utilizes the VueJs framework to build the user interface, using Typescript. We utilize several library packages with this framework that shape the structure of our frontend.

The following is a partial list of important packages used in the frontend:

Package Purpose
Axios Library for making HTTP requests
Pinia State management framework for VueJs
Primevue Vue component and template library
Vite Javascript bundler, hot-module replacement capabilities
Vitest Javascript unit testing framework
Vue-router Client-side routing library for VueJs
Vue-test-utils VueJs unit test utility library

Security and Role-based Access Controls

Note: Currently under proposal status

Database schema

pcns_rbac drawio

SSO Roles

Role are setup in the BCGov Common Hosted Single Sign-On. But for our purposes we will also be creating a matching entry in the database in order to properly link roles to their associated policies.

Current roles

  • PCNS_ADMIN
  • PCNS_DEVELOPER
  • PCNS_NAVIGATOR
  • PCNS_PROPONENT
  • PCNS_SUPERVISOR

Proposed roles

Using an {initiative}.{user_type} structure

  • housing.admin
  • housing.navigator
  • housing.supervisor
  • pcns.developer
  • pcns.proponent

From this we can differentiate between initiative specific roles, and application wide roles. There is no guarantee that a housing navigator will also be a navigator for a new initiative. But we can guarantee that a developer is an application wide developer, and a proponent is an application wide proponent.

Policies

Policies are a collection of permissions. Roles can be given one or more policies to determine the roles overall permissions.

Scopes

A scope is an optional limiter to a policy. This can be used to define a subset of the resource the assigned permissions are allowed to access. If a scope is not defined for a policy it defaults to all resource access.

Why are scopes needed? Multiple roles may require access to the same resource and action, for instance navigators and proponents will both require submission/read. But proponents should only ever be able to view submissions they themselves have submitted. Therefore a method of filtering result was required.

The following is a list of currently identified scopes required and their use:

  • self
    • filters results to resources created by current user id
    • ideally accomplished by modifying the database query instead of filtering the full data set

Permission

A permission is a combination of a resource and action. A permission is linked to one or more policies to determine the permission set the policy is allowed to do within the application.

Resources

The following is a list of currently known resources within the application. These are derived from the existing API routes and application concerns.

Routes & Services

  • document
  • enquiry
  • note
  • permit
  • roadmap
  • sso
  • submission
  • user

Application concerns

  • navigation
  • testing

Known actions

The following is a list of currently known actions within the application. These are derived from the existing API requests and application concerns.

  • create
  • delete
  • read
  • roleoverride
  • update

Database

New tables

role
PK roleId: int
initiative: text
user_type: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE initiative, user_type
policy
PK policyId: int
FK scopeId: int
name: text
description: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
scope
PK scopeId: int
name: text
description: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
permission
PK permissionId: int
FK resourceId: int
FK actionId: int
description: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE resourceId, actionId
resource
PK resourceId: int
name: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
action
PK actionId: int
name: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
role_policy
PK role_policy_id: int
FK roleId: int
FK policyId: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE roleId, policyId
policy_permission
PK policy_permission_id: int
FK policyId: int
FK permissionId: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE policyId, permissionId

Implementation

Backend

  1. Database

    1. Migration written which adds the above noted tables as designed
    2. Initial data seeded
  2. New types

    type Permission {
      initiative: string;
      userType: string;
      policyName: string;
      scopeName?: string;
      resourceName: string;
      actionName: string;
    }
    type Scope {
      name: string
      userId?: string
    }
  3. API Middleware

    1. Rename existing hasAccess API middleware to hasPermission and adjust functionality. New implementation will return an async function taking three parameters, initiative: string, resource: string, and action: string. These will come from enums. Possible string can be replaced with an appropriate enum type. Scope is not required here.
    2. User roles will be obtained from the decoded JWT which is injected into the currentUser object within the request.
    3. Roles filtered to where there is a matching initiative. Recall SSO roles are in the form of {initiative}.{user_type}.
    4. Split filtered result into separate initiative and user type arrays.
    5. A database query is executed to get a full relation of each roles policies and their permissions - this is untested pseudocode
    select role.initiative, role.user_type, policy.name, scope.name, resource.name, action.name
        from role
        join role_policy on role_policy.roleId = role.roleId
        join policy on policy.policyId = role_policy.policy_id
        join scope on policy.scopeId = scope.scopeId
        join policy_permission on policy_permission.policyId = policy.policyId
        join permission on permission.permissionId = policy_permission.policyId
        join resource on resource.resourceId = permission.resourceId
        join action on action.actionId = permission.resourceId
        where role.initiative = initiative and role.user_type = user_type and resource.name = resource and action.name = action

    If not found, return a 403 Forbidden error. If found then we know the user has the required permission, inject the scope.name into the request if it exists, and continue stack. While extremely unlikely, this is an additive system, so multiple permissions could be found. In this event inject the most permissive scope.

    1. hasPermission middleware will need to be added appropriately throughout all routing
  4. Controller layer

    1. Unfortunately there is no straight forward approach to handling scopes. Developers will have to be cognizant of which routes may or may not have scoped access. In the event a scope may be present, the controller should retrieve it from the request, and create an appropriate Scope object passed as a parameter to the service layer.
  5. Service layer

    1. Any functions that may have scope present should be modified to take an additional optional parameter, scope?: Scope
    2. This scope should then be used to determine the correct database query to be used on the resource.
    3. Appropriate response will be returned to the controller.
  6. New endpoints

    1. /permission
      • Authenticated endpoint
      • Returns a list of permissions for the authenticated user
      • Obtains the users SSO roles from the decoded JWT
      • Executes a database query returning all permissions found where matching initiative and user type

Frontend

  1. permissionStore
    1. A new store to be created. This will store the full list of user permissions
    2. Provides a computed getter function to check if a user has the requested permission
      • can: computed(() => (initiative: string, resource: string, action: string) => state.permissions.value.find( x => x.initiative === initiative && x.resource === resource && x.action === action) !== -1);
    3. Possible string can be replaced with an appropriate enum type
  2. Log in
    1. Upon a user logging in, an api call should be made to the new /permission endpoint to obtain the appropriate front end permission set
    2. This result will be stored in the new permissionStore
  3. Checking permissions
    1. Using the permissionStore, you can check if a user has the necessary permission via permissionStore.can(initiative, resource, action)
  4. Updating permissions
    1. At this time we are not concerned about real time permission updates
    2. We could potentially query the /permission endpoint on token refresh and store the new set in the permissionStore. Any changes should then propogate automatically via the computed function.

Dealing with the developer role

The developer role will not be included in the database permission table. Instead the front and back end authorization checks will check the JWT for the developer role. If present the action will automatically be allowed.

Initial permissions

initiative user_type resource action scope
housing admin document read

Future considerations

  • Could we implement policy inheritance? This would allow easily setting up different policies with the same permissions sets but with different scopings.
  • The middleware db call to gather all the permissions is kind of expensive. We have a small user base currently, it's fine. But if this grows, we will need to watch out. There is a potential way around this, although kinda complicated? We can spin up our own keycloak instance in a namespace. This would allow us much more control than the standard realm gives us. Then we can physically attach permissions and scopes together in keycloak, instead of the db, and it will all be reflected in the jwt. No more db call required.

Questions

Table of Contents

Clone this wiki locally