Skip to content

Security

The cloud is designed around a conservative trust boundary.

Trust Boundary

  • Coding agents may edit files and open pull requests.
  • Pull requests run validation on the CI runner, without secrets, the Docker socket or the ihc network.
  • Real deploys happen only on a push to main: that push is the approval.
  • The deploy runner serves the organization's repositories only. Its jobs have the Docker socket and the install directory, so a deploy job is root on the box: write access in the organization is box access (gitops-flow.md).
  • Credentials live on the box: the install .env and each app's apps/<slug>/.env, mode 0600, never in Git, never printed.
  • The portal's API token reads, and only one route. IHC_PORTAL_API_TOKEN (generated by first-run.sh into .env, never printed, fingerprint in state.json) opens GET /api/platform/status without a session, for agents and the installer (portal-api.md). With it, every other portal route answers 403; a wrong token is 401; a portal without one answers 503. On the proxy only that exact path, and only when the request carries an Authorization header, skips the forward auth (scripts/lib/portal-api.sh); the proxy drops any X-authentik-* header on that request, so a token can never pass as a person. The response carries no secret: service health, public URLs and the installation's topology. What it does reveal to a token holder is which services exist and which are down, so it lives in .env like every other credential and the agent profile does not receive it (smoke test 23).
  • Object storage: the root credentials never reach an app. Garage's RPC secret, admin token and metrics token are in the install .env only. Each app gets its own access key, allowed to read and write its bucket app-<slug> and nothing else: not owner (no bucket policy or deletion), no bucket creation, 403 on another app's bucket (smoke test 19). The admin API listens on garage:3903 with a token and is not routed by the proxy; the RPC port answers only to the RPC secret. s3.<BASE_DOMAIN> is public to whoever reaches the proxy, like any S3 endpoint: every request must be signed by a key. Provision sends secrets to Garage on stdin, never on a command line.

Ops tools: admin access to the machine

The ops profile (on by default in the interactive installer) adds four tools for ihc-admins. Treat membership of that group as root on the box:

  • Web terminal (terminal.<BASE_DOMAIN>, Wetty) is a shell on the host as the user that ran install.sh, who is in the docker group or runs rootless Docker: equivalent to root on the box. It signs in with a dedicated key, <install-dir>/local/wetty/id_ed25519, whose public half sits in that user's ~/.ssh/authorized_keys (comment ihc-wetty@<host>). To revoke it, delete that line; to rotate it, delete the line and the two key files and re-run install.sh --apply --from optional_profiles. The key is in the backup (the install directory is backed up), encrypted with the backup key.
  • File browser (files.<BASE_DOMAIN>, FileBrowser) sees every app's data, read and write, and the backup reports, read only. What it does not mount, on purpose: the install directory itself, .env (every platform secret), apps/ (each app's .env with its database password), local/ (the terminal key), runner/, state.json, config.yaml, the restic repository, home directories and system directories. App data lives in <install-dir>/app-data/<slug>, apart from apps/<slug>/, precisely so it can be shown without the secrets next to it. The installer refuses a configured path that breaks this rule, and scripts/tests/ops-mounts.sh fails if the compose file ever mounts the install directory or an env file into it.
  • Portainer has the Docker socket (root on the box) and Glances sees every process: both were already admin tools; they now require ihc-admins too.
  • No way around the SSO. The four run on the ihc-ops network, which only the proxy joins; apps on the ihc network cannot reach them directly. They publish no host port and their routes leave no path public. FileBrowser trusts the X-authentik-username header only because nothing but the proxy can reach it.
  • The smoke user. First-run keeps ihc-smoke-user in ihc-users (no admin rights; password only in .env, 0600; email ihc-smoke-user@smoke.invalid, a domain that never receives mail) so smoke tests 18 and 24 can prove the denials with a real sign-in. It can open Gitea, the apps and the portal's catalog like any user; deactivate it in Authentik if you do not want it, and tests 18 and 24 will then fail at the sign-in rather than at the denial.

The portal: who sees what, and its Authentik account

The portal's route lets in ihc-admins and ihc-users (two policy bindings on the portal application in Authentik); anyone else is refused by Authentik after signing in. Inside, the role comes from the groups the outpost sends (X-authentik-groups, trusted only from the proxy):

ihc-users (viewer) ihc-admins (admin)
Catalog: apps, health, SSO, deploys yes yes
Platform (/api/platform/status), storage usage 403 yes
Dockers page hidden (the runtime JSON under /api/runtime/* and /api/apps-runtime still answers: container names, images, state) yes
People and groups (/api/people, /api/groups) 403 yes
Disk snapshots, portal admins 403 super admin only

The service account ihc-portal. The People page reads Authentik with its own account, not with an admin's: first-run creates the user ihc-portal (type service account, no password, cannot sign in interactively, in no group), a role ihc-portal-reader with exactly two global permissions, authentik_core.view_user and authentik_core.view_group, and one API token (ihc-portal-api, intent API, no expiry), whose key goes to AUTHENTIK_PORTAL_TOKEN in .env (0600) and from there only into the portal's container.

  • It can: list users (username, name, email, active, last login, groups) and groups (name, members), which is what the page shows.
  • It cannot: create, change or delete anything (users, groups, passwords, tokens, applications, policies, flows), view or create other tokens, impersonate, or read any other model (providers, applications, events). A POST that would create a group answers 403 (smoke test 24 checks it on every install). It never sees passwords: Authentik never returns them.
  • Re-running first-run (or install.sh --apply --from foundation) puts the role back to those two permissions, removing any other a human may have added, and keeps the token while it still works; a token that stopped working (deleted or rotated in Authentik) is replaced and .env updated.
  • To revoke: Authentik > Directory > Tokens > ihc-portal-api > Delete (the People page then answers 503 until first-run runs again), or deactivate the user ihc-portal.
  • What it reveals if leaked: the directory (who works here, their emails and groups). Not a way in: no password, no write.

Every route of the portal, who reaches it and what still writes: portal/docs/SECURITY.md.

The MCP gateway: agents acting as a person

The gateway (mcp-gateway.md) lets an AI client act as the signed-in person. What bounds it:

  • Who enters. Only someone who signs in to Authentik and is in ihc-users: the application ihc-mcp is bound to that group (Authentik refuses the code to anyone else), and the gateway checks the groups of every token again (403 otherwise). Admin tools (platform status, people, groups) need ihc-admins in the token, as the portal's pages do.
  • The client. ihc-mcp is a public OAuth client: no secret exists to leak, PKCE binds the code to the program that asked, consent is explicit (a program on the person's laptop cannot get a token without a page the person clicks), and redirects go only to claude.ai, claude.com, loopback addresses and what MCP_OAUTH_EXTRA_REDIRECT_URIS adds. Access tokens live one hour; refresh tokens 30 days, and a deactivated person can neither sign in nor refresh. To cut someone off: deactivate the user or remove them from ihc-users (the next token check or refresh fails); to cut every client: Authentik > Applications > Providers > ihc-mcp > revoke its tokens, or remove the binding.
  • The token never reaches an app. Apps get a different token, the actor JWT, signed by the gateway's own key, valid five minutes, for one app (aud) and with only the scopes that app's manifest grants the person's groups. An app (or someone who compromised one) cannot replay it against another app, the gateway, Authentik, Gitea or the portal. The gateway's private key stays in the mcp-keys volume; its public half is published.
  • The app decides. The gateway proves who is asking; the app filters what that person may see, exactly as for a browser user. A tool that ignores actor.email shows everyone everything to anyone the manifest grants.
  • Reaching apps. The gateway calls apps on the cloud network at http://<slug>:<runtime.port> and the paths of their manifest (paths only, no host: an app cannot point the gateway elsewhere). Their /mcp/* routes are also behind the forward auth through the proxy, where a browser without an actor JWT gets 401.
  • Read only. No tool of this layer writes: the platform's tools read the inventory, the portal's status API and Authentik with the read-only account; the template's tools are read only, and the contract asks apps to keep them so.
  • What the gateway holds: the portal's status token and the read-only Authentik token (the same as the portal's), the inventory read only, its signing key. No Docker access, no .env, no database. Its logs name the person and the tool, never a token.
  • What a leaked access token gives: one hour of that person's read-only tools. Revoke it in Authentik (the user's sessions and tokens).

Private ingress: the tailnet and the CA

INGRESS=private (install-design.md) puts the box on the company's tailnet. What that exposes and what it trusts:

  • What the tailnet reaches. Ports 80 and 443 (the proxy, so every route with its SSO), the Gitea SSH port (Gitea's SSH, keys only) and 53 (the split-DNS server, which answers only for BASE_DOMAIN). Nothing else: the Tailscale node runs in its own network namespace, so the proxy admin (81), the control ports and every container stay off the tailnet. Smoke test 21 proves the admin port is refused on the tailnet address. Who on the tailnet may reach the node at all is the tailnet's access policy (ACLs or grants): restrict it to the people who should see the apps.
  • What the LAN reaches. Nothing: in private the proxy and Gitea SSH are published on 127.0.0.1 only (smoke test 21 checks the bindings and that the box's other addresses refuse 80 and 443).
  • The auth key (IHC_TAILSCALE_AUTHKEY) is used once, from the installer's environment: it is not in .env, not in the compose file and not in the running container. The node's identity is the state in the volume tailscale-state (in the backup, encrypted with the backup key); whoever has that volume can impersonate the node, so treat it like a key. To revoke: remove the node in the Tailscale admin console. Prefer a pre-approved, tagged, reusable or one-off key; an ephemeral key's node disappears from the tailnet some time after the box goes offline and a new key is then needed.
  • The CA. <install-dir>/local/ca/ca.key (0600) can sign certificates that every device with the CA installed accepts. Its name constraint limits that to BASE_DOMAIN and its subdomains (no other domain, no IP address), so a stolen key cannot impersonate a bank or a public site to those devices. It never leaves the box except in the encrypted backup. ca.crt is public. The wildcard key also lives in the proxy's data volume (it serves it).
  • Client addresses. The proxy sees the Tailscale node as the client of every tailnet request (X-Forwarded-For holds the node's container address), not the person's tailnet address: per-person attribution comes from the SSO, not from the network.

The operator agent: the largest surface of the installation

The agent profile (off by default) runs an LLM agent on the box that reads chat messages, logs and web pages, any of which can carry instructions written by someone else. It is therefore built to hold nothing whose loss would be worse than a wrong answer. Its rules are operator-contract.md; this is what it can technically reach.

It sees How
Containers, images, networks, volumes, disk usage, logs, process lists, stats docker-ro, a read-only filter in front of the Docker socket (docker/agent/docker-ro-proxy.py), a core service the portal reads through too (on its own internal network, ihc-docker; the agent knows it as agent-docker-proxy on ihc-agent): GET on an allowlist only; logs and stats never stream. Every Env in a container or image inspect comes back as names only: a plain socket proxy that allows GET /containers/<id>/json hands out every service's passwords, the backup key included
Every service on the ihc network, by name it joins ihc to run HTTP checks the way the proxy does (http://proxy/ with a Host header, health endpoints). It holds no database or admin credential for any of them
Gitea, read only GITEA_READONLY_TOKEN (read:misc, read:repository, read:organization, read:user)
The backup status <BACKUP_DESTINATION>/reports, mounted read only
The map of the box <install-dir>/agent/reference/ (read only): the contract, the runbooks, the generated brief (names and places of credentials, never values; the generator refuses to write one that contains a value)
Its own LLM key and chat tokens AGENT_* in .env, written by a human, passed to its container by name
It does not see Why
The Docker socket only the filter has it; the filter's network (ihc-agent) is internal
The install directory, .env, apps/ (the apps' env files), local/ (keys), state.json's directory not mounted; smoke test 20 checks every mount source
Any cloud secret in its environment only GITEA_READONLY_TOKEN and AGENT_*; smoke test 20 compares its environment with .env
The ops tools not on ihc-ops
A shell on the host none; what needs the host, it asks a human to run
A port or route the gateway listens on the container's loopback; people reach it through the chat channel only

Writes. By default none: every POST, PUT, DELETE through the filter answers 403, and the agent cannot restart even an app. ops.agent.allow_write: true adds exactly two: POST /containers/<id>/restart for a container of an app's Compose project (never one of the ihc project) and POST /build/prune (the build cache, never images or volumes), and only from the agent's container (WRITE_CLIENTS: the portal, on the same filter, never writes). Nothing else is ever on the list: no exec (exec is a shell), no create, no image or volume removal. Re-running a deploy needs a Gitea token that a human creates for it (AGENT_GITEA_TOKEN). Each request through the filter is logged (docker compose logs docker-ro).

Who can talk to it. ops.agent.allow_from lists chat user ids; empty means pairing, and the admin approves each first DM (docker compose exec agent node dist/index.js pairing approve <channel> <code>). Anyone who can talk to it can make it read anything above and repeat it in the chat, logs included: keep the allow list to people who could read the logs anyway. Its LLM provider receives what it reads. In a Telegram group it answers only in the chats listed in ops.agent.groups, only when the message mentions the bot, and only to the senders in ops.agent.group_allow_from (default allow_from): a DM pairing never authorizes anyone in a group, and an empty list means no group at all. Another bot in the group is a sender like any other: list its id only if you want it to be able to ask.

What remains. On the ihc network it can open a TCP connection to any service: the shared Redis's default user is still open to every container on that network (apps included; a known gap, to be closed), and a vulnerable service there is reachable from it as from any app. Logs can contain whatever an app chose to log. The OpenClaw image downloads channel plugins at first start, and the LLM and the chat channel are on the internet, so it has egress: asked to, it will git clone a public repository into its workspace and read it (it did in the conversation run, first-run.md). It cannot reach the host or the Docker socket from there, but whoever can talk to it can make it fetch and run code inside its own container.

Rules

  • Never store secrets in app manifests, compose files or .env.example.
  • Never deploy production on pull request events.
  • Never run untrusted pull request code with deploy credentials.
  • Never delete databases, volumes, repositories, stacks, or proxy routes automatically.
  • Never overwrite an existing domain or app without an explicit policy.
  • Prefer authentication for internal apps by default.
  • Prefer automatic port allocation for new apps.
  • Keep local .env files out of Git.

Required Validations

Checked by ihc-app provision on every deploy:

  • The repository belongs to the organization (the deploy runner serves nothing else) and its name is the app's slug.
  • The slug is not one the platform uses and is not registered to another repository.
  • The domain is <slug>.<BASE_DOMAIN>, and no other route already owns it.
  • App manifest is present and valid; compose file present, with the declared service.
  • Healthcheck is declared.
  • deploy.port is auto; host ports are dropped.
  • No local Postgres, Redis or S3 server; no host networking, privileged mode or Docker socket.
  • mcp, when present: audience and scopes namespaced by the slug, tool paths that are paths, each scope granted to a group.

Not checked yet: obvious secret values in tracked files.