Secure a session#

This feature is not part of the community edition: it needs to be unlocked.

By default, no authentication is required to query an Atoti session and the users have access to all the data in the session.

This shows how to configure:

  • An authentication mechanism to secure access to the session.

  • Restrictions to control the data each user is allowed to see.

Configuring the authentication mechanism#

Atoti supports multiple authentication mechanisms.

Here we’ll use OpenID Connect through Auth0:

[1]:
import os

import atoti as tt
[2]:
session = tt.Session(
    authentication=tt.OidcConfig(
        provider_id="auth0",
        # The connection details are read from environment variables for improved security.
        # See https://12factor.net/config.
        issuer_url=os.environ["AUTH0_ISSUER"],
        client_id=os.environ["AUTH0_CLIENT_ID"],
        client_secret=os.environ["AUTH0_CLIENT_SECRET"],
        scopes={"email", "profile", "username", "roles"},
        name_claim="email",
        roles_claims={"https://activeviam.com/roles"},
        # Remove the following property to let end users authenticate using their browser.
        access_token_format="opaque",  # noqa: S106
    )
)
[3]:
sales_table = session.read_csv(
    "../getting_started/tutorial/data/sales.csv", keys=["Sale ID"]
)
[4]:
shops_table = session.read_csv(
    "../getting_started/tutorial/data/shops.csv", keys=["Shop ID"]
)
[5]:
sales_table.join(shops_table, sales_table["Shop"] == shops_table["Shop ID"])
[6]:
session.tables.schema
[6]:
erDiagram "Sales" { _ String PK "Sale ID" _ LocalDate "Date" _ String "Shop" _ String "Product" nullable double "Quantity" nullable double "Unit price" } "Shops" { _ String PK "Shop ID" _ String "City" _ String "State or region" _ String "Country" _ String "Shop size" } "Sales" }o--o| "Shops" : "`Shop` == `Shop ID`"
[7]:
cube = session.create_cube(sales_table)

The users configured in Auth0 are:

  • paris_manager@atoti.io with the roles Paris and atoti user.

  • france_manager@atoti.io with the roles France and atoti user.

  • global_manager@atoti.io with only the role atoti user.

Here we are using an Open ID Connect authentication provider, however the configuration of the rest of the session is similar if you are using ldap or kerberos authentication.

Managing the security configuration of the session#

Session.security is used to manage all the dynamic security configuration on the session.

Roles and restrictions#

Users without the ROLE_USER role will not be able to access the application.

Restrictions can be used to limit access to the data within the session.

Let’s create restrictions and then assign roles so that:

  • paris_manager@atoti.io only has access to Paris data.

  • france_manager@atoti.io only has access to France data.

  • global_manager@atoti.io has access to everything.

Since by default users have access to all the data, we only need to create restrictions for the regional managers:

[8]:
session.security.restrictions.update(
    {
        "ROLE_PARIS": shops_table["City"] == "Paris",
        "ROLE_FRANCE": shops_table["Country"] == "France",
    }
)

We update the role mapping to create a mapping between the roles our users have in Auth0, and the roles we want them to have in the application.

[9]:
session.security.oidc.role_mapping.update(
    {
        "Paris": {"ROLE_PARIS"},
        "France": {"ROLE_FRANCE"},
        "atoti user": {"ROLE_USER"},
    }
)

Connecting to the session#

When navigating to the URL of the session, users are redirected to the login page of the configured authentication provider.
Let’s connect using our different users and make sure they can only see the expected data subset.

First, we can check that paris_manager@atoti.io can only see data for shops in Paris:

Paris manager view

Same thing if we use a query session authenticated as this user:

[10]:
def query_city_and_quantity_sum(*, impersonated_username: str):
    auth = tt.OAuth2ResourceOwnerPasswordAuthentication(
        username=impersonated_username,
        # For simplicity's sake, in this how-to, all the demo users share the same password.
        password=os.environ["AUTH0_PASSWORD"],
        issuer_url=os.environ["AUTH0_ISSUER"],
        client_id=os.environ["AUTH0_CLIENT_ID"],
        client_secret=os.environ["AUTH0_CLIENT_SECRET"],
    )
    query_session = tt.QuerySession(f"http://localhost:{session.port}", auth=auth)
    query_cube = query_session.cubes[next(iter(session.cubes))]
    return query_cube.query(
        query_cube.measures["Quantity.SUM"],
        levels=[query_cube.levels["City"]],
        include_totals=True,
    )
[11]:
query_city_and_quantity_sum(impersonated_username="paris_manager@atoti.io")
[11]:
Quantity.SUM
City
Total 603.00
Paris 603.00

When we connect to the application as france_manager@atoti.io, we can only see data for shops in France:

France manager view
[12]:
query_city_and_quantity_sum(impersonated_username="france_manager@atoti.io")
[12]:
Quantity.SUM
City
Total 3,027.00
Lyon 609.00
Marseille 603.00
Nice 609.00
Paris 603.00
Saint-Étienne 603.00

And finally, when we connect as global_manager@atoti.io, we can see data for shops everywhere:

Global manager view
[13]:
query_city_and_quantity_sum(impersonated_username="global_manager@atoti.io")
[13]:
Quantity.SUM
City
Total 8,077.00
Chicago 603.00
Houston 606.00
Los Angeles 606.00
Lyon 609.00
Marseille 603.00
New York 808.00
Nice 609.00
Paris 603.00
Saint-Étienne 603.00
San Antonio 606.00
San Diego 606.00
San Francisco 612.00
San Jose 603.00

Technical users#

Technical users (also called service accounts) are often required for plugging external services to the application (e.g. to collect metrics with Prometheus). In order to ease the connection of these tools, Atoti automatically enables Basic Authentication on the session when another authentication mechanism is configured. Let’s add a technical user and query our application using the session’s REST API.

[14]:
technical_user_name = "Technical user"
technical_user_password = "change me"  # noqa: S105

technical_user = session.security.basic.credentials[
    technical_user_name
] = technical_user_password
session.security.individual_roles[technical_user_name] = {"ROLE_USER"}

We create an endpoint to test the technical user’s credentials with:

[15]:
from dataclasses import asdict
[16]:
@session.endpoint("whoami", method="GET")
def whoami(request, user, session):  # noqa: ARG001
    return asdict(user)

Now we can query this endpoint by authenticating as the technical user:

[17]:
import requests
[18]:
response = requests.get(
    f"http://localhost:{session.port}/atoti/pyapi/whoami",
    auth=(technical_user_name, technical_user_password),
    timeout=30,
)
assert response.json() == {"name": "Technical user", "roles": ["ROLE_USER"]}

To finish up, we can check that our endpoint requires authentication:

[19]:
from http import HTTPStatus
[20]:
response = requests.get(
    f"http://localhost:{session.port}/atoti/pyapi/whoami",
    timeout=30,
)
assert response.status_code == HTTPStatus.FORBIDDEN