workspace_authorization.js

/**
## /workspace/authorization

The authorization module exports the seam through which workspace composition routes template scope access decisions.

A composing host may register asynchronous authorization providers to answer scope decisions from an external policy store, eg. OpenFGA. Each provider is registered with a key.

A provider is only consulted for a user with a matching authorization_provider property. Without the property the authorizeScope method returns the synchronous checkScope semantics for the user.roles array.

@requires /workspace/scopes

@module /workspace/authorization
*/

import { checkScope } from './scopes.js';

/**
@global
@typedef {Object} User
@property {Array<string>|boolean} [user.roles] The user roles.
@property {string} [user.authorization_provider] The key of the authorization provider which decides scope access for the user.
*/

/**
@global
@typedef {Object} AuthorizationContext
@property {Array<string>} scope The templateScope chain of the object being composed.
@property {string} scopeKey The joined scope chain. The scopeKey matches the identifiers recorded in workspace.scopes.
@property {Object} obj The object being composed.
@property {User} [user] The requesting user from request params.

*/

/**
@global
@typedef {Object} AuthorizationProvider
@property {function(AuthorizationContext):Promise<boolean>} checkScope Resolves whether the user has access to the scope.
*/

const providers = new Map();

/**
@function setAuthorizationProvider

@description
The method registers an authorization provider for workspace composition. The provider is only consulted for a user with an authorization_provider property matching the key.

Calling the method without a provider argument clears the registration for the key.

@param {string} key The provider key matched against the user.authorization_provider property.
@param {AuthorizationProvider} [provider] The provider to register.
*/
export function setAuthorizationProvider(key, provider) {
  if (provider === undefined) {
    providers.delete(key);
    return;
  }

  providers.set(key, provider);
}

/**
@function authorizeScope
@async

@description
The method resolves a scope access decision for workspace composition.

The checkScope semantics for the user.roles array apply unless the user has an
authorization_provider property.

With the property the decision is routed through the provider registered for
the user.authorization_provider key. The provider must resolve true for the
scope access to be granted. A missing provider or a provider error fails closed
and access is denied.

@param {AuthorizationContext} context The scope decision context.
@returns {Promise<boolean>} Whether the user has access to the scope.
*/
export async function authorizeScope(context) {
  const providerKey = context.user?.authorization_provider;

  if (!providerKey) {
    return checkScope(context.scope, context.user?.roles);
  }

  try {
    return (await providers.get(providerKey)?.checkScope(context)) === true;
  } catch (error) {
    console.error(error);
    return false;
  }
}