Skip to content

RBAC — Role-Based Access Control

AKKO implements a 4-layer security model that enforces access control from authentication through query execution:

Keycloak (authentication) → OPA (authorization) → Trino (query enforcement) → Superset (dashboard filtering)

Every user authenticates via Keycloak SSO, receives role claims in their JWT token, and those roles are enforced by OPA policies in Trino, role mappings in Superset/Airflow/Dashboards, and network-level gating via OAuth2-Proxy for services without native OIDC.

Kubernetes-first

All RBAC configuration is deployed via Helm. Keycloak realm is imported from realm-akko-k3d.json, OPA policies from a ConfigMap, and Trino rules from mounted files. No manual steps required after helm install.


Roles

AKKO defines five platform-wide realm roles in Keycloak:

Role Description Persona Access Level
akko-admin Platform administrator Alice Chen -- SRE / platform owner Full access to all services, admin consoles, DDL/DML, no masking
akko-engineer Data engineer Bob Martin -- builds pipelines CREATE/INSERT/SELECT on Iceberg, pipeline management, no PII masking
akko-analyst Senior data analyst Carol Santos -- explores data SELECT-only, full PII visibility, dashboards, notebooks
akko-user Standard user / compliance Eve Dupont -- audits data SELECT-only, PII columns masked (email, phone, SSN, date of birth)
akko-viewer Executive / dashboard viewer Dave Kim -- views dashboards SELECT on limited schemas, row-filtered, PII masked

Default role

New users are automatically assigned akko-analyst via the default-roles-akko composite role. This includes offline_access and uma_authorization.


Access Matrix

Service-Level Access

Service akko-admin akko-engineer akko-analyst akko-user akko-viewer
Keycloak (admin console) Full -- -- -- --
Trino (SQL) Full DDL/DML DDL/DML on designated schemas SELECT only SELECT, PII masked SELECT, filtered + masked
Superset Admin role Alpha role Gamma role Gamma role Public role
Airflow Admin User Viewer Viewer Viewer
Dashboards Admin Editor Viewer Viewer Viewer
JupyterHub Admin panel + spawn Spawn Spawn Spawn Spawn
MLflow Full Full Full Full Full
LiteLLM Full (master key) Full Full Full Full
object storage Root Root Root Root Root
OpenMetadata Admin Browse + edit Browse + edit Browse Browse

Services without fine-grained RBAC

MLflow, LiteLLM, object storage, and Spark currently use shared credentials. Access is gated at the network level (OAuth2-Proxy for MLflow, master key for LiteLLM, root credentials for object storage). Per-user RBAC for these services is planned.

Trino Catalog Access

Role Iceberg PostgreSQL System
akko-admin all (owner) all (owner) all
akko-engineer all (DDL on raw, staging, analytics, sandbox) all (DDL) read-only
akko-analyst read-only (all schemas) read-only read-only
akko-user read-only (all schemas, PII masked) read-only read-only
akko-viewer read-only (analytics, reporting, public only) -- read-only
(default) -- -- read-only

Trino Table Privileges (Iceberg)

Role Schemas Privileges
akko-admin all SELECT, INSERT, DELETE, UPDATE, OWNERSHIP, GRANT_SELECT
akko-engineer raw, staging, analytics, sandbox SELECT, INSERT, DELETE, UPDATE
akko-analyst all SELECT
akko-user all SELECT (PII masked)
akko-viewer analytics, reporting, public SELECT (row-filtered, PII masked)

Keycloak Configuration

Realm Structure

All AKKO identity configuration lives in a single Keycloak realm named akko, imported from realm-akko-k3d.json on first startup.

Key settings:

Setting Value
Realm name akko
Login theme akko (custom)
Access token lifespan 300s (5 min)
SSO session max 36000s (10 hours)
Registration Disabled
Brute force protection Enabled (5 failures, 5 min lockout)

Role Definition

Roles are defined as realm roles (not client roles). Each role maps to a Keycloak group claim via the groups protocol mapper, which emits role names into the JWT groups array.

The groups mapper is configured in the microprofile-jwt and service-specific client scopes:

{
  "name": "groups",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-usermodel-realm-role-mapper",
  "config": {
    "multivalued": "true",
    "claim.name": "groups",
    "id.token.claim": "true",
    "access.token.claim": "true"
  }
}

This ensures that when a user logs in, their roles appear in the JWT token as groups, which OPA, Trino, and other services use for authorization decisions.

OAuth2 Clients

13 OAuth2 clients are pre-configured in the realm:

Client Type Maps Roles
jupyterhub Confidential Yes (admin flag)
superset Confidential Yes (Admin/Alpha/Gamma/Public)
airflow Confidential Yes (Admin/User/Viewer)
grafana Confidential Yes (Admin/Editor/Viewer)
trino Confidential Yes (via OPA groups)
openmetadata Confidential Partial
polaris Confidential No
oauth2-proxy Confidential No (network gate)
minio Confidential No
account Public No (Keycloak internal)
account-console Public (PKCE) No (Keycloak internal)
admin-cli Public No (Keycloak internal)
broker Confidential No (Keycloak internal)
realm-management Confidential No (Keycloak internal)
security-admin-console Public No (Keycloak internal)

No cockpit client

The AKKO cockpit uses the Keycloak JavaScript adapter with the account or account-console public clients for session detection. It does not have its own dedicated OAuth2 client in the realm.

Assigning a User to a Role

  1. Open the Keycloak admin console: https://identity.akko.local/admin/
  2. Select the akko realm
  3. Navigate to Users and select the user (or create a new one)
  4. Go to the Role mapping tab
  5. Click Assign role and filter by realm roles
  6. Select the desired role (akko-admin, akko-engineer, akko-analyst, akko-user, or akko-viewer)

emailVerified must be true

All users must have emailVerified=true. The oauth2-proxy service rejects users without a verified email, causing login failures for JupyterHub and other proxied services.


OPA Policies

OPA (Open Policy Agent) provides policy-as-code authorization for Trino. Policies are written in Rego and deployed as a ConfigMap.

Architecture

User query → Trino → OPA (Rego evaluation) → Allow / Deny / Filter / Mask

Trino delegates three types of decisions to OPA:

  1. Authorization -- can this user execute this operation?
  2. Row filtering -- which rows should this user see?
  3. Column masking -- should sensitive columns be redacted?

Trino-OPA Integration

Trino is configured to call OPA for every query:

access-control.name=opa
opa.policy.uri=http://akko-akko-opa:8181/v1/data/trino/allow
opa.policy.row-filters-uri=http://akko-akko-opa:8181/v1/data/trino/rowFilters
opa.policy.column-masking-uri=http://akko-akko-opa:8181/v1/data/trino/columnMask

Access Control Policy

The main policy (trino_access.rego) maps operations to roles. This is the actual policy deployed via ConfigMap at helm/akko/charts/akko-opa/templates/configmap.yaml:

package trino
import rego.v1

default allow := false

# Admins can do everything
allow if {
    "akko-admin" in input.context.identity.groups
}

# Engineers: SELECT, INSERT, CREATE TABLE, DROP TABLE, ALTER
allow if {
    "akko-engineer" in input.context.identity.groups
    input.action.operation in [
        "ExecuteQuery", "AccessCatalog", "FilterCatalogs",
        "FilterSchemas", "FilterTables", "FilterColumns",
        "SelectFromColumns", "ShowSchemas", "ShowTables",
        "ShowColumns", "ShowCreateTable", "ShowFunctions",
        "ShowStats", "CreateTable", "InsertIntoTable",
        "AddColumn", "DropTable", "RenameTable"
    ]
}

# Analysts (senior): SELECT only, full data visibility (no masking)
allow if {
    "akko-analyst" in input.context.identity.groups
    input.action.operation in [
        "ExecuteQuery", "AccessCatalog", "FilterCatalogs",
        "FilterSchemas", "FilterTables", "FilterColumns",
        "SelectFromColumns", "ShowSchemas", "ShowTables",
        "ShowColumns", "ShowCreateTable", "ShowFunctions",
        "ShowStats"
    ]
}

# Standard users (compliance): SELECT only, PII masked (see column_masking.rego)
allow if {
    "akko-user" in input.context.identity.groups
    input.action.operation in [
        "ExecuteQuery", "AccessCatalog", "FilterCatalogs",
        "FilterSchemas", "FilterTables", "FilterColumns",
        "SelectFromColumns", "ShowSchemas", "ShowTables",
        "ShowColumns", "ShowCreateTable", "ShowFunctions",
        "ShowStats"
    ]
}

# Viewers (executives): SELECT + metadata, row-filtered + PII masked
allow if {
    "akko-viewer" in input.context.identity.groups
    input.action.operation in [
        "ExecuteQuery", "AccessCatalog", "FilterCatalogs",
        "FilterSchemas", "FilterTables", "FilterColumns",
        "SelectFromColumns", "ShowSchemas", "ShowTables",
        "ShowColumns", "ShowCreateTable", "ShowFunctions",
        "ShowStats"
    ]
}

# Row-filter identity: Trino re-checks permissions using the filter identity
allow if {
    input.context.identity.user == "viewer_active_only"
}

Column Masking Policy

PII columns are masked for akko-user and akko-viewer roles. Admins, engineers, and analysts see data in clear:

# Text PII columns: email, phone, ssn, medical_record_number
columnMask := {"expression": expression, "identity": identity} if {
    input.action.resource.column.columnName in [
        "email", "phone", "ssn", "medical_record_number"
    ]
    not "akko-admin" in input.context.identity.groups
    not "akko-engineer" in input.context.identity.groups
    not "akko-analyst" in input.context.identity.groups
    expression := "'***MASKED***'"
    identity := "mask_pii"
}

# Date PII columns: date_of_birth (type-safe NULL)
columnMask := {"expression": expression, "identity": identity} if {
    input.action.resource.column.columnName in ["date_of_birth"]
    not "akko-admin" in input.context.identity.groups
    not "akko-engineer" in input.context.identity.groups
    not "akko-analyst" in input.context.identity.groups
    expression := "CAST(NULL AS DATE)"
    identity := "mask_pii"
}

Masking summary:

Column admin engineer analyst user viewer
email clear clear clear ***MASKED*** ***MASKED***
phone clear clear clear ***MASKED*** ***MASKED***
ssn clear clear clear ***MASKED*** ***MASKED***
medical_record_number clear clear clear ***MASKED*** ***MASKED***
date_of_birth clear clear clear NULL NULL

Row-Level Filtering Policy

Row filters restrict which rows a role can see:

# Viewers can only see active accounts
rowFilters contains {"expression": expression, "identity": identity} if {
    "akko-viewer" in input.context.identity.groups
    input.action.resource.table.tableName == "accounts"
    expression := "status = 'active'"
    identity := "viewer_active_only"
}

Currently, only the accounts table has a row filter for akko-viewer. Additional filters can be added per table as needed.

Adding a New OPA Policy

OPA policies are defined inline in the ConfigMap template at helm/akko/charts/akko-opa/templates/configmap.yaml. The ConfigMap contains three Rego files:

File Purpose
trino_access.rego Operation-level allow/deny rules per role
column_masking.rego PII column masking rules
row_filter.rego Row-level security filters

Step-by-step: Add a new access rule

  1. Open helm/akko/charts/akko-opa/templates/configmap.yaml
  2. Add your Rego rule in the appropriate .rego section. For example, to add row filtering on the transactions table for viewers:

    # In the row_filter.rego section:
    rowFilters contains {"expression": expression, "identity": identity} if {
        "akko-viewer" in input.context.identity.groups
        input.action.resource.table.tableName == "transactions"
        expression := "amount < 10000"
        identity := "viewer_small_txn_only"
    }
    
  3. If the row filter uses a custom identity (like viewer_small_txn_only), add a corresponding allow rule in trino_access.rego:

    allow if {
        input.context.identity.user == "viewer_small_txn_only"
    }
    
  4. Deploy the updated policy:

    helm upgrade akko helm/akko/ -n akko \
      -f helm/examples/values-dev.yaml \
      --set-file akko-keycloak.realm.data=helm/examples/realm-akko-k3d.json
    
  5. OPA hot-reloads policies from the ConfigMap -- no pod restart is needed. Verify the policy is loaded:

    # Check OPA has the updated policy
    kubectl exec -n akko deploy/akko-akko-opa -- \
      curl -s http://localhost:8181/v1/policies | python3 -m json.tool
    
    # Test the policy with a sample input
    kubectl exec -n akko deploy/akko-akko-opa -- \
      curl -s -X POST http://localhost:8181/v1/data/trino/allow \
      -d '{"input":{"context":{"identity":{"user":"dave","groups":["akko-viewer"]}},"action":{"operation":"SelectFromColumns","resource":{"table":{"catalogName":"iceberg","schemaName":"banking","tableName":"transactions"}}}}}' \
      | python3 -m json.tool
    

Testing policies locally

Before deploying, test Rego syntax locally:

# Save the policy to a file and test with opa eval
opa eval -i input.json -d policy.rego "data.trino.allow"
# Or check syntax only
opa check policy.rego


Trino Enforcement

In addition to OPA, Trino uses file-based access control for catalog/schema/table permissions. Both systems work together:

  • OPA handles operation-level authorization (allow/deny), column masking, and row filtering
  • File-based rules (rules.json) enforce catalog/schema/table-level access with fine-grained privileges

Group Mappings

Trino maps users to groups via group.txt:

akko-admin:admin,alice,trino,airflow
akko-engineer:bob
akko-analyst:carol
akko-user:eve
akko-viewer:dave

Service users

The trino and airflow users must be in the akko-admin group. Trino uses trino for internal operations, and Airflow connects as airflow to run pipeline queries.

Standard user group

The akko-user group maps to Eve (compliance / standard user). When OPA evaluates column masking rules, it checks the groups claim in the JWT -- the Trino group.txt file is used for file-based access control rules, not OPA decisions.

Rules Evaluation

Rules in rules.json are evaluated top-to-bottom, first match wins. The file has three sections: catalogs, schemas, and tables. A catch-all deny rule at the end blocks unmatched access:

{ "catalog": ".*", "allow": "none" }

Refresh Period

Both rules.json and group.txt are refreshed every 5 minutes. Changes take effect without a Trino restart.


Superset Row-Level Security

Superset maps Keycloak roles to its internal roles:

Keycloak Role Superset Role Capabilities
akko-admin Admin All dashboards, SQL Lab, datasets, admin settings
akko-engineer Alpha Create dashboards, SQL Lab, datasets
akko-analyst Gamma View dashboards, SQL Lab, view datasets
akko-user Gamma View dashboards, SQL Lab, view datasets
akko-viewer Public View public dashboards only, no SQL Lab

Superset connects to Trino using the trino service user (which has akko-admin privileges). This means that OPA column masking and row filtering are not applied at the Superset-to-Trino level. Instead, Superset enforces access via its own role system:

  • Dashboard visibility -- Public role can only see dashboards explicitly marked as published
  • SQL Lab -- only Admin, Alpha, and Gamma roles have SQL Lab access
  • Dataset access -- datasets are gated by Superset role permissions

End-to-end enforcement

For full per-user data filtering through Superset, the recommended approach is to configure Superset to pass the actual user identity to Trino (instead of the shared trino user), so OPA policies apply to dashboard queries. This is planned for a future release.


Managing Users

Adding a New User (Web UI)

  1. Open the Keycloak admin console at https://identity.akko.local/admin/
  2. Log in with the admin credentials (see values-dev.yaml for dev password, or global.auth.keycloakAdminPassword for production)
  3. Select the akko realm (top-left dropdown)
  4. Navigate to Users > Add user
  5. Fill in the required fields:
    • Username (required, lowercase, no spaces)
    • Email (required -- OAuth2-Proxy rejects users without email)
    • First name, Last name
    • Toggle Email verified to ON
  6. Click Create
  7. Go to the Credentials tab:
    • Click Set password
    • Enter the password and confirm
    • Toggle Temporary to OFF for service accounts or test users
    • Click Save
  8. Go to the Role mapping tab:
    • Click Assign role
    • In the filter dropdown, select Filter by realm roles
    • Select the desired role (akko-admin, akko-engineer, akko-analyst, akko-user, or akko-viewer)
    • Click Assign

Adding a New User (Keycloak Admin API)

For automation or CI/CD pipelines, use the Keycloak Admin REST API:

# 1. Get an admin token
ADMIN_TOKEN=$(kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X POST http://localhost:8080/realms/master/protocol/openid-connect/token \
  -d "client_id=admin-cli&grant_type=password&username=admin&password=<admin-password>" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# 2. Create the user
kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X POST http://localhost:8080/admin/realms/akko/users \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "newuser",
    "email": "newuser@company.com",
    "firstName": "New",
    "lastName": "User",
    "enabled": true,
    "emailVerified": true,
    "credentials": [{"type": "password", "value": "strong-password", "temporary": false}]
  }'

# 3. Get the user ID
USER_ID=$(kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s http://localhost:8080/admin/realms/akko/users?username=newuser \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")

# 4. Get the role ID (e.g., akko-analyst)
ROLE_ID=$(kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s http://localhost:8080/admin/realms/akko/roles/akko-analyst \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  | python3 -c "import sys,json; r=json.load(sys.stdin); print(r['id'])")

# 5. Assign the role
kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X POST \
  "http://localhost:8080/admin/realms/akko/users/$USER_ID/role-mappings/realm" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d "[{\"id\": \"$ROLE_ID\", \"name\": \"akko-analyst\"}]"

Port-forward alternative

If you prefer to run commands from your local machine instead of kubectl exec, port-forward the Keycloak pod first:

kubectl port-forward -n akko svc/akko-keycloak 8080:8080 &
# Then replace "kubectl exec ... curl" with just "curl http://localhost:8080/..."

Verifying Access

After assigning a role, verify the user's token contains the correct claims:

# Get a token for the user (using the JupyterHub client, which has the groups mapper)
TOKEN=$(kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X POST http://localhost:8080/realms/akko/protocol/openid-connect/token \
  -d "grant_type=password&client_id=jupyterhub&client_secret=akko-dev-jupyterhub-oidc&username=alice&password=alice123&scope=openid" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# Decode the JWT and check the groups claim
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# Expected output includes: "groups": ["akko-admin", "default-roles-akko", ...]

What to check in the JWT

  • The groups array must contain the assigned role name (e.g., akko-admin)
  • The email_verified field must be true
  • The preferred_username must match the username
  • The iss (issuer) must be https://identity.akko.local/realms/akko

Revoking Access

Remove a role from a user (Web UI)

  1. Open https://identity.akko.local/admin/ and select the akko realm
  2. Navigate to Users and select the user
  3. Go to the Role mapping tab
  4. Find the role to remove and click the X (unassign) button next to it
  5. The user must log out and log back in for the change to take effect (JWT tokens are valid until they expire -- 5 minutes by default)

Remove a role from a user (Admin API)

# Remove akko-engineer role from a user
kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X DELETE \
  "http://localhost:8080/admin/realms/akko/users/$USER_ID/role-mappings/realm" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d "[{\"id\": \"$ROLE_ID\", \"name\": \"akko-engineer\"}]"

Disable a user entirely

  1. Web UI: Navigate to the user, toggle Enabled to OFF, click Save
  2. Admin API:
    kubectl exec -n akko deploy/akko-keycloak -- \
      curl -s -X PUT \
      "http://localhost:8080/admin/realms/akko/users/$USER_ID" \
      -H "Authorization: Bearer $ADMIN_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"enabled": false}'
    

Force logout (invalidate active sessions)

# Logout all sessions for a specific user
kubectl exec -n akko deploy/akko-keycloak -- \
  curl -s -X POST \
  "http://localhost:8080/admin/realms/akko/users/$USER_ID/logout" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Token expiry delay

Revoking a role or disabling a user does not immediately block access. Existing JWT tokens remain valid until they expire (default: 5 minutes). Services that cache tokens (Superset, Dashboards) may require the user to log out and back in.

Adding a User to Trino File-Based Groups

If Trino uses file-based access control in addition to OPA, new users must be added to group.txt:

  1. Edit the Trino group file in the Helm chart (the exact location depends on your chart structure)
  2. Add the username to the appropriate group line:
    akko-analyst:carol,newuser
    
  3. Upgrade the Helm release:
    helm upgrade akko helm/akko/ -n akko \
      -f helm/examples/values-dev.yaml \
      --set-file akko-keycloak.realm.data=helm/examples/realm-akko-k3d.json
    
  4. Wait up to 5 minutes for Trino to reload the file (or restart the Trino coordinator pod)

Default Test Users

Username Role Password Email Full Name
alice akko-admin alice123 alice@akko.local Alice Chen
bob akko-engineer bob123 bob@akko.local Bob Martin
carol akko-analyst carol123 carol@akko.local Carol Santos
eve akko-user eve123 eve@akko.local Eve Dupont
dave akko-viewer dave123 dave@akko.local Dave Kim

All test users have emailVerified=true and enabled=true. Alice also has the realm-admin client role, giving her access to the Keycloak admin console.

Test passwords

In the development realm (realm-akko-k3d.json), passwords follow the pattern <username>123. Never use these credentials in production. Use strong passwords, external identity providers (LDAP, SAML), or Keycloak's password policies.


Troubleshooting

User Cannot Access a Service

  1. Check the JWT token -- verify the groups claim contains the expected role
  2. Check emailVerified -- must be true for OAuth2-Proxy-gated services
  3. Check Keycloak session -- the user may have an old session with stale role claims. Log out and back in.

Wrong Permissions in Trino

  1. Check group.txt -- verify the user is in the correct Trino group
  2. Check OPA logs -- kubectl logs -n akko deploy/akko-akko-opa to see policy evaluations
  3. Check rules.json -- rules are first-match-wins; a rule higher in the list may be matching first
  4. Wait for refresh -- Trino refreshes group and rule files every 5 minutes

Column Masking Not Working

  1. Verify the column name matches one in the OPA policy (email, phone, ssn, medical_record_number, date_of_birth)
  2. Check the user's role -- only akko-user and akko-viewer see masked values
  3. Test via CLI -- use curl to query Trino directly with the user's token to rule out Superset caching

Role Not Propagating to Superset/Airflow/Dashboards

These services map Keycloak roles to internal roles at login time. If a role changes:

  1. The user must log out of the service
  2. Clear the service's session (or use an incognito window)
  3. Log in again -- the new role will be mapped

Running RBAC Tests

# All RBAC integration tests
pytest tests/integration/ -m rbac -v

# Security tests (includes OPA policy enforcement)
pytest tests/integration/ -m security -v

# Specific service
pytest tests/integration/test_rbac_grafana.py -v

# Full test suite
bash tests/run-all.sh --fast

Architecture Decision Records

ADR Decision
Keycloak as IdP Single source of truth for identity. All services authenticate via OIDC.
OPA for Trino ABAC Policy-as-code enables row-level and column-level security without modifying Trino.
File-based Trino rules Complements OPA for catalog/schema/table-level permissions. 5-minute refresh.
Realm roles (not client roles) Simpler management. One role per user, visible across all clients via groups claim.
5 roles Covers admin, engineering, analysis, compliance, and executive personas. Extensible as needed.