Skip to content
Merged
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
23 changes: 0 additions & 23 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,3 @@ jobs:
go-version-file: operator/go.mod

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing test-token-broker is correct — the module is gone, and the job's working-directory: token-broker would fail outright. But it is worth checking what replaced its coverage, because the deleted job's own comment states it existed so that #537 "cannot recur silently."

After this PR:

So the two new main packages are compiled in CI only as a side effect of the Docker image build. A build break confined to one of those two main.go files would pass Lint, Unit Tests and Build, and surface in the image build — recoverable, but a noisier signal than a failing build job.

Cheapest fix is to extend the build target to match the Dockerfile:

build: manifests generate fmt vet ## Build manager, bundle-service and token-broker binaries.
	go build -o bin/manager cmd/main.go
	go build -o bin/bundle-service ./cmd/bundle-service/
	go build -o bin/token-broker ./cmd/token-broker/

That also keeps make build honest about what the image now ships, which is the same single-source-of-truth argument the chart rendering in kind-reload-all.sh makes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. Makefile:146 was go build -o bin/manager cmd/main.go, and neither new main package appeared anywhere in the Makefile — so a break confined to either would pass Lint, Unit Tests and Build and only surface in the image build. That's the silent-failure mode the deleted test-token-broker job existed to prevent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build now compiles all three, matching the Dockerfile. Verified: produces bin/manager, bin/bundle-service, bin/token-broker.

- name: Build
run: make build

# token-broker is a separate Go module. The workflow-level
# defaults.run.working-directory pins every other job to `operator`, so
# nothing here compiled this module — it shipped in v0.4.0-rc.1 with an
# unresolvable authlib requirement (rossoctl/operator#537). This job builds
# and tests it so that cannot recur silently.
test-token-broker:
name: Token Broker Build & Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: token-broker
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: token-broker/go.mod
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Run tests
run: go test ./...
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,71 @@ The operator runs the following controllers and webhooks:

Rossoctl includes a dedicated bundle service used by AuthBridge clients to fetch authorization bundles.

This service is deployed using the manifests in `operator/config/bundleservice/` and is intended for SRE operational use.
The service ships inside the operator image and is **opt-in**. Enable it at install time:

```sh
helm install rossoctl-operator ... --set bundleService.enabled=true
```

Key facts:

- Deployment name: `bundle-service`
- Namespace: `system`
- Namespace: the release namespace
- Service type: `ClusterIP`
- Port: `8080`
- Health endpoints: `/healthz`, `/readyz`

Use `operator/operator/cmd/bundle-service/README.md` for SRE runbook guidance and operational details.
Enabling it also installs a NetworkPolicy restricting callers to pods labelled
`rossoctl.dev/authbridge: "true"`. This is the service's **only** access control — it
performs no in-process authorization and serves any bundle named in the `?spiffe=` query
param — so it is installed with the component rather than behind `networkPolicy.enable`.
Note that it only takes effect on a cluster whose CNI enforces NetworkPolicy; kind's
default CNI does not.

Use `operator/cmd/bundle-service/README.md` for SRE runbook guidance and operational details.

## Token Broker

The Token Broker enables HITL (Human-in-the-Loop) authorization: when an agent needs
permissions beyond those in its own token, the broker runs an OAuth 2.0 PKCE flow to
obtain just-in-time, user-scoped credentials.

It also ships inside the operator image and is **opt-in**:

```sh
helm install rossoctl-operator ... \
--set tokenBroker.enabled=true
```

Key facts:

- Deployment name: `token-broker`
- Namespace: the release namespace
- Service type: `ClusterIP`
- Port: `8190`
- Health endpoints: `/healthz`, `/readyz`
- Replicas: fixed at 1 — sessions and the token cache are in-memory, so scaling out
requires shared state first

OAuth client credentials are **not** templated by the chart. Create the Secret out of
band and point `tokenBroker.oauth.existingSecret` at it:

```sh
kubectl create secret generic github-oauth-credentials -n rossoctl-system \
--from-literal=client-id=<CLIENT_ID> --from-literal=client-secret=<CLIENT_SECRET>
```

The chart also installs an HTTPRoute for the OAuth callback. Its hostname defaults to
`token-broker.localtest.me`, which works out of the box on a kind/dev cluster; the host
in `tokenBroker.oauth.callbackUrl` **must** match it, or the provider's post-consent
redirect 404s and the broker waits for a callback that never arrives. Override both for
real deployments, or set `tokenBroker.httpRoute.enabled=false` and route the callback
yourself.

For production, set `tokenBroker.jwt.*` — incoming JWTs are not verified when those are
left unset.

See `operator/cmd/token-broker/README.md` for the API reference and operational details.

## Quick Start

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
{{- if .Values.bundleService.enabled }}
# Mandatory bootstrap data, not an example. These four Rego entry points are the
# packages AuthBridge's OPA evaluates; each is `default allow := false`, and
# namespace/client-scope CRs are modifiers layered on top. Without this CR the
# packages do not exist and every decision fails closed.
#
# Must live in the service's own namespace: watcher.go ignores global-scope CRs
# from any other namespace, logging only a warning.
apiVersion: agent.rossoctl.dev/v1alpha1
kind: AuthorizationPolicy
metadata:
name: default
namespace: rossoctl-system
namespace: {{ .Release.Namespace }}
spec:
scope: global
policies:
Expand Down Expand Up @@ -62,3 +70,4 @@ spec:
ns_ok if not data.authbridge.ns.outbound.response
client_ok if data.authbridge.client.outbound.response.allow
client_ok if not data.authbridge.client.outbound.response
{{- end -}}
57 changes: 57 additions & 0 deletions charts/operator/templates/bundleservice/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{{- if .Values.bundleService.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: bundle-service
namespace: {{ .Release.Namespace }}
spec:
replicas: {{ .Values.bundleService.replicas }}
selector:
matchLabels:
app: bundle-service
{{- include "chart.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
app: bundle-service
{{- include "chart.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: bundle-service
spec:
containers:
- name: bundle-service
# Same image as the manager unless explicitly overridden; ENTRYPOINT is
# /manager, so `command` is what selects this binary.
image: {{ .Values.bundleService.container.image.repository | default .Values.controllerManager.container.image.repository }}:{{ .Values.bundleService.container.image.tag | default .Values.controllerManager.container.image.tag }}
imagePullPolicy: {{ .Values.bundleService.container.image.pullPolicy | default .Values.controllerManager.container.image.pullPolicy | default "IfNotPresent" }}
command:
- {{ .Values.bundleService.container.cmd }}
ports:
- name: http
containerPort: 8080
protocol: TCP
env:
# Scopes which global-scope AuthorizationPolicy CRs are honoured:
# the watcher ignores global CRs outside the service's own namespace.
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
{{- range $key, $value := .Values.bundleService.container.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.bundleService.container.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.bundleService.container.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.bundleService.container.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.bundleService.container.securityContext | nindent 12 }}
securityContext:
{{- toYaml .Values.bundleService.securityContext | nindent 8 }}
serviceAccountName: {{ .Values.bundleService.serviceAccountName }}
{{- end -}}
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
{{- if .Values.bundleService.enabled }}
# This NetworkPolicy is the ONLY access control on bundle-service: the server
# performs no in-process authorization and will serve any bundle named in the
# ?spiffe= query param. It is therefore installed with the component rather
# than behind `networkPolicy.enable`. Requires a CNI that enforces
# NetworkPolicy — kind's default CNI does not.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: bundle-service
namespace: system
namespace: {{ .Release.Namespace }}
spec:
podSelector:
matchLabels:
Expand Down Expand Up @@ -34,7 +43,8 @@ spec:
port: 53
- protocol: TCP
port: 53
# Allow access to Kubernetes API server
# Allow access to the Kubernetes API server. The API server IP is
# cluster-specific, so this is deliberately broad.
- to:
- ipBlock:
cidr: 0.0.0.0/0
Expand All @@ -43,3 +53,4 @@ spec:
port: 443
- protocol: TCP
port: 6443
{{- end -}}
36 changes: 36 additions & 0 deletions charts/operator/templates/bundleservice/rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{{- if and .Values.rbac.enable .Values.bundleService.enabled }}
# The service watches AuthorizationPolicy CRs cluster-wide to compose bundles.
# list+watch only: it serves from an informer cache and never writes status.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: rossoctl-bundle-service
rules:
- apiGroups:
- agent.rossoctl.dev
resources:
- authorizationpolicies
verbs:
- get
- list
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: rossoctl-bundle-service
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: rossoctl-bundle-service
subjects:
- kind: ServiceAccount
name: {{ .Values.bundleService.serviceAccountName }}
namespace: {{ .Release.Namespace }}
{{- end -}}
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
{{- if .Values.bundleService.enabled }}
apiVersion: v1
kind: Service
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: bundle-service
namespace: system
namespace: {{ .Release.Namespace }}
spec:
type: ClusterIP
selector:
Expand All @@ -12,3 +16,4 @@ spec:
port: 8080
targetPort: http
protocol: TCP
{{- end -}}
10 changes: 10 additions & 0 deletions charts/operator/templates/bundleservice/serviceaccount.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{{- if and .Values.rbac.enable .Values.bundleService.enabled }}
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: bundle-service
name: {{ .Values.bundleService.serviceAccountName }}
namespace: {{ .Release.Namespace }}
{{- end -}}
112 changes: 112 additions & 0 deletions charts/operator/templates/tokenbroker/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{{- if .Values.tokenBroker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
{{- include "chart.labels" . | nindent 4 }}
app.kubernetes.io/component: token-broker
name: token-broker
namespace: {{ .Release.Namespace }}
spec:
# Sessions and the token cache are held in memory, so this must stay at 1.
# Scaling out requires shared session state first. Enforced rather than merely
# documented: splitting sessions across pods fails at runtime, in the OAuth
# flow, well away from whoever set the replica count.
{{- if .Values.tokenBroker.replicas }}
{{- fail "tokenBroker.replicas is not supported: sessions and the token cache are in-memory, so the broker must run a single replica. Scaling out requires shared session state first." }}
{{- end }}
replicas: 1
selector:
matchLabels:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the comment is exactly the right thing to write here —

Sessions and the token cache are held in memory, so this must stay at 1. Scaling out requires shared state first; it is deliberately not a value.

Since the constraint is real correctness rather than preference, consider making it enforced instead of documented, so a future --set tokenBroker.replicas=3 fails loudly rather than silently splitting sessions across pods:

{{- if .Values.tokenBroker.replicas }}
{{- fail "tokenBroker.replicas is not supported: sessions and the token cache are in-memory, so the broker must run a single replica. Scaling out requires shared session state first." }}
{{- end }}
replicas: 1

That way the reasoning in the comment is also the thing that stops the mistake. Purely optional — the current form is already clearer than most.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken. The constraint is correctness rather than preference, so the fail guard is a better home for the reasoning than a comment.

The guard keys off the value being set at all rather than its value, since 1 is the only legal setting — which
works because tokenBroker.replicas is deliberately absent from values.yaml. Verified: renders replicas: 1
normally, and --set tokenBroker.replicas=3 fails with the message rather than silently splitting sessions.

app: token-broker
{{- include "chart.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
app: token-broker
{{- include "chart.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: token-broker
annotations:
# The broker terminates its own OAuth flows; no mesh sidecar required.
sidecar.istio.io/inject: "false"
spec:
containers:
- name: token-broker
# Same image as the manager unless explicitly overridden; ENTRYPOINT is
# /manager, so `command` is what selects this binary.
image: {{ .Values.tokenBroker.container.image.repository | default .Values.controllerManager.container.image.repository }}:{{ .Values.tokenBroker.container.image.tag | default .Values.controllerManager.container.image.tag }}
imagePullPolicy: {{ .Values.tokenBroker.container.image.pullPolicy | default .Values.controllerManager.container.image.pullPolicy | default "IfNotPresent" }}
command:
- {{ .Values.tokenBroker.container.cmd }}
ports:
- name: http
containerPort: 8190
protocol: TCP
env:
- name: TOKEN_BROKER_PORT
value: "8190"
- name: OAUTH_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ .Values.tokenBroker.oauth.existingSecret }}
key: {{ .Values.tokenBroker.oauth.clientIdKey }}
- name: OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.tokenBroker.oauth.existingSecret }}
key: {{ .Values.tokenBroker.oauth.clientSecretKey }}
# Host here MUST match httpRoute.hostname.
- name: OAUTH_CALLBACK_URL
value: {{ .Values.tokenBroker.oauth.callbackUrl | quote }}
- name: ALLOWED_REDIRECT_HOSTS
value: {{ .Values.tokenBroker.oauth.allowedRedirectHosts | quote }}
- name: RESOURCE_CONFIG
value: {{ .Values.tokenBroker.resourceConfig | quote }}
{{- with .Values.tokenBroker.oauth.authorizationEndpoint }}
- name: OAUTH_AUTHORIZATION_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.oauth.tokenEndpoint }}
- name: OAUTH_TOKEN_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.oauth.scopesSupported }}
- name: OAUTH_SCOPES_SUPPORTED
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.jwt.jwksUrl }}
- name: JWT_JWKS_URL
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.jwt.issuer }}
- name: JWT_ISSUER
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.jwt.audience }}
- name: JWT_AUDIENCE
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.sessionTimeout }}
- name: TOKEN_BROKER_SESSION_TIMEOUT
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.maxSessionsPerUser }}
- name: TOKEN_BROKER_MAX_SESSIONS_PER_USER
value: {{ . | quote }}
{{- end }}
{{- with .Values.tokenBroker.tokenWaitTimeout }}
- name: TOKEN_BROKER_TOKEN_WAIT_TIMEOUT
value: {{ . | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tokenBroker.container.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.tokenBroker.container.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.tokenBroker.container.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.tokenBroker.container.securityContext | nindent 12 }}
securityContext:
{{- toYaml .Values.tokenBroker.securityContext | nindent 8 }}
serviceAccountName: {{ .Values.tokenBroker.serviceAccountName }}
{{- end -}}
Loading
Loading