Spike: MODKBEKBJ-358 - Investigate OKAPI request routing implementation

MODKBEKBJ-358 - Getting issue details... STATUS

Multi-tenant context execution problem


The system is aware of 3 tenants:

  • Tenant A
  • Tenant B
  • Tenant C

There is also some relation between the tenants so that in some cases Tenant A can be replaced with Tenant B and in another cases with Tenant C.

Module 1 can be executed in the context of Tenant A only

Module 2 can be executed either in the context of Tenant B or Tenant C but not Tenant A

To execute Modules in proper context there has to be a component to resolve the correct tenant before the request gets to the modules.

Module characteristics important for request processing

Module characteristics defined in ModuleDescriptor-template.json. Here is the module descriptor from mod-authtoken:

mod-authtoken -> ModuleDescriptor-template.json
{
  "id": "${artifactId}-${version}",
  "name": "authtoken",
  "provides": [
    {
      "id": "authtoken",
      "version": "2.0",
      "handlers" : [
        {
          "methods" : [ "POST" ],
          "pathPattern" : "/token"
        },
        {
          "methods" : [ "POST" ],
          "pathPattern" : "/refreshtoken"
        },
        {
          "methods" : [ "POST" ],
          "pathPattern" : "/refresh"
        }
      ]
    }
  ],
  "requires" : [
    {
      "id" : "permissions",
      "version" : "5.2"
    },
    {
      "id" : "users",
      "version" : "15.0"
    }
  ],
  "filters" : [
    {
      "methods" : [ "*" ],
      "pathPattern" : "/*",
      "phase" : "auth",
      "type" : "headers"
    }
  ],
  "launchDescriptor": {
    "dockerImage": "${artifactId}:${version}",
    "dockerPull": false,
    "dockerArgs": {
      "HostConfig": {
        "Memory": 357913941,
        "PortBindings": { "8081/tcp": [ { "HostPort": "%p" } ] }
      }
    },
    "env": [
      { "name": "JAVA_OPTIONS",
        "value": "-XX:MaxRAMPercentage=66.0 -Dcache.permissions=true"
      }
    ]
  }
}


Modules can declare two ways to handle a request:

  • handlers
  • filters

There should be exactly one handler for each path. That will be of request-response type by default.

Each request may be passed through one or more filters. Filters have a phase attribute which determines the order in which filters are applied.

At the moment we have three phases defined:

  • auth will be invoked first. It is used for checking the X-Okapi-Token, and permissions.
  • pre will be invoked just before the handler. It is intended for logging and reporting all requests.
  • post will be invoked just after the handler. It is intended for logging and reporting all responses.

The type attribute in the RoutingEntry in the ModuleDescription controls how the request is passed to the filters and handlers, and how the responses are processed. Currently, the following types supported:

  • headers -- The module is interested in headers/parameters only, and it can inspect them and perform an action based on the presence/absence of headers/parameters and their corresponding value. The module is not expected to return any entity in the response, but only a status code to control the further chain of execution or, in the case of an error, an immediate termination. The module may return certain response headers that will be merged into the complete response header list according to the header manipulation rules below.
  • request-only -- The module is interested in the full client request: header/parameters and the entity body attached to the request. The headers returned including the response code affects further processing but the response body is ignored. Note that type request-only Okapi will buffer an incoming request body (POST presumably) into memory. This does not scale for large import(s) or the like. Use request-log instead if the response may be ignored.
  • request-response -- The module is interested in both headers/parameters and the request body. It is also expected that the module will return an entity in the response. This may be e.g. a modified request body, in which case the module acts as a filter. The returned response may then be forwarded on to the subsequent modules as the new request body. Again, the chain of processing or termination is controlled via the response status codes, and the response headers are merged back into the complete response using the rules described below.
  • redirect -- The module does not serve this path directly, but redirects the request to some other path, served by some other module. This is intended as a mechanism for piling more complex modules on top of simpler implementations, for example, a module to edit and list users could be extended by a module that manages users and passwords. It would have actual code to handle creating and updating users, but could redirect requests to list and get users to the simpler user module. If a handler (or a filter) is marked as a redirect, it must also have a redirectPath to tell where to redirect to.
  • request-log -- The module is interested in the full client request: header/parameters and the entity body attached to the request. This is similar to request-only but the entire response, including headers and response code, is ignored by Okapi. This type appeared in Okapi version 2.23.0.
  • request-response-1.0 -- This is like request-response, but makes Okapi read the full body before POSTing to the module so that Content-Length is set and chunked encoding is disabled. This is useful for modules that have trouble dealing with chunked encoding or require getting content length before inspecting. This type appeared in Okapi 2.5.0.

Handlers can define one or more permissions required to be able to execute the handler.  The permissions can come from two sources: either they are granted for the user, or they are granted for a module. See a simplified version of module descriptor from mod-notes below. "permissionsRequired" attribute defines user permissions, "modulePermissions" attribute defines permissions granted to the module specifically during the particular request handling

mod-notes: ModuleDescriptor-template.json
{
  "id": "${artifactId}-${version}",
  "name": "Notes",
  "requires": [
    {
      "id": "users",
      "version": "15.0"
    },
    {
      "id": "configuration",
      "version": "2.0"
    }
  ],
  "provides": [
    {
      "id": "notes",
      "version": "1.0",
      "handlers": [
        {
          "methods": ["GET"],
          "pathPattern": "/notes",
          "permissionsRequired": ["notes.collection.get", "notes.domain.all"]
        },
        {
          "methods": ["POST"],
          "pathPattern": "/notes",
          "permissionsRequired": ["notes.item.post", "notes.domain.all"],
          "modulePermissions": ["users.item.get"]
        },
        {
          "methods": ["GET"],
          "pathPattern": "/notes/{id}",
          "permissionsRequired": ["notes.item.get","notes.domain.all"]
        },
        {
          "methods": ["PUT"],
          "pathPattern": "/notes/{id}",
          "permissionsRequired": ["notes.item.put", "notes.domain.all"],
          "modulePermissions": ["users.item.get"]
        },
........................................................................................
      ]
    },
    {
      "id": "_jsonSchemas",
      "version": "1.0",
      "interfaceType" : "multiple",
      "handlers" : [
        {
          "methods" : [ "GET" ],
          "pathPattern" : "/_/jsonSchemas"
        }
      ]
    }, {
      "id": "_ramls",
      "version": "1.0",
      "interfaceType" : "multiple",
      "handlers" : [
        {
          "methods" : [ "GET" ],
          "pathPattern" : "/_/ramls"
        }
      ]
    }, {
      "id": "_tenant",
      "version": "1.0",
      "interfaceType": "system",
      "handlers": [
        {
          "methods": [
            "POST"
          ],
          "pathPattern": "/_/tenant"
        },
        {
          "methods": [
            "DELETE"
          ],
          "pathPattern": "/_/tenant"
        }
      ]
    }
  ],
  "permissionSets": [
    {
      "permissionName": "notes.collection.get",
      "displayName": "Notes - get notes collection",
      "description": "Get notes collection"
    },
    {
      "permissionName": "notes.item.get",
      "displayName": "Notes - get individual note from storage",
      "description": "Get individual note"
    },
    {
      "permissionName": "notes.item.post",
      "displayName": "Notes - create note",
      "description": "Create note"
    },
    {
      "permissionName": "notes.item.put",
      "displayName": "Notes - modify note",
      "description": "Modify note"
    },
    {
      "permissionName": "notes.item.delete",
      "displayName": "Notes - delete note",
      "description": "Delete note"
    },
    {
      "permissionName": "notes.domain.all",
      "displayName": "Notes - allow access to all domains",
      "description": "All domains"
    },
......................................................................................
    {
      "permissionName": "notes.allops",
      "displayName": "Notes module - all CRUD permissions",
      "description": "Entire set of permissions needed to use the notes modules, but no domain permissions",
      "subPermissions": [
        "notes.collection.get",
        "notes.item.get",
        "notes.item.post",
        "notes.item.put",
        "notes.item.delete",
        "note.links.collection.put",
        "notes.collection.get.by.status"
      ],
      "visible": false
    },
    {
      "permissionName": "note.types.allops",
      "displayName": "Note types - all CRUD permissions",
      "description": "Entire set of permissions needed to use the note type for note module",
      "subPermissions": [
        "note.types.item.get",
        "note.types.collection.get",
        "note.types.item.post",
        "note.types.item.put",
        "note.types.item.delete"
      ],
      "visible": false
    },
    {
      "permissionName": "notes.all",
      "displayName": "Notes module - all permissions and all domains",
      "description": "Entire set of permissions needed to use the notes modules on any domain",
      "subPermissions": [
        "notes.allops",
        "notes.domain.all",
        "note.types.allops"
      ],
      "visible": false
    }
  ],
  "launchDescriptor": {
    "dockerImage": "${artifactId}:${version}",
    "dockerPull": false,
    "dockerArgs": {
      "HostConfig": {
        "Memory": 357913941,
        "PortBindings": { "8081/tcp": [ { "HostPort": "%p" } ] }
      }
    },
    "env": [
      { "name": "JAVA_OPTIONS",
        "value": "-XX:MaxRAMPercentage=66.0" },
      { "name": "DB_HOST", "value": "postgres" },
      { "name": "DB_PORT", "value": "5432" },
      { "name": "DB_USERNAME", "value": "folio_admin" },
      { "name": "DB_PASSWORD", "value": "folio_admin" },
      { "name": "DB_DATABASE", "value": "okapi_modules" },
      { "name": "DB_QUERYTIMEOUT", "value": "60000" },
      { "name": "DB_CHARSET", "value": "UTF-8" },
      { "name": "DB_MAXPOOLSIZE", "value": "5" }
    ]
  }
}


OKAPI request processing

  1. OKAPI receives a request with URI address
  2. It builds a list of modules that serve the request, and that are enabled for the tenant. The modules are sorted according to their configuration in ModuleDescriptors
    1. usually, the list consists of two modules
      1. authentication and authorization module (mod-authtoken)
      2. target module to handle the request (like mod-inventory, mod-orders, mod-kb-ebsco-java)
  3. OKAPI combines all required permissions from modules involved in the call
  4. OKAPI starts sending requests for the modules. For each module in its pipeline list, it
    1. Checks where the module is running (which node, which port)
    2. Passes the original HTTP request to the module, with additional headers
    3. When the module response arrives, Okapi checks if it was an OK response. If not, it will return the error response to the caller, and abort all processing.
  5. When Okapi has processed all modules in its list, it returns the last response to who ever called it.

Authorization

Each call to a business module has to be authorized by Auth module (mod-authtoken).

Authorization includes the following steps (the steps are actually might varŅƒ depending on the call context):

  1. Checks that the request has an X-Okapi-Tenant header
  2. Checks if there is an X-Okapi-Token header. If not, it creates one.
  3. If there is an X-Okapi-Token, it verifies it.
  4. Validates that user is active: user with userId obtained from the request has "active" attribute equal to true
  5. Tests that user has all the necessary permission to call the module

If any of the validation tests failed then the whole authorization process failed and the request processing is aborted abnormally.

Request flow samples

The samples provided below show two common cases of request flow:

  1. request is processed by a single module only (Simple Case)
  2. request involves several modules in the processing (Advanced Case)
    1. usually, there is a main module or orchestrator which receives the initial request and then delegates some work to other modules. The response is also returned to the caller by the orchestrator.

Modules involved in sample flows:

OKAPI_1/OKAPI_2 and Auth-Token_1/Auth-Token_1 are the same components OKAPI and Auth_Token respectively. They are duplicated just for the convenience of showing a flow on the diagrams.

Simple Case: a single business module is called

Use Case:

Retrieve a list of "note types" defined in the system

Main Steps:

  1. UI sends GET /note-types request to OKAPI
  2. The request is authorized by Auth module (Auth-Token)
    1. user issued the request verified to be an active one - (A) IS USER ACTIVE
    2. user permissions checked - (B) ARE PERMISSIONS VALID
  3. OKAPI sends the request to Notes module
  4. Notes module retrieves the list types and replies back to OKAPI which in turns returns the list to UI

OKAPI log: okapi-get-note-types.log


Advanced Case: several business modules are called internally

Use Case:

Create a new "note types".

Main Steps:

  1. UI sends POST /note-types request with new note type details to OKAPI
  2. The request is authorized by Auth module (Auth-Token)
    1. user issued the request verified to be an active one - (A) IS USER ACTIVE
    2. user permissions checked - (B) ARE PERMISSIONS VALID
  3. OKAPI sends the request to Notes module
  4. Notes module receives the request and processes it as follows:
    1. calls Configuration module to get the maximum number of note types allowed in the system - (C) INTERNAL CALL TO CONFIGURATION
      1. configuration request passes OKAPI and Auth as usual and gets to Configuration module which retrieves the limit and sends it back Notes module
    2. the maximum number of types limitation applied, if successful the process continues
    3. new note type has to be populated with user creator details, so Notes calls Users to get the details of the current user - (D) INTERNAL CALL TO USERS
      1. user request passes OKAPI and Auth as usual and gets to User module which retrieves user by id and sends it back Notes module
    4. new note type populated with user creator details and saved
    5. "Created" response returned to UI

OKAPI log: okapi-post-note-type.log

Summary

  • tenant resolution can be integrated into the request flow as filter at "pre" phase
  • create a POC to test the approach with tenant resolution module acts as a filter
    • POC should also include some basic logic to support Consortia business requirements at least for
      • mod-kb-ebsco-java and
      • mod-notes
    • examine any possible issues with replaced tenant
      • in particular the potential difference between tenant value in x-okapi-tenant and x-okapi-token headers

Reference information: