Skip to content

Database Access

The LEAF platform uses dedicated database users following the principle of least privilege. All sensor data access goes through SECURITY DEFINER functions — no role has direct SELECT on sensor_data except writers.

RolePurpose
readersService accounts that query data (Grafana, portal). Access via email-based SECURITY DEFINER functions + SELECT on management tables. No direct access to sensor_data.
writersService accounts that write data (Node-RED for sensor_data, portal for management tables). Full DML on all tables.
api_readersExternal callers. Token-based SECURITY DEFINER functions only. No email functions, no direct table access.
UserRolesUsed by
leaf_portal_userreaders + writersPortal application
leaf_grafana_userreaders onlyGrafana dashboards
leaf_api_userapi_readers onlyExternal scripts, notebooks

All data access is management-based. A user must have at least one user_management entry whose management scope covers the requested data.

user_account ──► user_management ──► management
┌──────────┴──────────┐
department_id organisation_id
entity (optional) time_start / time_end

The management record defines:

  • Which organisation and department the user can access
  • Optionally, which entity (sensor/device) within that department
  • Optionally, a time window (time_start / time_end) outside which data is not returned

These bounds are hard limits enforced at the database level inside every SECURITY DEFINER function. A caller cannot request data outside their management grant regardless of what parameters they pass.

These functions are used by the portal and Grafana. The caller’s email is resolved to management grants at query time.

FunctionReturnsUse case
organisations_for_user(email)Organisations the user has any access toPortal navigation
management_for_user(email)All management grants for the userGrafana variable queries
sensor_data_for_department_per_uuid(email, dept_uuid, entity, metric)Raw rows, most recent firstPortal data explorer export
sensor_data_for_department_per_time(email, org, dept, from, to, interval, entities[])Time-bucketed aggregatesPortal plots, Grafana time-series panels
sensor_data_recent(email, limit)Most recent N rows across all accessible dataDashboard summary

These functions are used by external scripts and the portal API endpoint. The API token is resolved to the owning user’s email internally.

FunctionReturnsUse case
management_for_token(token)Management grants for the token’s ownerVariable queries in token-based Grafana
sensor_data_timeseries_by_token(token, org, dept, from, to, interval, entities[], metric)Time-bucketed aggregatesGrafana panels with API token auth
sensor_data_raw_by_token(token, org, dept, from, to, entities[], metric, limit)Raw rowsExternal scripts, notebooks
sensor_catalog_for_token(token, org, dept)Distinct (entity, metric) pairsVariable queries, discovery
Section titled “asyncpg (recommended for async applications)”
import asyncpg
pool = await asyncpg.create_pool(
host="localhost",
port=5432,
database="leaf",
user="leaf_api_user",
password="your-password",
min_size=2,
max_size=10,
)
# Query with an API token
rows = await pool.fetch(
"""
SELECT *
FROM sensor_data_timeseries_by_token(
$1, $2, $3,
$4::timestamptz, $5::timestamptz,
'1 hour'::interval,
NULL, -- all entities
NULL -- all metrics
)
ORDER BY time
""",
"your-api-token",
"YourOrganisation",
"YourDepartment",
datetime(2025, 1, 1),
datetime(2025, 2, 1),
)
import pandas as pd
from sqlalchemy import create_engine, text
engine = create_engine(
"postgresql://leaf_api_user:your-password@localhost:5432/leaf"
)
with engine.connect() as conn:
df = pd.read_sql(
text("""
SELECT *
FROM sensor_data_timeseries_by_token(
:token, :org, :dept,
:from_ts::timestamptz, :to_ts::timestamptz,
'1 hour'::interval,
NULL, NULL
)
ORDER BY time
"""),
conn,
params={
"token": "your-api-token",
"org": "YourOrganisation",
"dept": "YourDepartment",
"from_ts": "2025-01-01",
"to_ts": "2025-02-01",
}
)
print(df.head())
with engine.connect() as conn:
catalog = pd.read_sql(
text("""
SELECT entity, metric
FROM sensor_catalog_for_token(:token, :org, :dept)
ORDER BY entity, metric
"""),
conn,
params={"token": "your-api-token", "org": "YourOrg", "dept": "YourDept"},
)
print(catalog)
postgresql://leaf_api_user:YOUR_PASSWORD@localhost:5432/leaf

When connecting from inside the Docker network, use timescaledb as the host instead of localhost.

Always provide a time range. Without p_from/p_to, the query scans all TimescaleDB chunks. At large data volumes (100M+ rows) this adds hundreds of milliseconds of planning time alone. Pass a concrete time range to let TimescaleDB prune to only the relevant chunks.

Pass time parameters inside the function. Because the functions use SECURITY DEFINER, PostgreSQL cannot inline them. A WHERE time > ... clause applied outside the function call does not benefit from chunk exclusion. Pass p_from and p_to as function arguments.

Use sensor_catalog_for_token for entity/metric discovery. The sensor_catalog is a pre-computed materialized view of distinct (department_id, entity, metric) triples, refreshed automatically. Querying it is far faster than running DISTINCT over sensor_data.

Store credentials in environment variables. Never hardcode passwords or API tokens in scripts.

import os
token = os.environ["LEAF_API_TOKEN"]
password = os.environ["LEAF_DB_PASSWORD"]
Operationleaf_api_userleaf_grafana_userleaf_portal_user
Direct SELECT on sensor_data
Token-based functions
Email-based functions
INSERT / UPDATE on management tables
INSERT on sensor_data
ToolUse case
asyncpgAsync Python applications
pandas + SQLAlchemyData analysis notebooks
psqlQuick interactive queries
pgAdmin / DBeaverGUI query builder
GrafanaDashboards — see Grafana Integration