Access Control
Tiled uses a programmable Access Control Policy to manage which users can access and perform actions on (read, write, delete) which entries.
For use background on how to think about access control generally, we recommend the talk Why Authorization Is Hard, (Sam Scott, PyCon 2022).
A key point is that if a user cannot access something, it should be as if it does not exist. In a user interface built on Tiled, whether graphical or terminal-based, we don’t want to even show the user buttons or options that they are not allowed to use.
In support of this, a Tiled Access Control Policy answers two questions:
Given one specific dataset, what is the user allowed to do with it (read, write, etc.)?
Given a hierarchical dataset, which of the “children” is the user allowed to {read, read and write, …}?
The first one lets us efficiently ask which “buttons” to show for a given data set. The second one lets us efficiently ask which items to show (or hide…) in a list of contents.
Rephrasing these two items now using the jargon of entities in Tiled:
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.
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:
"""
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:
authentication:
providers:
- provider: toy
authenticator: tiled.authenticators:DictionaryAuthenticator
args:
users_to_passwords:
alice: ${ALICE_PASSWORD}
bob: ${BOB_PASSWORD}
cara: ${CARA_PASSWORD}
confirmation_message: "You have logged in as {id}."
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