Access Control

Tiled uses a programmable Access Control Policy to manage which users can access and perform actions on (read, write, delete) which entries.

An Access Control Policy answers two questions:

  1. Given a Principal (user or service) and a Node, return a list of the scopes (actions) the Principal is allowed to perform on that Node.

  2. Given a Principal (user or service), a Node, and a list of scopes (actions), return a list of Query objects that, when applied to the Node, filters its children such that the Principal can do all of those actions on the remaining children (if any).

This determination can be backed by a call to an external service or by a static configuration file. We demonstrate both here.

First, the static configuration file. Consider this simple tree of data:

tiled/examples/toy_authentication.py
"""
This contains a simple tree for demonstrating access control.
See the configuration:

example_configs/toy_authentication.yml
"""
import numpy

from tiled.adapters.array import ArrayAdapter
from tiled.adapters.mapping import MapAdapter

# Make a MapAdapter with a couple arrays in it.
tree = MapAdapter(
    {
        "A": ArrayAdapter.from_array(10 * numpy.ones((10, 10))),
        "B": ArrayAdapter.from_array(20 * numpy.ones((10, 10))),
        "C": ArrayAdapter.from_array(30 * numpy.ones((10, 10))),
        "D": ArrayAdapter.from_array(30 * numpy.ones((10, 10))),
    },
)

protected by this simple Access Control Policy:

example_configs/toy_authentication.yml
authentication:
  providers:
  - provider: toy
    authenticator: tiled.authenticators:DictionaryAuthenticator
    args:
      users_to_passwords:
        alice: ${ALICE_PASSWORD}
        bob: ${BOB_PASSWORD}
        cara: ${CARA_PASSWORD}
  tiled_admins:
    - provider: toy
      id: alice
access_control:
  access_policy: tiled.access_policies:SimpleAccessPolicy
  args:
    provider: toy  # matches provider above
    access_lists:
      alice:
      - A
      - B
      bob:
      - A
      - C
      cara: tiled.access_policies:SimpleAccessPolicy.ALL
    scopes:
    - "read:metadata"
    - "read:data"
    public:
    - D
trees:
  - path: /
    tree: tiled.examples.toy_authentication:tree

Under access_lists: usernames are mapped to the keys of the entries the user may access. The section public: designates entries that an unauthenticated (anonymous) user may access if the server is configured to allow anonymous access. (See Security.) The special value tiled.adapters.mapping:SimpleAccessPolicy.ALL designates that the user may access any entry in the Tree.

ALICE_PASSWORD=secret1 BOB_PASSWORD=secret2 CARA_PASSWORD=secret3 tiled serve config example_configs/config.yml

For large-scale deployment, Tiled typically integrates with an existing access management system. This is sketch of the Access Control Policy used by NSLS-II to integrate with our proposal system.

import cachetools
import httpx
from tiled.queries import In


# To reduce load on the external service and to expedite repeated lookups, use a
# process-global cache with a timeout.
response_cache = cachetools.TTLCache(maxsize=10_000, ttl=60)


class PASSAccessPolicy:
    """
    access_control:
      access_policy: pass_access_policy:PASSAccessPolicy
      args:
        url: ...
        beamline: ...
    """

    def __init__(self, url, beamline, provider):
        self._client = httpx.Client(base_url=url)
        self._beamline = beamline
        self.provider = provider

    def _get_id(self, principal):
        for identity in principal.identities:
            if identity.provider == self.provider:
                return identity.id
        else:
            raise ValueError(
                f"Principcal {principal} has no identity from provider {self.provider}. "
                f"Its identities are: {principal.identities}"
            )

    def allowed_scopes(self, node, principal):
        return {"read:metadata", "read:data"}

    def filters(self, node, principal, scopes):
        queries = []
        id = self._get_id(principal)
        if not scopes.issubset({"read:metadata", "read:data"}):
            return NO_ACCESS
        try:
            response = response_cache[id]
        except KeyError:
            response = self._client.get(f"/data_session/{id}")
            response_cache[id] = response
        if response.is_error:
            response.raise_for_status()
        data = response.json()
        if ("nsls2" in (data["facility_all_access"] or [])) or (
            self._beamline in (data["beamline_all_access"] or [])
        ):
            return queries
        queries.append(
            In("data_session", data["data_sessions"] or [])
        )
        return queries