Skip to content

Security model

This is an educational development platform, for a laptop with Compose or a Kubernetes cluster with Helm. It has not had an independent security audit, and only the latest release receives fixes. To report a vulnerability, see the security policy. The security review lists the findings that are still open.

Trust boundaries

  • Portal administration requires the Keycloak iceberg-admin/platform-admin role. Team roles never grant it.
  • User portal: users are linked by exact Keycloak issuer and subject to a Polaris principal, and Polaris validates their tokens itself. The portal's platform identity and RustFS administration credentials are used only for directory lookups and for database and share actions. Before each of those actions, the portal re-checks the caller's current Administrator role, owning team and environment against Polaris. Writers can change the catalog properties those checks read, so give the Writer role only to people you would trust as that team's Administrator (open finding). Catalog browsing, previews and notebooks use the user's own token.
  • Previews and governed queries run fixed code in resource-limited child processes that hold only the user's token and can read only the tables they bind. Governed queries (query_semantic_model, list_dimension_values) build their SQL from the semantic model's expressions, which must pass an allow-list of scalar and aggregate functions; filter values and search text are parameters, and a field's declared type becomes a cast only when it is a known type. Access is checked again before results are returned.
  • MCP endpoints accept only RS256 Keycloak tokens issued to the public iceberg-mcp client. The signature, issuer, audience, expiry and authorized party are verified, and the user is mapped to their Polaris principal on every call. The admin endpoint also requires platform-admin on every call. Destructive tools are annotated, and delete_database, delete_share and delete_semantic_model require the exact name. create_user returns a one-time password, and an external create_share and rotate_share_credential return a client secret, in the agent's transcript, so treat that transcript as a secret. The iceberg-mcp redirects allow any localhost or 127.0.0.1 port and path, but no other host.
  • Tools on the user's computer sign in through the public iceberg-cli client with the device authorization grant and call Polaris and RustFS directly. Only Polaris grants apply on that path; the portal's issuer and subject check does not. Access tokens last one hour. The offline refresh token stays on the user's machine for up to 30 days of inactivity.
  • Data shares hold one read grant per selected table, view or semantic model and nothing else: no listing, no writes, no other database. Vended storage credentials are read-only and limited to the shared table's location. Only a current Administrator of the owning team can manage a share, and portal administrators can only revoke. The secret is shown once and never stored, except in the transcript of an agent that created or rotated it. A shared view is not a row or column filter, because the recipient reads its underlying tables in full. Revocation and expiry end Polaris tokens at once, but storage credentials that were already vended remain valid until they expire. The user portal enforces expiry every 30 seconds and whenever shares are listed. Team shares grant recipients read access only, never administration.
  • Listed data shares publish metadata, never data: names, descriptions, column schemas, partition fields, row counts, producer lineage within the listing, and semantic models. A listing has no credential and its share's principal is never activated for it. Only extensions registered with --listing (PLATFORM_LISTING_EXTENSIONS) can read listings, with their service-account token, and only a current Administrator of the owning team can list a share. The Bridge reads the definitions with a temporary role that holds no data privilege. The data catalog decides who sees a listing, so the owner should list only what everyone in that catalog may know about, including what a semantic model's expressions name.
  • Extensions (see extensions) are trusted, separately released services such as Conversational BI. The Bridge accepts only Keycloak tokens for the iceberg-bridge audience, issued to a registered extension's own clients. Polaris refuses those tokens, so a user token held by an extension cannot touch data. A user call is re-checked like a portal session (exact issuer and subject link, current team role). Only a team Administrator can enable an extension for one environment; its automation principal then has the team's writer access to that environment's databases and a second, read-only role. Team shares that the team received in that environment reach the read-only role too, so an extension the recipient team enabled can read what every member of that team can read, and must narrow access per user with /me. The extension never receives the principal's secret or storage keys, only short-lived Polaris tokens and vended storage credentials. Both last POLARIS_TOKEN_LIFETIME (one hour by default), which also applies to data-share recipients' tokens; Keycloak sign-ins are not affected. Platform and team administrators can revoke an automation principal at once; tokens already issued stay valid until they expire, so a longer lifetime means slower revocation.
  • Conversational BI only reads. It narrows the recipient team's automation principal to each person with /me on every question and every result it serves, and a shared model counts only when the share includes the model and every table it reads. The LLM never writes SQL: queries are compiled from the semantic model, whose expressions pass an allowlist, and run in a disposable DuckDB process that has one read token, no local files and a locked configuration. The configured LLM provider receives the conversation, model names and descriptions, the compiled SQL and at most BI_LLM_ROWS (default 20) rows per result; to keep data on your hardware, point OPENAI_BASE_URL at a local OpenAI-compatible server. Model owners' guidance reaches the LLM as quoted data. The CopilotKit runtime runs on an internal network without credentials and gets conversations only with a short-lived run ticket that the gateway mints per request.
  • dbt pipelines write as the team's automation principal, so a team Administrator decides per environment whether its pipelines may change that environment's tables. Writers commit only to branches, and only team Administrators change main, by merging the branch head they reviewed. Acceptance and production build only revisions that were the head of main, never a commit from inside a merged branch; Writers can start those builds, only Administrators schedule them. The docs site that dbt docs generate writes runs scripts with the viewer's session, so the extension serves it only from successful docs runs of such a revision. The iceberg_dbt.py terminal client refuses any file path that would leave the project folder, also on Windows. Each run gets one Polaris token (read-only except for build, run and seed) and ends 15 minutes before it expires and runs in its own container from the pinned runner image: read-only file system, no capabilities, an unprivileged user, memory, CPU and process limits, no internet, and a private network holding only the catalog and storage. Project code is untrusted input to that container, and the token can reach only the team's databases in that environment. The launcher is the only container with the Docker socket, which amounts to root on the host. It sits on an internal network, requires a random token from the API service, and accepts only a run archive and token: image, flags, mounts and network are fixed in its code. On Kubernetes the run is a Job in a namespace of its own, and the launcher holds no Docker socket, only the right to create Jobs and their Secrets in that namespace; the run reaches the launcher's two job endpoints with a ticket valid for that run only.
  • Ingestion (dlt) loads outside data as the team's automation principal, so a team Administrator decides per environment whether it may write there. Only team Administrators create pipelines, set their secrets and schedules; Writers start runs. Pipelines are declarative configuration, never code: three dlt sources (REST API, SQL database, files), checked when saved and again in the run. Secrets are encrypted at rest (AES-GCM, key in the extension's .env), write-only in the page, API and MCP server, passed to the run only as environment variables and scrubbed from its log and error, also when URL-encoded. The query string of a failed request's URL is masked too, because an API key can travel there under any name. Each run gets one write token and ends 15 minutes before it expires. It runs in its own container from the pinned runner image with the same limits as a dbt run, a private network holding only the catalog and storage, and a separate network for internet access. The run refuses sources that resolve to private, loopback or link-local addresses (cloud metadata included) unless the platform administrator sets ING_ALLOW_PRIVATE_SOURCES=true for a local demo (runs.allowPrivateSources on Kubernetes). Its launcher holds the Docker socket, or on Kubernetes the same namespace rights, under the same rules as dbt's.
  • Docker socket (Compose): the user portal and the internal monitoring collector mount it, which gives them host-level capability, even with a read-only mount. Run them on a dedicated, trusted development host. Container isolation here is not a boundary against hostile tenants.
  • Notebooks receive only their user's data credentials and their owner's current access token, never refresh tokens. They run without root or Linux capabilities, on individual networks with outbound internet access. Team members share writable notebook files, and a notebook runs with the credentials of whoever runs it, so members must trust each other's code. That trust also covers the portal session: notebook output runs with the viewer's portal session (open finding), and the semantic-model helpers run a model's SQL as written, also for a shared model (open finding). A notebook fetches its token from an address that answers only its own container. When Polaris or Keycloak can't be reached, sessions stay open and notebooks stop after 90 seconds.
  • Sign-in: Keycloak locks an account for a minute after 10 wrong passwords, doubling up to 15 minutes. People choose their own passwords; no password policy applies. The realm's admin-cli client cannot sign in with a password.
  • Audit: the Bridge logs every automation principal it creates or revokes, every token it issues and every listing it reads (docker compose logs bridge, or kubectl -n iceberg logs deploy/bridge on Kubernetes). The user portal logs every session it closes after an authorization check.
  • Sessions stay in server memory, so each portal runs as one replica. Issued tokens and vended storage credentials stay valid until they expire. See deployment boundaries.
  • The active team and environment only filter the UI. A user's credentials stay valid for all of their teams.

Deploying beyond localhost

Default ports bind to localhost. For anything beyond a trusted local network:

  • Add HTTPS and secure cookies.
  • Make reverse proxies preserve the Host header and support WebSockets.
  • Set FORWARDED_ALLOW_IPS to the proxy's address so sign-in limits apply per client. Never use * on a directly reachable portal.
  • Never expose the Docker socket, the Polaris management API or the storage administration endpoints.
  • For external data shares or tools on other computers, put TLS in front of Polaris /api/catalog and the RustFS S3 API only. Then set POLARIS_PUBLIC_URL and S3_ENDPOINT. The quick-share skill's gateway does exactly this: it passes only /api/catalog to Polaris and only AWS-signed requests outside /rustfs and /minio to RustFS, so its administration API stays internal.

Keep .env, data volumes and team notebooks private. Database deletion is irreversible, so routine upgrades and tests must preserve volumes.

On Kubernetes

The Helm chart applies these rules itself:

  • Only sign-in and data paths are public. The Ingress routes /realms/iceberg and /resources to Keycloak, so its administration console and the master realm stay inside the cluster. It routes /api/catalog to Polaris and never its management API, and the S3 API to RustFS without /rustfs or /minio. The RustFS console is off. The user portal's /internal paths are not routed, and its notebook token endpoint refuses any forwarded request.
  • No component gets the Docker socket or cluster-wide rights. The user portal may create pods, their Secrets and storage in the notebooks namespace (<namespace>-notebooks) only. The monitor may read pods and their metrics. Extension launchers may create Jobs in their own runs namespace. These namespaces hold nothing but notebooks or runs, so the right to start pods there exposes none of the platform's Secrets.
  • Notebooks and runs are held to the restricted Pod Security standard, with the same limits as on Docker: user 10001, a read-only root filesystem, no capabilities and no service account token. Their credentials sit in a Secret owned by the pod or Job, never in its spec.
  • NetworkPolicies replace the Docker networks. A notebook reaches Polaris, RustFS, the user portal's token endpoint and the internet, but no other private address. A dbt run reaches only Polaris, RustFS and its launcher, and an Ingestion run also public sources. Keycloak and both portals also refuse connections from those namespaces (labelled io.iceberg-platform.isolated) themselves, except the user portal's token endpoint for notebooks, so the rules hold whichever way your CNI applies address ranges. Postgres accepts Polaris, Keycloak and the monitor only. Your CNI must enforce NetworkPolicy, as Cilium, Calico and kind's kindnet do.
  • The portals trust X-Forwarded-For from any address by default (forwardedAllowIps: "*"), because only the ingress controller and pods inside the cluster reach them. Set it to your ingress controller's pod range to keep other pods from choosing the address their sign-in attempts count against.

Back up the Secret iceberg-platform with the volumes: the data only opens with its credentials.