Security
The operator's security model: principals, gateway listeners, trust boundaries, enrolment tokens, permissions, local mode and known limitations.
This page is the security model of a Wingsmith install: who can do what, where trust is decided, and what is not protected yet. To report a vulnerability, see Security reporting.
Wingsmith is pre-release and built for one organisation operating its own clusters. Several of the limitations below are known and not yet fixed. Plan around them.
Trust boundaries
| Component | Runs | Trusts |
|---|---|---|
Plane api | Central, behind your ingress | Callers presenting a valid admin session, tenant credential or gateway service token |
Plane hub | Central, behind your ingress | Operators presenting a per-cluster enrolment token |
Plane reactor | Central, no listener | The database |
| PostgreSQL | Central | The plane processes only. It holds all declared state and every credential hash |
| Gateway | Central, in the request path | Depends on the listener. See Gateway listeners |
| Operator, agent | One each per cluster | The plane and gateway they are configured to dial |
Clusters only dial out. Neither the plane nor the gateway opens a connection into a cluster or holds a kubeconfig. See Networking.
Principals
The plane resolves every request to one of four principal kinds. There are no roles or scopes beyond these.
| Principal | Credential | Can do |
|---|---|---|
| Admin | Password, then a session token (hgrs1.) and refresh token (hgrr1.) | Everything |
| Tenant | Tenant console token (hgrt1.) | Read-only view of what that tenant is served. Only GET /v1/serving/* |
| Operator | Per-cluster enrolment token (hgro1.) | Open a hub session and write observed state for that one cluster |
| Gateway | HANGAR_GATEWAY_SERVICE_TOKEN | GET /v1/gateway/tenants, GET /v1/endpoints/resolved, POST /v1/clusters/tunnel-auth. No admin rights |
Admin
The single human operator of the installation, using the console or hgr. Wingsmith assumes one operator, or a small team sharing the role. There are no per-user accounts, no SSO and no audit trail by person.
POST /v1/auth/login returns a short-lived session token and a server-side refresh token.
| Setting | Default |
|---|---|
HANGAR_SESSION_TTL_SECONDS | 900 (15 minutes) |
HANGAR_REFRESH_TTL_SECONDS | 604800 (7 days), absolute |
Outside HANGAR_APP_ENV=local, admin auth is always on, and the plane refuses to start with HANGAR_ADMIN_AUTH=disabled or without the admin password hash and the session signing secret. A misconfigured deployment fails closed.
Generate the password hash with the plane image you built:
docker run --rm -it registry.example.com/hangar/plane:<tag> python -m app.auth hash
Set it on the api, with a random signing secret:
env:
- name: HANGAR_ADMIN_PASSWORD_HASH
value: "<bcrypt hash>"
- name: HANGAR_SESSION_SIGNING_SECRET
valueFrom:
secretKeyRef:
name: hangar-plane
key: session-signing-secret
Operator
Each enrolment token is minted per cluster and binds the session to that cluster. An operator can only write observed state for the cluster its token names. Operator tokens are accepted by the hub only and refused on the HTTP API. There is no shared fleet-wide operator token.
Gateway
Set HANGAR_GATEWAY_SERVICE_TOKEN to the same random value on the plane api and on the gateway. Outside local, an unset token means no gateway principal resolves at all, and the gateway exits at boot without it.
Gateway listeners
The gateway decides trust by which listener accepted the connection. Every listener states its zone; there is no default.
| Listener | Authentication | Exposure |
|---|---|---|
Internal, :8080 | None. Reaching it is the proof of trust | ClusterIP only, with a NetworkPolicy |
Public, HANGAR_GATEWAY_PUBLIC_LISTEN | Tenant from the Host header, plus that tenant’s API key when it declares auth: api_key | HTTPS ingress on a wildcard host. Off unless set |
Tunnel, :8443 | Cluster enrolment token, verified with the plane | TCP passthrough, TLS terminated by the gateway |
A caller on the internal listener can reach every enabled tenant’s models, including tenants that require an API key on the public listener. Without a NetworkPolicy, any pod in the cluster can do this. Your own edge authenticates and bills its users, then calls this listener. Policy example: Networking.
On the public listener:
- A tenant declaring
auth: nonegets401unlessHANGAR_GATEWAY_PUBLIC_ALLOW_NO_AUTH=true. - Only tenants with
ingress: publicand endpoints withvisibility: publicare served. Aprivateendpoint, the plane’s default, is served on the internal listener alone. - Another tenant’s model reads as
404, not403.
On the tunnel listener, a verified token is reused for about a minute and re-verified about once a minute for open tunnels. A token you rotate or revoke therefore loses its tunnels in a little over two minutes. If the plane is unreachable, open tunnels stay up, and a dial whose token was verified within the last 15 minutes is still accepted. Restarting the gateway replicas drops the cache at once.
Enrolment tokens
One token serves three uses:
- The operator authenticates to the hub with it.
- The agent authenticates to the gateway tunnel listener with it.
- The metrics agent authenticates to
remote_writewith it, unless you give the chart other metrics credentials.
Whoever reads the token from any of those pods can open a hub session and write observed state for that cluster, establish tunnels for it, and write metrics under its label. Protect the Secret:
- Keep it in a Kubernetes Secret and restrict
geton that Secret to the operator and agent ServiceAccounts. - Rotate it with
POST /v1/clusters/{ref}/enrolment-token. This is an admin action and the old token is revoked immediately. Update the cluster’s Secret and restart the operator and agent pods.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: read-enrolment-token
namespace: hangar-system
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["<enrolment token secret name>"]
verbs: ["get"]
Enrolment itself is on Enrol a cluster.
Operator permissions
The operator’s RBAC is cut to what the install allows.
- Read-only cluster: no write access outside the release namespace. Which operations the plane may request comes from
cluster.allowedOperations, which is empty by default. - Managed cluster: the plane decides what runs in the workload namespace, including engine and benchmark images. Anyone with plane admin can run code there.
Narrow that with allowlists in the chart values:
operator:
images:
allowedPrefixes:
- registry.example.com/approved/
benchmark:
images:
- registry.example.com/harness/aiperf:<tag>
- registry.example.com/harness/evalscope:<tag>
allowedPrefixes applies to every engine, frontend and prefetch image, matched at a path, tag or digest boundary, and the operator refuses anything else. Include the per-model frontend’s registry too, or no model renders. benchmark.images is matched exactly; empty keeps the operator’s defaults. Without an allowlist, treat plane admin as equivalent to write access to the workload namespace. Chart settings: Chart values.
Request protections
Failed sign-ins
Failed sign-ins are budgeted per caller address and account, so one caller spending its budget locks out itself, not the operator. Behind a proxy, set HANGAR_TRUSTED_PROXIES to the proxy addresses or CIDRs, or every caller shares the proxy’s budget. Addresses in that list have their X-Forwarded-For believed when charging failures.
Cross-site checks
- A
POST,PUT,PATCHorDELETEwhoseOriginis neither the plane’s own nor inHANGAR_ALLOWED_ORIGINSis refused with403. - A write whose body is not JSON is refused with
415. - Every response carries
Content-Security-Policy: frame-ancestors 'none'andX-Frame-Options: DENY.
Set HANGAR_ALLOWED_ORIGINS only if the console is served from a different origin than the api.
What the gateway logs
The gateway keeps no request or response bodies in its logs. The access line carries the request id, method, path (never the query string), status, outcome, timings, token counts, tenant and a 12-character key-hash prefix. It never carries a body, a header value or a model answer.
A discarded upstream error is logged as a summary: status, the engine’s type and code, body length and whether it was truncated. The full body is logged only under HANGAR_GATEWAY_LOG_UPSTREAM_BODIES, which must stay off in production. An engine’s error quotes the request it refused.
Local mode
HANGAR_APP_ENV=local is for a laptop. Never expose it to a network. compose.yml in the repository runs this way.
- A request with no credential resolves to the admin principal.
- The API binds
127.0.0.1, and answers only loopback,HANGAR_PUBLIC_HOSTor a host inHANGAR_ALLOWED_ORIGINS; otherHostvalues get403. - The password hash and session signing secret are not required.
- The gateway service token defaults to a fixed, public value.
HANGAR_APP_ENV defaults to production in the plane image, so a plane started with no environment fails closed.