Skip to main content

Landlock filesystem sandbox for Ory Identities

Ory Identities (Kratos) uses the Linux Landlock LSM to restrict filesystem access at runtime. After Kratos starts up and loads everything it needs from disk, the kernel denies any further open or execute on paths that are not explicitly allow-listed. This narrows the blast radius of bugs, misconfigurations, or supply-chain compromises that could otherwise read arbitrary files.

The sandbox requires Linux 5.13 or later. On older kernels and non-Linux platforms, Kratos runs without it.

What is sandboxed

Jsonnet worker

The hidden jsonnet subcommand that Kratos re-execs to evaluate Jsonnet mappers — OIDC claim mappers, courier templates, and identity-schema transforms — applies an empty Landlock layer at startup. The worker only ever needs the inherited stdin/stdout/stderr, so every path-based filesystem access from inside the worker is denied by the kernel, even if a Jsonnet snippet were to bypass the in-process import barrier. Already-open file descriptors keep working, so the parent ↔ worker IPC is unaffected.

This layer is active on Ory Identities OSS, Network, and OEL and is not configurable: there is no legitimate use case to allow the Jsonnet VM to read from disk.

kratos serve (Ory Network and OEL)

On Ory Network and OEL, the Landlock sandbox also wraps the main kratos serve process. After initialization, the process is restricted to the files and directories it needs at runtime:

  • the configuration files passed via --config
  • TLS certificates and keys for the public and admin listeners
  • SMTP client certificate and key files (courier.smtp.client_cert_path, courier.smtp.client_key_path, and the equivalents under courier.channels[].smtp_config)
  • the courier template directory (courier templates are loaded lazily, at send time)
  • every file referenced from the config via a file:// URI. Kratos walks the loaded configuration once at startup and allow-lists every value that begins with file://. This covers identity schemas under identity.schemas[].url, OIDC claim mappers, web_hook body templates, courier HTTP body templates, session tokenizer mappers and JWKS files, and any future file:// field added to the config schema. Operators do not need to duplicate these paths under security.landlock.allowed_paths.
  • the directory containing the SQLite database — covers the database file itself and any -journal, -wal, -shm, or transient -mj-XXXXX siblings SQLite creates next to it
  • any paths listed in security.landlock.allowed_paths

Auto-discovery matches the file:// URI form documented in the config schema. A few legacy fields still accept a bare filesystem path without the file:// prefix (notably the deprecated form of web_hook body); those bare paths are not auto-discovered, so list them under security.landlock.allowed_paths or migrate the configuration to the file:// form.

A small set of system files is allowed by default:

  • /dev/null — subprocess plumbing
  • /etc/resolv.conf and /etc/hosts — required by Go's pure-Go DNS resolver
  • the running Kratos binary itself, with read + execute, so the Jsonnet sandbox can re-exec it as a worker

The system trust store at /etc/ssl is not allowed. Ory Network and OEL binaries embed Mozilla's CA bundle via golang.org/x/crypto/x509roots/fallback and run with godebug x509usefallbackroots=1, so crypto/x509 never reads the system store at runtime. Operators who need to trust an additional CA must point SSL_CERT_FILE or SSL_CERT_DIR at the file or directory and list it under security.landlock.allowed_paths.

All other filesystem access is denied by the kernel after activation. This includes the /proc and /sys virtual filesystems.

os.TempDir (typically /tmp) is granted read-write only when SQLite is the configured DSN — SQLite stores some temporary files there. With CockroachDB, PostgreSQL, or MySQL, kratos serve never writes to the temp directory, so it is left out of the allowlist for a tighter sandbox.

Configuration

The sandbox is enabled by default. Two options control it:

security:
landlock:
# Set to true to opt out completely. Not recommended in production.
disabled: false
# Extra paths to allow. Directories grant access to every file underneath;
# individual files grant access only to themselves.
allowed_paths:
- /etc/kratos/schemas/fragments/address.json
- /etc/ssl/my-corporate-ca.pem

Hot reload

Landlock restrictions are irrevocable for the lifetime of the process. Hot-reloading a config that flips security.landlock.disabled from false to true does not lift the sandbox — the process must be restarted for the change to take effect. Other config changes (allowlist entries, courier templates, and so on) reload normally, but newly-introduced paths are only honoured on the next process start.

Symlinks in any configured path — --config files, TLS paths, identity.schemas[].url, the SQLite DSN, security.landlock.allowed_paths, and so on — are followed by the kernel when the rule is added at startup. The rule attaches to the inode that the symlink resolves to at that moment. As long as the target does not change, accesses through the symlink keep working transparently.

Landlock rules are irrevocable, so when a symlink target swaps at runtime — for example when cert-manager or certbot renews a certificate by writing a new file and re-pointing the symlink — the rule still references the original target inode. What happens next depends on the shape of the grant:

  • Leaf grant on a file. The rule covers only the original target inode. The renewed target is a fresh inode that is not in the allowlist, and the kernel denies reads through the symlink with EPERM. Restart kratos serve after the swap so the rules re-attach to the new inodes; with automated renewal, wire the restart into the renew hook.
  • Grant on a containing directory. The rule covers every inode underneath. If both the symlink and the renewed target sit under the granted directory — as they do with certbot (/etc/letsencrypt/live/<domain>/... and /etc/letsencrypt/archive/<domain>/..., both under /etc/letsencrypt) or a typical cert-manager volume mount — the swap is transparent and no restart is needed.

To make cert renewals robust, add the cert directory (for example /etc/letsencrypt) to security.landlock.allowed_paths instead of relying on the per-file grants Kratos derives from the TLS path configuration.

Local $ref in identity schemas

Auto-discovery walks the loaded config, not the JSON bodies that the config points at. $ref references inside an identity schema body that target local files ("$ref": "file:///path/to/fragment.json") are therefore not picked up — the top-level identity.schemas[].url is allowed, but the schema content is not parsed. If a schema relies on a local $ref, the referenced file must be listed under security.landlock.allowed_paths, otherwise schema compilation fails because the kernel denies the read.

security:
landlock:
allowed_paths:
- /etc/kratos/schemas/fragments/address.json
identity:
schemas:
- id: customer
url: file:///etc/kratos/schemas/customer.json # auto-allowed

…where customer.json contains:

{ "$ref": "file:///etc/kratos/schemas/fragments/address.json" }

This applies only to schemas that are split across multiple local files via $ref. Schemas served over HTTPS, inlined as base64://, or kept in a single file need no extra configuration.

Troubleshooting

A path that the sandbox does not allow surfaces in the application as EPERM ("Operation not permitted") on open(2), openat(2), or execve(2). Kratos typically logs this as permission denied while loading a config file, schema, template, or TLS material. To distinguish a Landlock denial from a regular Unix permission error, work through the steps below.

1. Confirm the sandbox is the cause

Check the Kratos startup logs for:

level=info msg="Landlock filesystem sandbox is active."

Just before it, two log lines list every path that was added to the allowlist:

level=info msg="Landlock: collected roPaths." roPaths=[...]
level=info msg="Landlock: collected rwDirs." rwDirs=[...]

If the path that triggered EPERM is missing from both lists, Landlock is the cause. As a sanity check, restart with security.landlock.disabled: true: if the error disappears, the denial came from the sandbox.

2. Read the kernel audit log

On Linux 6.10 and later, Landlock emits a kernel audit record for every denied access. The record names the syscall, the resolved path, and the denied access right:

sudo journalctl -k --since "5 minutes ago" | grep -i landlock
sudo dmesg -T | grep -i landlock
sudo ausearch -m LANDLOCK_DENY -ts recent # auditd-based distros

A typical record looks like:

audit: type=1334 audit(...): domain=2 op=fs blockers=fs.read_file path="/etc/kratos/schemas/fragments/address.json" dev="vda1" ino=131072

The path= field is exactly what to add to security.landlock.allowed_paths. Older kernels (5.13 – 6.9) do not emit these records — fall back to step 3 there.

3. Trace the syscall directly

When the audit log is unavailable or the path is templated, attach strace to the running process and watch for EPERM on the relevant syscalls:

sudo strace -f -p "$(pgrep -f 'kratos serve')" -e trace=openat,execve -e status=failed

Lines ending in = -1 EPERM (Operation not permitted) show the exact path the kernel rejected, even when Kratos's own log message has been swallowed by a wrapper.

4. Fix the configuration

Once the offending path is known, add it to the allowlist (a directory grants every file underneath; a file grants only itself), then restart kratos serve — Landlock rules are immutable for the lifetime of the process, so a hot reload will not lift the denial.

security:
landlock:
allowed_paths:
- /etc/kratos/schemas/fragments/address.json