Skip to content

Architecture

Kyle Morel edited this page Aug 14, 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

Database schema

yarsschema Audit columns removed from image for readability

Groups

Users can belong to one or many groups. A group is associated with a specific initiative. A group is defined by its associated roles, which further dictate the exact policies & permission sets a user may have. User are tracked by their SSO identity ID, and user to group associations are kept in the identity_group table.

Roles

Roles are defined by a unique name. Roles are a collection of zero to many policies.

Policy

A policy is a unique set of an Action and a Resource. This defines a specific permission that will be granted to a user in the application. Policies may have additional modifiers, called Attributes, which further define how a resource is acted upon.

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

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
  • update

Attribute

An attribute is an optional modifier for a policy. They can apply globally to all groups, or act specifically against a group. These are simply strings that the application will have to take into account accordingly. The primary use of an attribute is defining the scope of a resource a group can access (eg: only access resources created by current user vs access to all resources), but they could be used for just about anything.

Database

New tables

group
PK group_id: int
FK initiative_id: uuid
name : text
description : text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE group_id, initiative_id
role
PK role_id: int
name: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
policy
PK policy_id: int
FK resource_id: int
FK action_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
resource
PK resource_id: int
name: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
action
PK action_id: int
name: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
attribute
PK attribute_id: int
name: text
description: text
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE name
identity_group
FK identity_id: int
FK group_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE identity_id, group_id
group_role
FK group_id: int
FK role_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE group_id, role_id
role_policy
FK role_id: int
FK policy_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE role_id, policy_id
policy_attribute
FK policy_id: int
FK attribute_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE policy_id, attribute_id
attribute_group
FK attribute_id: int
FK group_id: int
created_by: text
created_at: timestamp
updated_by: text
updated_at: timestamp
UNIQUE attribute_id, group_id

Implementation

Backend

  1. Database

    1. Knex migration written which adds the above noted tables as designed
    2. Knex migration to seed initial groups, roles, policies, resources, actions, and attributes, and correctly link together
    3. The migration will include a database view to easily view all associates (excluding attributes)
    4. An initial seed containing developers roles which is run automatically on all PR deployments
  2. API Middleware

    1. Three new middlewares will be created: requireSomeGroup, hasAuthorization, and hasAccess.
    2. requireSomeGroup will take zero parameters. It's sole responsibility is to check if a user is currently assigned to any groups. If not, it will attempt to assign the default group.
    3. hasAuthorization will take two parameters, resource and action. The middleware has 2 primary responsibilities
      1. To ensure the requesting user has a policy set matching the requested parameters
      2. Inject the attributes associated to any matching policies into the request
    4. hasAccess will take one parameter, being a route parameter of some kind (eg: submissionId). The middleware has 1 responsibility
      1. To check if the attributes found in hasAuthorization contain resource access scoping. If so then this middleware will ensure that the requesting user created the resource in question.
  3. Controller layer

    1. Unfortunately there is no straight forward approach to handling attributes. Developers will have to be cognizant of which routes may or may not require attributes.
  4. Service layer

    1. Any service calls that may require resource scoping should be passed the scope from the controller.
    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.
  5. New endpoints

    1. /permissions
      • Authenticated endpoint
      • Returns a list of permissions for the authenticated user

Frontend

  1. authzStore
    1. A new store to be created. This will store the full list of user permissions obtained from the new /permissions endpoint, as well as front end navigation permission
    2. Provides a computed getter function to check if a user has the requested permission
      • can: computed(() => (initiative: Initiative, resource: Resource, action: Action, group?: GroupName) =>
  2. Log in
    1. Upon a user logging in, an api call should be made to the new /permissions endpoint to obtain the appropriate front end permission set
    2. This result will be stored in the new authzStore
  3. Checking permissions
    1. Using the authzStore, you can check if a user has the necessary permission via authzStore.can(initiative, resource, action, group?: GroupName)
  4. Updating permissions
    1. At this time we are not concerned about real time permission updates
    2. We could potentially query the /permissions endpoint on token refresh and store the new set in the authzStore. Any changes should then propagate 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 users groups for the developer role. If present the action will automatically be allowed.

Table of Contents

Clone this wiki locally