Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copy-pr-bot.yaml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
enabled: true
enabled: true
60 changes: 55 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3"
uuid = { version = "1.17", features = ["v4", "serde"] }
thiserror = "2.0"
tower = { version = "0.5.3", features = ["util"] }
http = "1.4.1"
moka = { version = "0.12.15", features = ["future"] }
secrecy = "0.10.3"
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }

Expand Down
69 changes: 69 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,75 @@ GCS uses the configured/default ModelExpress cache root; `MODEL_EXPRESS_CACHE_DI

See [`CLI.md`](CLI.md) for full CLI usage documentation.

## ServiceAccount Authentication

Optional, off by default. When enabled, the server authenticates every gRPC caller
(except health checks) against a Kubernetes ServiceAccount token and authorizes them
against an exact-match allowlist. No sidecar or service mesh is required: the server
calls the Kubernetes `TokenReview` API in-process.

- **AuthN**: the caller presents a projected ServiceAccount token; the server verifies it
via `TokenReview` and extracts `system:serviceaccount:<namespace>:<serviceaccount>`.
- **AuthZ**: that `<namespace>:<serviceaccount>` must exactly match a configured allowlist
entry.

### Modes

| Mode | Behavior |
|------|----------|
| `off` (default) | No auth. Tokens are ignored. |
| `enforce` | Verify every call and reject unauthenticated or non-allowlisted callers. |

### Server configuration

| Env Var | Default | Description |
|---------|---------|-------------|
| `MODEL_EXPRESS_SECURITY_MODE` | `off` | `off` \| `enforce` |
| `MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES` | (none) | Comma-separated audiences the token must carry. Required for `enforce`. |
| `MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS` | (none) | Comma-separated `<namespace>:<serviceaccount>` allowlist. Required for `enforce`. |
| `MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS` | `60` | TTL for the verified-token and rejection caches. |

`enforce` fails config validation if either the audience list or the allowlist is empty,
so a typo can't silently deny-all or accept-any-audience.

The server's ServiceAccount needs permission to create `TokenReview`s (a cluster-scoped
subresource), via a `ClusterRoleBinding` to the built-in `system:auth-delegator` role. The
Helm chart creates this automatically when `security.enabled=true`:

```yaml
security:
enabled: true
mode: enforce
tokenAudiences: ["modelexpress"]
allowedServiceAccounts:
- "vllm:worker"
- "vllm:router"
```

### Client configuration

Clients (Rust and Python) attach the token automatically when a projected token file is
present, and send nothing when it is absent (so the same client works against an `off`
server, including off-cluster). Mount a projected token into each worker pod with an
audience that matches the server's, then point the client at it:

```yaml
volumes:
- name: mx-token
projected:
sources:
- serviceAccountToken:
path: modelexpress
audience: modelexpress
expirationSeconds: 3600
# mounted at /var/run/secrets/tokens/modelexpress
```

| Env Var | Default | Description |
|---------|---------|-------------|
| `MX_AUTH_TOKEN_PATH` | `/var/run/secrets/tokens/modelexpress` | Projected token file path |
| `MX_AUTH_TOKEN_TTL_SECONDS` | `60` | How often to re-read the token (rotation is also picked up on mtime change) |

## Docker

### Production Image
Expand Down
22 changes: 22 additions & 0 deletions helm/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ spec:
{{- if .Values.extraEnv }}
{{- toYaml .Values.extraEnv | nindent 12 }}
{{- end }}
{{- if .Values.security.enabled }}
{{- if and (eq .Values.security.mode "enforce") (not .Values.security.tokenAudiences) }}
{{- fail "security.mode=enforce requires a non-empty security.tokenAudiences" }}
{{- end }}
{{- if and (eq .Values.security.mode "enforce") (not .Values.security.allowedServiceAccounts) }}
{{- fail "security.mode=enforce requires a non-empty security.allowedServiceAccounts" }}
{{- end }}
- name: MODEL_EXPRESS_SECURITY_MODE
value: {{ .Values.security.mode | quote }}
{{- with .Values.security.tokenAudiences }}
- name: MODEL_EXPRESS_SECURITY_TOKEN_AUDIENCES
value: {{ join "," . | quote }}
{{- end }}
{{- with .Values.security.allowedServiceAccounts }}
- name: MODEL_EXPRESS_SECURITY_ALLOWED_SERVICE_ACCOUNTS
value: {{ join "," . | quote }}
{{- end }}
{{- if hasKey .Values.security "cacheTtlSecs" }}
- name: MODEL_EXPRESS_SECURITY_CACHE_TTL_SECS
value: {{ .Values.security.cacheTtlSecs | quote }}
{{- end }}
{{- end }}
{{- if .Values.command }}
command:
{{- toYaml .Values.command | nindent 12 }}
Expand Down
21 changes: 21 additions & 0 deletions helm/templates/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,24 @@ subjects:
name: {{ include "modelexpress.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}

# TokenReview access for ServiceAccount auth (security.enabled). The server verifies
# caller tokens by POSTing TokenReviews, a cluster-scoped subresource, so this needs a
# ClusterRoleBinding to the built-in system:auth-delegator role.
{{- if and .Values.serviceAccount.create .Values.security.enabled }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: {{ include "modelexpress.fullname" . }}-auth-delegator
labels:
{{- include "modelexpress.labels" . | nindent 4 }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:auth-delegator
subjects:
- kind: ServiceAccount
name: {{ include "modelexpress.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
{{- end }}
15 changes: 15 additions & 0 deletions helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ serviceAccount:
# Required when MX_METADATA_BACKEND=kubernetes.
enabled: false

# ServiceAccount auth (AuthN via Kubernetes TokenReview + namespace/SA allowlist AuthZ).
# Disabled by default. When enabled together with serviceAccount.create, the chart also
# creates a ClusterRoleBinding to system:auth-delegator so the server can POST
# TokenReviews. If you bring your own ServiceAccount (serviceAccount.create=false), bind
# it to system:auth-delegator yourself or every request fails closed.
security:
enabled: false
# off | enforce. enforce rejects unauthenticated or non-allowlisted callers.
mode: enforce
# Audiences the caller's projected token must carry. Required for enforce.
tokenAudiences: []
# Exact-match allowlist of "<namespace>:<serviceaccount>". Required for enforce.
allowedServiceAccounts: []
# cacheTtlSecs: 60

podAnnotations: {}

podLabels: {}
Expand Down
Loading
Loading