Tags
January 19, 2024
by
Alexander VerbruggenRead more about this author

Nabu CMS - Security

The Nabu Content Management System (CMS) is a core part of most web applications that we build. It is not required but it does do a lot of heavy lifting if you have are building a typical application web application.

Node

At the core of the CMS data model is the Node. When you are building a new application using the CMS, all relevant business entities should be modeled as an extension of Node.

This means if we want to model a business entity called Company without CMS, we could just create a new table:

raw company

If we want to use the CMS to its fullest potential however, this business object should probably be an extension of node:

extended company

By extending Node, you inherit a lot of different fields which enable a number of CMS features.

Tree

The most important field in the Node table is the parentId. Nodes are connected to one another in a parent-child relationship, forming a filesystem-like tree.

This hierarchical layout is very important when it comes to security because it is in that tree that we configure who can do what.

Suppose we add a concept Contract that also extends Node, we could set the parentId of a contract to that of the company it relates to.

Our tree might look like this:

- project root
    - company A
        - contract1
        - contract2
    - company B
        - contract3

Suppose we want to give an internal employee access to edit all contracts for all companies. We could assign him this particular permission at the project root level.

The fact that he has that permission is inherited throughout the children of project root (which is the entire project) so no matter which contract we want to check, our employee will always have the permission to edit it.

Suppose we want to give the employee of company A permission to edit all contracts belonging to company A but more importantly not those of company B. If we give him this permission on the company A node, it will be inherited by all its child nodes so he can edit all contracts belonging to that company.
Because company B has no parent-child relationship to company A, he can not edit the contracts of company B.

You could even give a user permission to edit one particular contract by assigning the permission at that level.

Connections

A flat parent-child hierarchy has one particular downside: each node can only have one parent. For a lot of usecases this is plenty, but there are occasions that you want to create a different view on the data.

Suppose we have so many geographically distributed companies that we want to internally form groups that handle different sets of companies depending on geography.

Our node tree did not use any geographical distinction to create the parent child relations though. There is no point in the tree that we can assign our permissions on that would automatically be inherited by all the correct companies based on their location.

This is where node connections can help us. This allows you to link two otherwise unrelated nodes for security purposes.

We could create a new node to represent a geographical region, say "Belgium". We can then create a connection between that node and all companies that reside in Belgium.
We can then create a group of people that contains all our employees that manage Belgian contracts and give them the correct role on that new node. It will be automatically inherited to all connections.

Additional features

By virtue of extending Node, your business object will automatically gain some functionalities of frameworks that build upon the Node concept. Some examples:

  • tagging: nodes can be tagged with whatever additional metadata you want to add for additional taxonomy
  • key/value: you can easily add key value pairs to nodes, this can be both structured and unstructured
  • attachments: a separate attachment plugin makes it easy to add things like images and files to a node
  • external ids: business objects often have representations in other systems as well, in the external ids table we retain the identities our node might have in different systems. This includes global identifiers like VAT numbers.

To node or not to node

At design time you need to decide which of your business objects should be a node and which shouldn't be a node.

Security granularity is definitely an important consideration: do you ever need to give people distinct permissions on a particular instance of your business object or not?

Other things to consider:

  • do we need one or more of the frameworks operating on node?
  • is it a (relatively speaking) low volume of entities?

By default something should not be a node unless one of the above considerations applies.

Related

Business entities that are not "upgraded" to a node are still likely linked to a node. We call these "related" entities because there is generally a path (direct or indirect) to link them to a node.

Related entities can have their security checked on the node that they relate to which means they are still contextually protected.

Sometimes you will have entities that can not be traced back to your node tree, we call these "unrelated" entities. They generally have either global security or custom security.

User

A user is a person (or system!) interacting with our application. This is usually done through API's. There is a User table that extends Node which means every user is also part of the tree and can have its own child nodes if necessary.

There is an additional extension Account that extends User and adds a standardized view of metadata typically associated with a human user.

account model

A user is historically identified by the combination of alias and realm though authenticationId (the primary key of the user) is also widely used in Nabu.

The alias is an identifier that has to be unique within the given realm, this can be an email address, a phone number or something else. The alias type determines what type of alias it is, defaulting to an email address.

In classic login situations the user can enter the alias in combination with the password to identify himself. Other methods of authentication work on different tables but all link back to the user once he has been correctly identified.

Realms

A realm is simply a core grouping of users. You might for instance create a realm that represents your internal employees and a realm that represents client users.

Each web application that is hosted on the Nabu server has a primary realm. Unless specified differently only users that exist within this realm can authenticate in the application.

However, realms are also represented as nodes and the users have that realm node as the parent. Those realm nodes can use node connections to inherit users from another.

For example:

internal realm:
- john@example.com
- jane@example.com

client realm:
- bob@example.com
- alice@example.com

If our application realm is not connected to anything, no one can log in. If the application realm is connected to the internal realm, only john and jane can log in. If you connect both realms, the clients can log in as well.

This allows you to quickly assign entire groups of people to applications.

Impersonation

Impersonation is where one user explicitly takes on the role of another user. This is of course a particularly dangerous feature but it can be really helpful when trying to help people who are having problems with your application.

This feature is of course strictly regulated and it is recognized by the system. The token that is generated for impersonation will work exactly like a token obtained from a valid authentication, but it is also marked as being an impersonation token.

The impersonator is logged onto the token and that information is retained when asynchronous tasks are started, logs are created,...

The root

We talked about the node tree but glossed over a particular detail: any tree like structure must have a root at some point.

In a typical setup we will usually have multiple root nodes where a root node is defined as a node that does not have a parent id. Some root nodes are managed by the system, for instance each realm will automatically be created as a root node.

However, when building a business application that uses the CMS, you will need a place to create your nodes. Each business application has its own root node within which it is free to choose its own layout and apply its own security rules.

Another system managed root node that is always available is $global. This is where everything is added that does not belong to a particular business context.

The system will also enforce a node connection from $global to all other root nodes which allows you to define security rules at that level that apply everywhere.

Authentication

Now that we know know how users are represented by the CMS, let's have a look at how we establish an identity. As always, there are endless ways to implement this in Nabu if you have some custom requirements, but I will highlight how the default system works for the CMS.

At the core is a login REST service that expects:

  • authenticationId: an identifier that points to something where you want to complete the authentication from, depending on the provider this can be many things
  • secret: a secret that proves that you are allowed to access the authentication id
  • type: the type of authentication being used

The simplest example is of course the venerable password login:

  • authenticationId: this is the user alias, typically an email address but it could also be a phone number or even just a nickname
  • secret: your password
  • type: simply "password"

The system will look for an implementation that can resolve the "password" authentication and check if you are a valid user, if you are, it returns an internal token representing you as a user. This token is a java object based on the principal specification. It is what is used througout Nabu as your identity.

If this authentication is successful, and there is nothing else configured, the login service will generate a new temporary authentication which represents you as the user. This temporary authentication is passed to the user as an opaque token which can be used to reconstruct the java object as needed.

Openid

Another example of authentication is openid, the system will generate a redirect link that contains a state that represents a record the openid provider added to the database.

Upon completing the user flow of the openid login process, the state is reflected back to the system and combined with the code that was obtained through the oauth2 ping-pong. The frontend will then call the backend using:

  • authenticationId: the state id
  • secret: the code that was obtained
  • type: "oauth2"

Nabu will once again look for a provider that understands the given type, this provider will likely be the openid provider which will finalize the oauth2 flow and return a valid token if successful.

Challenge

If the login in successful, Nabu will check if there are additional challenges to be completed. This is typically the case in a multifactor setup. Multiple challenges can be added to a user, they will be run in sequence.

Some challenges do not need an initial action, for instance TOTP as is used by google authenticator and the likes, is always ready to go. Other multifactor (e.g. sms, email...) require a challenge to be sent.

The login method will run a challenge setup service if necessary, then return a token to the user that can only be used to complete the challenge. It will also tell the user which type of challenge it is.

The user then completes the challenge like this for instance:

  • authenticationId: the challenge token you received from the login
  • secret: the actual challenge you received out of band, for example the TOTP number that your authenticator app has generated
  • type: the challenge type you were given, e.g. "totp"

The login service will look up the provider that understands this given type and validate. It will then check for additional challenges if necessary.

If no additional challenges remain, an opaque token is returned that can be used to access the application.

Remember

The token that is returned by the login at the end has a few default properties:

  • timeout after 30 minutes of inactivity
  • 24 hours maximum lifetime
  • linked to the device that requested it

If that was the end of the authentication process, users would often be confronted with the need to re-authenticate as their token had expired.

Instead, the login adds a boolean where a user can ask to be remembered. This will place a one-time password (OTP) in a HTTPOnly Secure cookie on the browser.

The frontend can then call the remember service which takes no explicit input but does check the cookies to see if there is a valid OTP available.

Page Builder will automatically detect when a service returns a 401 (indicating a user is not logged in) and attempt a remember. If the remember is successful, the original call is replayed meaning the user did not notice anything of this exchange except for a slightly longer delay before his action went through.

When the OTP is used, a new one is generated to be used next time.

Forget

You can explicitly call the forget service which will proactively invalidate any token you might have received in the frontend and also invalidate and remove the OTP set by the remember.

Authorization

Now that you have a grasp of the basic concepts underlying the security offered by the CMS, let's look at the actual authorization rules and how they are persisted.

security model

Actions

Actions are permissions that were unfortunately not named as such when the CMS was first set up. An action is identified by its name and the ownerId. The owner id is a node in the hierarchy.

As a general rule, actions should be defined as specifically as possible within the node hierarchy. An action (or permission) is tied directly to one or more pieces of code, for example REST services.

These services are generally built specifically for your project and meant to be used by everyone in that project so the vast majority of custom actions that you create should be added to the project node.

If your action is more globally useful, you can define it at the $global level, this is often the case when building frameworks for example. If you were to ship code very specific to for example a client that is represented somewhere in the tree, the action should be added there.

Localization

From a security perspective only actions that are owned by the context you are querying or its parent hierarchy to the root are considered. This means suppose you have this setup:

  • We have this node hierarchy:
- global
    - projectA
        - action: contract.list
    - projectB
        - action: contract.list
  • We have a Member role that contains the permission contract.list as defined by projectB.
  • We have a user that has the Member role assigned to him at the global root level.

If we were to ask if the user has contract.list at the root, the system wouldn't know what contract list is, there isn't one at the root. The answer would be no.

If we were to ask if a user has that permission in projectA, the answer would still be no. The user has a permission called contract.list but the one defined by projectB, it does not apply to projectA.

If we now added an action contract.list at the global root level and added it to the role, the user would have a valid contract.list permission in both projectA and projectB.

So in essence we added a third action which is a separate record from the other two, but because of the node relation between the action owners, the third action is applied in both contexts.

Custom actions

Because actions are generally tied to specific bits of code, it is rarely useful to let end users dynamically define their own actions. This means we normally don't have actions further down the tree.

Roles

Because actions are generally aimed at a specific bit of code, it would get very tiresome very quickly if you had to assign each action to each user individually.

Instead we combine actions into roles (linked in the ActionRole table) that combine actions together into actually usable profiles.

For example in an IOT application you might have a role "Device Manager" that includes actions "device.list", "device.activate", "company.get", "location.get",...

There are in general two ways to look at roles:

  • company focused: a user has a role within the company as a whole which necessitates certain access to particular applications
  • project focused: a user has a role within a project, depending on what the user needs cross project, he can be assigned a dynamic combination of roles

Both are equally supported but we tend to take the more company focused view. Company focused roles transcend any single project so we create them at the $global level. Project specific roles are of course created at the project level.

Custom roles

Unlike actions, it can be interesting to give endusers control over their own roles to manage their own users. These roles are typically spread out over the node hierarchy as needed.

Groups

If you have a complex enough security landscape, applying roles to each user separately in the correct context would still be a frustrating experience. Instead users are combined into groups (linked in the UserGroup table).

A group has an owner which is important for its uniqueness, but it has no real security implication. Suppose you have a company in your node hierarchy and they want to invite all their employees to your platform. We will often create a group "members" which is owned by the company and contains all the members.

It is in the linking table between group and role (GroupRole) that we actually configure the context in which security rule applies. The optional nodeId field determines the location in the tree from where the assignment is applied.

If the nodeId is not filled in, it applies globally. This is used for example to model the super administrators.

Note: In the initial setup inheritance could be toggled off but this has long since be enabled by default and can no longer be turned off.