Security Boundaries & Access Control for TimescaleDB Hypertables

Enforcing access control on a TimescaleDB deployment is harder than on a plain PostgreSQL schema because the objects a query touches are not the objects you granted privileges on. A hypertable is a thin routing layer over hundreds of physical chunks, a continuous aggregate is a materialized view backed by its own internal hypertable, and retention and compression run inside background workers that execute as the object owner rather than the caller. Any one of these can quietly widen the effective access scope of tenant data. This guide solves one focused problem for IoT platform developers, DevOps engineers, and Python automation builders: how to draw a security boundary that holds across chunks, continuous aggregates, and automated maintenance jobs so that a tenant only ever reads its own rows and no background job leaks data across the boundary. It builds directly on the Core Hypertable Architecture & Partitioning Strategy, because access control must be aligned with the chunk model that governs how data is stored, materialized, and dropped.

Row-level security filters tenant queries but is bypassed by the refresh owner A tenant application query passes through the row-level security policy, which filters the result down to only that tenant's rows. A continuous-aggregate refresh job running as the table owner bypasses the same policy and materializes every tenant's rows, so the aggregate needs its own separate policy for tenant-scoped reads. RLS POLICY Tenant app query runs as app_reader Only this tenant's rows query filtered Refresh job runs as table owner All rows materialized bypasses RLS Fix: apply a separate RLS policy on the aggregate for tenant-scoped reads.

The diagram captures the central trap: row-level security (RLS) filters interactive tenant queries, but the role that owns and refreshes a continuous aggregate bypasses those same policies. A correct design treats the base table, the aggregate, and every background job as three separate surfaces that each need their own boundary.

Prerequisites & Schema Assumptions

Before applying any of the SQL below, confirm the environment and schema meet these assumptions. This page targets TimescaleDB 2.10+ on PostgreSQL 14+.

The required TimescaleDB functions are the standard lifecycle set — add_continuous_aggregate_policy, add_retention_policy, add_compression_policy, and add_job — plus the PostgreSQL RLS primitives ENABLE/FORCE ROW LEVEL SECURITY and CREATE POLICY. If you run tenants across separate physical partitions, this page assumes you have already read space partitioning for multi-tenant IoT, since physical segregation and RLS are complementary layers, not substitutes.

Step-by-Step Implementation

The steps below map to the three surfaces in the diagram: secure the base hypertable, secure the continuous aggregate, then constrain the background jobs that cross both.

Step 1 — Enable and force RLS on the base hypertable

RLS must be enabled to activate policy evaluation, and it must be forced if the same role that owns the table ever runs interactive queries. Without FORCE ROW LEVEL SECURITY, the owner silently bypasses every policy. Wrap the DDL in idempotent guards so the migration is safe to re-run.

sql
-- Idempotent RLS activation on the raw telemetry hypertable
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_tables
    WHERE tablename = 'sensor_readings' AND rowsecurity = true
  ) THEN
    ALTER TABLE sensor_readings ENABLE ROW LEVEL SECURITY;
    ALTER TABLE sensor_readings FORCE ROW LEVEL SECURITY;
  END IF;
END $$;

Enabling RLS on the parent hypertable is sufficient — TimescaleDB applies the policy to every existing and future chunk automatically, so you never grant on or attach policies to individual chunks.

Step 2 — Create a tenant-isolation policy

PostgreSQL has no CREATE POLICY IF NOT EXISTS, so guard creation with a pg_policies existence check. Include both a USING clause (which rows are visible on read) and a WITH CHECK clause (which rows an insert or update may produce) so a tenant cannot write outside its own scope.

sql
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_policies
    WHERE tablename = 'sensor_readings' AND policyname = 'tenant_isolation_policy'
  ) THEN
    CREATE POLICY tenant_isolation_policy ON sensor_readings
      FOR ALL
      USING (current_setting('app.current_tenant_id', true)::uuid = tenant_id)
      WITH CHECK (current_setting('app.current_tenant_id', true)::uuid = tenant_id);
  END IF;
END $$;

The second argument true to current_setting makes an unset GUC return NULL instead of raising, so an unauthenticated session sees zero rows rather than erroring — fail-closed by construction.

Step 3 — Materialize the continuous aggregate and understand what it does not inherit

Define the rollup as usual. The critical fact is that RLS policies on sensor_readings are not inherited by the aggregate: querying sensor_readings_1h never evaluates the base table’s policies, because the aggregate reads from its own materialized internal hypertable. This is the same materialization model described under materialized view architecture and syntax.

sql
CREATE MATERIALIZED VIEW IF NOT EXISTS sensor_readings_1h
WITH (timescaledb.continuous) AS
SELECT
  time_bucket('1 hour', time) AS bucket,
  device_id,
  tenant_id,
  AVG(temperature) AS avg_temp,
  COUNT(*)         AS readings_count
FROM sensor_readings
GROUP BY 1, 2, 3
WITH NO DATA;

Always keep tenant_id in the GROUP BY — if it is dropped, the aggregate becomes physically un-isolable and no downstream policy can reconstruct tenant scope.

Step 4 — Apply a second RLS policy on the aggregate itself

Because inheritance does not happen, the aggregate needs its own policy for tenant-scoped reads. Enable and force RLS on the continuous aggregate view exactly as you did on the base table.

sql
ALTER MATERIALIZED VIEW sensor_readings_1h ENABLE ROW LEVEL SECURITY;
ALTER MATERIALIZED VIEW sensor_readings_1h FORCE ROW LEVEL SECURITY;

CREATE POLICY cagg_tenant_isolation ON sensor_readings_1h
  FOR SELECT
  USING (current_setting('app.current_tenant_id', true)::uuid = tenant_id);

If your TimescaleDB version rejects policies directly on the view object, expose the aggregate through a per-tenant security-barrier view (CREATE VIEW ... WITH (security_barrier)) and grant SELECT on the view instead of the aggregate.

Step 5 — Attach the refresh policy under a constrained owner

The refresh job runs as the aggregate’s owner and bypasses RLS by design — that is intentional, because materialization must see all tenants’ rows to build the rollup. The boundary you control is who owns the aggregate: make it the dedicated owner role, never a superuser and never the application role. Attach the continuous aggregate refresh policy idempotently.

sql
SELECT add_continuous_aggregate_policy('sensor_readings_1h',
  start_offset      => INTERVAL '3 hours',
  end_offset        => INTERVAL '1 hour',
  schedule_interval => INTERVAL '1 hour',
  if_not_exists     => TRUE);

Step 6 — Grant read access to the application role only

Finish by granting SELECT on the aggregate (and the base table, if direct access is needed) to the application role, and nothing more. The application role owns no objects, so it can never bypass the policies you just wrote.

sql
GRANT SELECT ON sensor_readings     TO app_reader;
GRANT SELECT ON sensor_readings_1h  TO app_reader;
Defense-in-depth layers around tenant data and the owner-run worker that pierces them Tenant rows sit at the centre, protected by three nested layers: an inner physical layer of time and space-partitioned chunks, a middle row-level-security layer keyed on app.current_tenant_id, and an outer role-privilege layer that grants SELECT to app_reader only. The application role owns nothing and is confined to the outer layer, while a background worker running as the constrained owner role bypasses row-level security to reach the data during refresh and retention. Layer 1 · Role privileges GRANT SELECT to app_reader — which owns nothing Layer 2 · Row-level security USING / WITH CHECK on app.current_tenant_id Tenant rows Layer 3 · time + space partitioned chunks Background worker refresh · retention runs as owner role bypasses RLS Only the owner-run worker pierces RLS; app_reader stays in Layer 1.

Configuration Parameters Reference

Setting Type Recommended value Effect on the boundary
ENABLE ROW LEVEL SECURITY table/view DDL on for every tenant object Activates policy evaluation for non-owner roles; without it policies are inert
FORCE ROW LEVEL SECURITY table/view DDL on Applies policies to the object owner too, closing the owner-bypass hole
Policy USING clause predicate current_setting(...) = tenant_id Filters rows returned by SELECT/UPDATE/DELETE
Policy WITH CHECK clause predicate same as USING Rejects INSERT/UPDATE rows that fall outside the tenant scope
app.current_tenant_id custom GUC set per checkout via set_config Supplies tenant context to the policy; unset ⇒ fail-closed
Job/procedure security mode SECURITY INVOKER | DEFINER INVOKER for tenant-scoped jobs Controls whose privileges a background job runs under
Continuous aggregate owner role dedicated non-superuser owner Determines which identity bypasses RLS during refresh
GRANT SELECT on objects privilege app role only Separates read access from materialization ownership

Treat the owner role as a privileged identity: anything it owns is exempt from that object’s RLS unless FORCE is set, so keep its footprint small and never connect application traffic through it.

Integration With Adjacent Lifecycle Features

Access control cannot be reasoned about in isolation from the rest of the lifecycle, because compression and retention both run as background jobs that cross the tenant boundary.

Compression is a per-chunk operation. When a chunk converts to columnar compression models for high-frequency telemetry, the RLS policy on the parent hypertable still applies to reads of that chunk — compression changes the physical layout, not the policy surface — so no additional access rule is needed for compressed data. The compression policy job, however, runs as the owner and must retain ownership of every chunk it touches; revoking owner privileges to “lock down” a table will stall the compression job.

Retention behaves differently and is the sharper edge. drop_chunks and TTL policy mapping and enforcement operate on chunk metadata in the TimescaleDB catalog, not on rows, so they do not evaluate RLS at all. A retention job drops an entire chunk regardless of tenant. This is safe only because chunks are time-aligned rather than tenant-aligned — but it means you cannot express “retain tenant A for 90 days, tenant B for 30 days” through RLS. True per-tenant retention requires physically separating tenants into their own chunks, which is exactly what configuring space partitions for multi-tenant time series enables, so retention can target a tenant’s chunks without scanning the others. Index design on those partitions, covered in best practices for chunk indexing on high-cardinality tags, also affects how efficiently the RLS predicate can prune, since the policy adds a tenant_id filter to every plan.

For Python automation builders, enforcement should be idempotent and set the tenant context explicitly on every connection. The snippet below verifies RLS state, sets the session tenant GUC safely, and applies a retention window without blocking application threads.

python
import logging
from datetime import timedelta

import psycopg
from psycopg.rows import dict_row

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def enforce_boundary(
    conn_str: str,
    tenant_id: str,
    retention_days: int = 30,
    hypertable: str = "sensor_readings",
) -> None:
    """Idempotently verify RLS and apply a retention window for one hypertable."""
    try:
        with psycopg.connect(conn_str, row_factory=dict_row) as conn:
            # 1. Set tenant context. SET does not accept bind parameters, so use
            #    set_config() to parameterize the value safely against injection.
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT set_config('app.current_tenant_id', %s, false)",
                    (tenant_id,),
                )

            # 2. Confirm RLS is active on the target hypertable.
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT relrowsecurity, relforcerowsecurity
                    FROM pg_class
                    WHERE oid = %s::regclass
                    """,
                    (hypertable,),
                )
                row = cur.fetchone()
                if row and not (row["relrowsecurity"] and row["relforcerowsecurity"]):
                    logger.warning("RLS not fully enforced on %s", hypertable)

            # 3. Apply an idempotent retention policy. Pass the interval as a real
            #    parameter; %s cannot be substituted inside a quoted SQL literal.
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT add_retention_policy(
                        %s,
                        drop_after   => %s,
                        if_not_exists => TRUE
                    )
                    """,
                    (hypertable, timedelta(days=retention_days)),
                )
            conn.commit()
            logger.info("Boundary verified for tenant %s", tenant_id)
    except psycopg.Error:
        logger.exception("Database error during boundary enforcement")
        raise

Performance Validation

Confirm the boundary is actually in force by querying the catalog rather than trusting the migration ran. First, verify that both RLS flags are set on every tenant object.

sql
-- relrowsecurity = RLS enabled; relforcerowsecurity = applies to owner too
SELECT c.relname,
       c.relrowsecurity      AS rls_enabled,
       c.relforcerowsecurity AS rls_forced
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
  AND c.relname IN ('sensor_readings', 'sensor_readings_1h');

Next, list the active policies and their predicates so a review can confirm the tenant key is present in both USING and WITH CHECK.

sql
SELECT tablename, policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename IN ('sensor_readings', 'sensor_readings_1h');

Finally, confirm which role owns the refresh and retention jobs — this is the identity that bypasses RLS, and it should be your dedicated owner, never a superuser.

sql
SELECT job_id, application_name, proc_name, owner, hypertable_name
FROM timescaledb_information.jobs
WHERE proc_name IN ('policy_refresh_continuous_aggregate', 'policy_retention');

To prove isolation empirically, set the GUC to a known tenant and check that a count only ever returns that tenant’s rows: SELECT set_config('app.current_tenant_id', '...', false); SELECT count(DISTINCT tenant_id) FROM sensor_readings; should return 1 for any authenticated tenant and 0 when the GUC is unset.

Troubleshooting

new row violates row-level security policy for table "sensor_readings" — An INSERT or UPDATE produced a row whose tenant_id does not match app.current_tenant_id, or the GUC is unset so the WITH CHECK predicate evaluated to NULL. Set the tenant context with set_config('app.current_tenant_id', <uuid>, false) before writing, and confirm the payload’s tenant_id matches.

Owner still sees all tenants’ rows — RLS is enabled but not forced. The object owner bypasses policies until you run ALTER TABLE ... FORCE ROW LEVEL SECURITY. Verify with the relforcerowsecurity column from the validation query above.

Querying the continuous aggregate returns cross-tenant rows — Base-table policies are not inherited by the aggregate. Apply a separate policy to sensor_readings_1h (Step 4) or front it with a per-tenant security-barrier view.

ERROR: must be owner of table sensor_readings when creating a policy or job — The connected role is not the object owner. Run policy and add_continuous_aggregate_policy DDL as the dedicated owner role, and reserve the application role for SELECT only.

unrecognized configuration parameter "app.current_tenant_id" — The GUC was read with the strict single-argument form of current_setting before it was ever set. Always use the two-argument form current_setting('app.current_tenant_id', true) in policy predicates so a missing setting returns NULL and fails closed.

Frequently Asked Questions

Does adding RLS slow down continuous aggregate refresh?

No. The refresh job runs as the aggregate owner and bypasses RLS entirely, so the materialization path is unaffected. RLS only adds a tenant_id predicate to interactive read queries; keep that column indexed on your space partitions so the planner can prune efficiently.

Can I put an RLS policy directly on a continuous aggregate?

On recent TimescaleDB versions you can enable and force RLS on the aggregate view and attach a SELECT policy, as in Step 4. If your version rejects it, expose the aggregate through a security_barrier view that carries the tenant predicate and grant SELECT on that view instead.

What role do TimescaleDB background workers run as?

Refresh, compression, and retention jobs execute as the owner of the object the policy is attached to — not as the user who scheduled them or the user querying the data. That owner bypasses the object’s RLS, which is why the owner role must be a constrained, non-superuser identity you treat as privileged.

Is space partitioning by tenant enough to isolate data without RLS?

No. Space partitioning physically segregates tenants into separate chunks, which is valuable for per-tenant retention and pruning, but a role with SELECT on the hypertable can still read every chunk. Physical segregation and RLS are complementary layers: use partitioning for lifecycle control and RLS for query-time access control.

Do drop_chunks and retention policies respect RLS?

No. drop_chunks and retention operate on chunk metadata in the catalog, not on rows, so they never evaluate RLS. They drop whole time-aligned chunks regardless of tenant. Per-tenant retention windows therefore require tenant-aligned chunks via space partitioning, not RLS.

← Back to Core Hypertable Architecture & Partitioning Strategy