Skip to content

Multi-tenant isolation

Two tenants share one database. One row filter limits each API key to the rows of its tenant.

Example

Start the server with examples/multi-tenant/serve.sh. Each tenant has a subject binding. For API keys, the claims of a binding are fixed values.

# One binding per tenant. The tenantId claim feeds the row filter.
apiVersion: sluice.bino.bi/v1alpha1
kind: SubjectBinding
metadata:
  name: tenant-acme
spec:
  claims:
    subjectId: "acme-analyst"
    tenantId: "acme"
  apiKeys:
    - id: "sl_demo_acme"
      hashRef: "secret://env/SLUICE_APIKEY_ACME_HASH"
      groups: ["analytics"]
---
apiVersion: sluice.bino.bi/v1alpha1
kind: SubjectBinding
metadata:
  name: tenant-beta
spec:
  claims:
    subjectId: "beta-analyst"
    tenantId: "beta"
  apiKeys:
    - id: "sl_demo_beta"
      hashRef: "secret://env/SLUICE_APIKEY_BETA_HASH"
      groups: ["analytics"]

The row filter compares tenant_id with the tenantId claim.

apiVersion: sluice.bino.bi/v1alpha1
kind: RowFilterPolicy
metadata:
  name: filter-tenant
  priority: 80
spec:
  match:
    any:
      - resources:
          catalogs: ["shop"]
          schemas: ["main"]
          tables: ["customers", "orders"]
  combine: restrictive
  filter:
    predicate:
      column: tenant_id
      op: Equals
      value: "{{ subject.tenantId }}"

The acme key reads the customers.

curl -s http://localhost:8080/v1/query \
  -H "X-Api-Key: sl_demo_acme.supersecret" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT id, tenant_id, email FROM shop.main.customers ORDER BY id"}'
{
  "query_id": "<query-id>",
  "columns": ["id", "tenant_id", "email"],
  "rows": [
    [1, "acme", "a1@acme.example"],
    [2, "acme", "a2@acme.example"]
  ],
  "row_count": 2,
  "truncated": false
}

The beta key sends the same SQL.

curl -s http://localhost:8080/v1/query \
  -H "X-Api-Key: sl_demo_beta.supersecret" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT id, tenant_id, email FROM shop.main.customers ORDER BY id"}'
{
  "query_id": "<query-id>",
  "columns": ["id", "tenant_id", "email"],
  "rows": [
    [3, "beta", "b1@beta.example"],
    [4, "beta", "b2@beta.example"]
  ],
  "row_count": 2,
  "truncated": false
}

What happened: Sluice put the filter around the table and bound the claim value as parameter $1. The data also holds a tenant gamma. No binding has that claim, so no client reads those rows.

This suite case shows the rewritten SQL. sluice policy test policies.d --tests tests runs the suite without a server.

- name: acme reads only acme customers
  identity: { subject: acme-analyst, groups: [analytics], claims: { tenantId: acme } }
  sql: "SELECT id, email FROM shop.main.customers"
  expect:
    outcome: allow
    filters: ["shop.main.customers"]
    rewrittenSql: >-
      SELECT id, email
      FROM (SELECT * FROM shop.main.customers sluice_src WHERE sluice_src.tenant_id = $1) customers

More examples

A join filters both tables.

curl -s http://localhost:8080/v1/query \
  -H "X-Api-Key: sl_demo_acme.supersecret" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT c.email, o.id, o.amount_cents FROM shop.main.customers c JOIN shop.main.orders o ON o.customer_id = c.id ORDER BY o.id"}'
{
  "query_id": "<query-id>",
  "columns": ["email", "id", "amount_cents"],
  "rows": [
    ["a1@acme.example", 10, 1999],
    ["a2@acme.example", 11, 4599]
  ],
  "row_count": 2,
  "truncated": false
}

A subquery is filtered too. The table holds five orders. The acme key counts two.

curl -s http://localhost:8080/v1/query \
  -H "X-Api-Key: sl_demo_acme.supersecret" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT (SELECT count(*) FROM shop.main.orders) AS orders_visible"}'
{
  "query_id": "<query-id>",
  "columns": ["orders_visible"],
  "rows": [
    [2]
  ],
  "row_count": 1,
  "truncated": false
}

A WHERE clause cannot widen the filter. The acme key asks for beta rows and gets none.

curl -s http://localhost:8080/v1/query \
  -H "X-Api-Key: sl_demo_acme.supersecret" \
  -H "Content-Type: application/json" \
  -d @- <<'EOF'
{"sql": "SELECT id, tenant_id FROM shop.main.orders WHERE tenant_id = 'beta'"}
EOF
{
  "query_id": "<query-id>",
  "columns": ["id", "tenant_id"],
  "rows": [],
  "row_count": 0,
  "truncated": false
}

Limits

  • A subject without the tenantId claim gets ACL_DENIED and no rows.

See also