Table of contents
  1. ADSP service SDK (Node.js)
    1. Generating service
    2. Setting up a service account
    3. Initializing the SDK
    4. Authorizing requests
      1. Using the core strategy
      2. Service specific user roles
    5. Determining tenancy
    6. Finding services
    7. Handling configuration
      1. Converting configuration
    8. Registering event definitions, notification types, etc.
    9. Additional utilities
      1. ADSP ID
      2. Role-based authorization
      3. Platform health check
      4. Errors and error handler
      5. Logging

ADSP service SDK (Node.js)

Platform services integrate into the foundational capabilities via a Software Development Kit (SDK). The SDK includes interfaces and utilities for handling tenancy, configurations, and registration. The same SDK can be used for development of tenant services.

Note that the SDK provides friendly interfaces on top of APIs. It is intended to speed up service development but is not the only way to access platform capabilities.

npm i @govalta/adsp-service-sdk

Generating service

The ADSP project includes a workspace generator for creating new express based backend services. The base template includes usages of SDK capabilities and is a good starting point for understanding the SDK.

Generate a new service by running:

npx nx workspace-generator adsp-service

The generate output includes sub-project structure and initial files under /apps as well as deployment manifests under .openshift/managed and .compose.

Setting up a service account

The SDK requires credentials for a service account and uses this account for accessing platform capabilities. Fine grained configuration is possible and principle of least privilege should be applied.

In order to create the service account.

  1. Create a confidential Client with a client ID in the format: urn:ads:{tenant}:{service} . The SDK does not authenticate end users and so all authentication grant types can be disabled.
  2. Enable service account for the client.
  3. In Service Account Roles, add the appropriate Client Roles for the capabilities that will be accessed:
    1. Client urn:ads:platform:tenant-service role platform-service is required.
    2. Client urn:ads:platform:configuration-service role configured-service is needed for registration and accessing service specific configuration
    3. Client urn:ads:platform:event-service role event-sender is needed for sending domain events.
  4. Additional audiences in the service account access token are required for some capabilities:
    1. Client urn:ads:platform:push-service needs to be include via an audience mapper for socket based configuration cache invalidation.

Initializing the SDK

SDK capabilities are access via either the initializePlatform or the initializeService function. It takes service metadata as inputs and returns an object with the initialized platform interfaces and utilities.

  import { AdspId, initializePlatform } from '@abgov/adsp-service-sdk';

  const serviceId = AdspId.parse(environment.CLIENT_ID);
  const {
    coreStrategy,
    tenantStrategy,
    ...sdkCapabilities,
  } = await initializePlatform(
    {
      displayName: 'My platform service',
      description: 'Example of a platform service.',
      serviceId,
      accessServiceUrl: new URL(environment.KEYCLOAK_ROOT_URL),
      clientSecret: environment.CLIENT_SECRET,
      directoryUrl: new URL(environment.DIRECTORY_URL),
      configurationSchema,
      events: [],
      roles: [],
      notifications: [],
    },
    { logger }
  );

Authorizing requests

The SDK provides Passport strategies for verifying JWT bearer tokens in tenant and core realm requests.

Verify tenant bearer token:

  const {
      tenantStrategy,
      ...sdkCapabilities,
    } = await initializePlatform(parameters);

  passport.use('tenant', tenantStrategy);
  const authenticateHandler = passport.authenticate(['tenant'], { session: false });

Using the core strategy

Core requests are used by platform services making requests to other platform services under a service account. However, users may have core accounts as well. Services should only use the core strategy when requests from a core context is expected, and should enforce role-based access controls on operations.

  const {
      coreStrategy,
      ...sdkCapabilities,
    } = await initializePlatform(parameters);

  passport.use('core', coreStrategy);
  const authenticateHandler = passport.authenticate(['core'], { session: false });

Service specific user roles

Keycloak issued tokens contain client roles nested under realm_access. Both tenant and core strategies flatten service specific roles from the token and qualifies roles related to other service clients with the client ID..

For example:

  {
    "realm_access": { "roles": ["user"] },
    "resource_access": {
      "my-service": { "roles": ["my-user"] },
      "other-service": { "roles": ["other-user"] }
    }
  }

For my-service, the roles are mapped to roles:

  • user
  • my-user
  • other-service:other-user

Determining tenancy

Requests to platform services are in the context of a specific tenant with few exceptions. The context is implicit when a request is made with a tenant bearer token. It can be explicit in cases where an endpoint allows anonymous access or when a platform service makes a request to another platform service under a core service account.

The SDK provides a request handler that resolves implicit from user tenancy and explicit from a tenantId query parameter. Resolved tenant is set on the request object; no value is set if tenancy cannot be resolved.

Getting tenancy using the tenant request handler:

  const {
    tenantHandler,
    ...sdkCapabilities
  } = await initializePlatform(parameters);

  app.use(
    '/my-resource',
    authenticateHandler,
    tenantHandler,
    (req, res) => { res.send(req.tenant) }
  );

The handler uses the tenant service client to retrieve tenant information. This is also available from the SDK for direct use.

Getting tenant information using the tenant service:

  const {
    tenantService,
    ...sdkCapabilities
  } = await initializePlatform(parameters);

  app.use(
    '/my-resource',
    authenticateHandler,
    async (req, res) => {
      const tenant = await tenantService.getTenant(req.user.tenantId);
      res.send(tenant);
    }
  );

Finding services

Service discovery in ADSP is handled using client side service discovery with a directory of services providing a register of available services.

Getting a service URL from the directory:

  const {
    directory,
    ...sdkCapabilities,
  } = await initializePlatform(parameters);

  const serviceUrl = await directory.getServiceUrl(adspId`urn:ads:platform:event-service`);

Handling configuration

Platform services can make use of a common configuration service for managing configuration. The SDK allows services to define their configuration schema and access configuration.

Defining the configuration json schema:

  const {
    configurationHandler,
    configurationService,
    ...sdkCapabilities,
  } = await initializePlatform({
      configurationSchema: {
        type: 'object',
        properties: {
          types: {
            type: 'object',
            additionalProperties: {
              type: 'object',
              properties: {
                name: { type: 'string' },
                description: { type: 'string' }
              },
              required: ['name'],
            }
          }
        }
      },
      ...parameters,
  });

  const serviceUrl = await directory.getServiceUrl(adspId`urn:ads:platform:event-service`);

Each service can have core configuration that applies across tenants and configuration specific to each tenant. The SDK provides a configuration request handler that will retrieve configuration in the request tenant context.

Getting configuration via the request:

  const {
    configurationHandler,
    ...sdkCapabilities,
  } = await initializePlatform(parameters);

  app.use(
    '/my-resource',
    authenticateHandler,
    tenantHandler,
    configurationHandler,
    async (req, res) => {
      const [tenantConfig, coreConfig] = await req.getConfiguration<MyServiceConfiguration>();
      res.send(tenantConfig);
    }
  );

The tenant context is based on req.tenant set by the tenant request handler when available and falls back to req.user.tenantId.

The handler uses configuration service client to retrieve configuration. This is also available from the SDK for direct use.

Getting configuration using the configuration service:

  const {
    configurationService,
    ...sdkCapabilities,
  } = await initializePlatform(parameters);

  app.use(
    '/my-resource',
    authenticateHandler,
    tenantHandler,
    async (req, res) => {
      const [tenantConfig, coreConfig] = await configurationService.getConfiguration<MyServiceConfiguration>(
        serviceId,
        accessToken,
        tenantId,
      );
      res.send(tenantConfig);
    }
  );

Converting configuration

Services may want to apply transformations on the retrieved configuration. The SDK allows services to provide functions for converting and combining core and tenant configuration. For example, services can use these to generate effective configuration when tenant overrides parts of core configuration.

Provide conversion functions:

  const {
    configurationHandler,
    configurationService,
    ...sdkCapabilities,
  } = await initializePlatform({
      configurationSchema,
      configurationConverter: (config, tenantId) => new MyConfigurationEntity(config, tenantId),
      combineConfiguration: (
        tenantConfig: MyConfigurationEntity,
        coreConfig: MyConfigurationEntity,
        tenantId
      ) => tenantConfig.merge(coreConfig),
      ...parameters,
  });

  app.use(
    '/my-resource',
    authenticateHandler,
    tenantHandler,
    configurationHandler,
    async (req, res) => {
      const effectiveConfig = await req.getConfiguration<MyServiceConfiguration, MyConfigurationEntity>();
      res.send(effectiveConfig);
    }
  );

configurationConverter is called for both core and tenant configuration then the results are input into combineConfiguration.

Registering event definitions, notification types, etc.

The SDK allows services to register configuration for some platform services.

  • roles defines the client roles of the service. New tenant realms are created with a client that includes the roles specified here.
  • events defines the domain events of the service.
  • notifications defines the notification types of the service.

Defining configuration for other platform services:

  const {
    ...sdkCapabilities,
  } = await initializePlatform({
    roles: [{
      name: 'my-service-admin',
      description: 'Administrator role for my-service.',
      inTenantAdmin: true,
    }],
    events: [{
      name: 'intake-submitted',
      description: 'Signalled when an intake is submitted',
      payloadSchema: {
        type: 'object',
        properties: {
          submitter: {
            type: 'string'
          }
        }
      }
    }],
    notifications: [{
      name: 'intake-updates',
      description: 'Provides updates on intake application.',
      publicSubscribe: false,
      subscriberRoles: [],
      channels: [Channel.email],
      events: [{
        namespace: 'my-service',
        name: 'intake-submitted',
        templates: {
          [Channel.email]: {
            subject: 'Intake submitted',
            body: 'Hi , Your intake was submitted.',
          }
        }
      }],
    }],
    ...parameters,
  });

Additional utilities

The SDK provides several other useful utilities.

ADSP ID

Utilities for handling ADSP URNs.

Parse and convert urns:

  import { adspId, AdspId } from '@abgov/adsp-service-sdk';

  const dynamicAdspId = AdspId.parse(dynamicId);
  const staticAdspId = adspId`urn:ads:platform:task-service`;

Role-based authorization

Platform health check

Include platform service checks in the service health check endpoint.

  const {
    healthCheck,
    ...sdkCapabilities,
  } = await initializePlatform(parameters);

   app.get('/health', async (_req, res) => {
    const platform = await healthCheck();
    res.json(platform);
  });

Errors and error handler

Use a standard error handler:

  import { createErrorHandler } from '@abgov/adsp-service-sdk';

  const errorHandler = createErrorHandler(logger);
  app.use(errorHandler);

Logging

Import a standard logger for platform services:

  import { createLogger } from '@abgov/adsp-service-sdk';
  const logger = createLogger('my-service', environment.LOG_LEVEL);