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:
[1]:
from pathlib import Path
resources_directory = Path().cwd().parent / "getting_started" / "tutorial" / "data"
[2]:
import os
import atoti as tt
session_config = tt.SessionConfig(
security=tt.SecurityConfig(
sso=tt.OidcConfig(
access_token_format="opaque", # noqa: S106
client_id=os.environ["OIDC_CLIENT_ID"],
client_secret=os.environ["OIDC_CLIENT_SECRET"],
issuer_url=os.environ["OIDC_ISSUER_URL"],
name_claim="preferred_username",
provider_id="unused",
roles_claims={("resource_access", os.environ["OIDC_CLIENT_ID"], "roles")},
scopes={"openid", "profile", "roles"},
),
),
)
[3]:
session = tt.Session.start(session_config)
[ ]:
sales_table = session.read_csv(
resources_directory / "sales.csv", keys={"Sale ID"}, table_name="Sales"
)
[ ]:
shops_table = session.read_csv(
resources_directory / "shops.csv", keys={"Shop ID"}, table_name="Shops"
)
[6]:
sales_table.join(shops_table, sales_table["Shop"] == shops_table["Shop ID"])
[7]:
session.tables.schema
[7]:
[8]:
cube = session.create_cube(sales_table)
The users configured in the OIDC provider are:
global-user with the role user.
french-user with the roles france and atoti.
parisian-user with the roles paris and atoti.
Managing the security config of the session#
Session.security is used to manage all the dynamic security config 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:
global-user has access to everything.
french-user only has access to France data.
parisian-user only has access to Paris data.
Since by default users have access to all the data, we only need to create restrictions for the regional users:
[9]:
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 the OIDC provider, and the roles we want them to have in the Atoti application.
[10]:
session.security.oidc.role_mapping.update(
{
"atoti": {"ROLE_USER"},
"france": {"ROLE_FRANCE"},
"paris": {"ROLE_PARIS"},
},
)
Connecting to the session#
[11]:
def query_city_and_quantity_sum(*, impersonated_username: str):
authentication = tt.OAuth2ResourceOwnerPasswordAuthentication(
client_id=os.environ["OIDC_CLIENT_ID"],
client_secret=os.environ["OIDC_CLIENT_SECRET"],
issuer_url=os.environ["OIDC_ISSUER_URL"],
# To keep things simple in this how-to, all the users share the same password.
password=os.environ["OIDC_USER_PASSWORD"],
scopes={"openid"},
username=impersonated_username,
)
with tt.Session.connect(
session.url,
authentication=authentication,
) as existing_session:
cube = next(iter(existing_session.cubes.values()))
return cube.query(
cube.measures["Quantity.SUM"],
levels=[cube.levels["City"]],
include_totals=True,
)
First, we can check that parisian-user can only see data for shops in Paris:
[12]:
query_city_and_quantity_sum(impersonated_username="parisian-user")
[12]:
Quantity.SUM | |
---|---|
City | |
Total | 603.00 |
Paris | 603.00 |
When we connect to the application as french-user, we can only see data for shops in France:
[13]:
query_city_and_quantity_sum(impersonated_username="french-user")
[13]:
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-user, we can see data for shops everywhere:
[14]:
query_city_and_quantity_sum(impersonated_username="global-user")
[14]:
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.
[15]:
technical_user_name = "technical-user"
technical_user_password = "changeit" # noqa: S105
technical_user = session.security.basic_authentication.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:
[16]:
from dataclasses import asdict
[17]:
@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:
[18]:
import httpx
[19]:
response = httpx.get(
f"{session.url}/atoti/pyapi/whoami",
auth=(technical_user_name, technical_user_password),
).raise_for_status()
response.status_code
[19]:
200
[20]:
response.json()
[20]:
{'name': 'technical-user', 'roles': ['ROLE_USER']}
To finish up, we can check that our endpoint requires authentication:
[21]:
httpx.get(f"{session.url}/atoti/pyapi/whoami").status_code
[21]:
302