Files
OpenShell/tasks/scripts/cluster-bootstrap.sh
Mrunal Patel 084505425b feat(auth): add OIDC/Keycloak authentication with RBAC and scope-based permissions (#935)
* feat(auth): add OIDC/Keycloak authentication with RBAC

Add OAuth2/OIDC authentication to the gateway server with role-based
access control, CLI login flows, and full deployment plumbing.

Server: JWT validation against configurable OIDC issuer (oidc.rs),
JWKS key caching with TTL and rotation handling, method classification
(unauthenticated/sandbox-secret/dual-auth/bearer), identity extraction
with provider-agnostic Identity type, and RBAC enforcement via
AuthzPolicy with configurable admin/user roles and auth-only mode.

CLI: browser-based Authorization Code + PKCE flow, Client Credentials
flow for CI/automation, token storage with refresh, gateway add/login/
logout commands, OIDC bearer token injection over mTLS transport,
discovery endpoint for auto-configuration.

Security: sandbox-secret scope restriction on UpdateConfig (policy
sync only), anti-spoofing header stripping, dual-auth fallthrough
from sandbox-secret to Bearer token.

Deployment: OIDC config wired through DeployOptions, Docker env vars,
Helm values/templates, HelmChart manifest, cluster-entrypoint.sh, and
bootstrap scripts. Keycloak dev server script with pre-configured
realm (test users, roles, PKCE client, CI client).

Tested with Keycloak. The roles claim path and role names are
configurable to support other OIDC providers.

* feat(auth): add OAuth2 scope-based fine-grained permissions

Add opt-in scope enforcement on top of existing OIDC role-based access
control. When --oidc-scopes-claim is set, the server extracts scopes
from the JWT and checks them per-method against an exhaustive scope map.

Scopes: sandbox:read, sandbox:write, provider:read, provider:write,
config:read, config:write, inference:read, inference:write, and
openshell:all (wildcard). Methods not in the scope map require
openshell:all. Scopes layer on top of roles and cannot escalate
privilege. Auth-only mode (empty role names) still enforces scopes
when enabled.

Server: scopes_claim in OidcConfig, scope extraction from JWT
(space-delimited and JSON array formats), standard OIDC scope
filtering, scope check in AuthzPolicy after role check.

CLI: --oidc-scopes on gateway add/start stored in metadata and
consumed by gateway login, --oidc-scopes-claim on gateway start
forwarded to server, scopes parameter in browser and client
credentials OAuth2 flows with openid deduplication.

Deployment: oidc_scopes_claim wired through DeployOptions, docker.rs,
Helm, bootstrap scripts, and cluster entrypoint.

Keycloak: realm config updated with built-in OIDC scopes and 9
OpenShell client scopes as optional on openshell-cli and openshell:all
as default on openshell-ci.

* fix(auth): address branch review findings

Add GetInferenceBundle to sandbox-secret methods so sandbox inference
route refresh works under OIDC. Make GetSandboxConfig dual-auth so CLI
users can read sandbox settings with Bearer tokens.

Preserve OIDC gateway metadata on restart — a bare gateway start
without --oidc-* flags no longer erases the stored OIDC registration.

Document CI client ID requirement (openshell-ci vs openshell-cli) in
the testing guide. Add security note about auth-only mode blast radius
for GitHub Actions.

* fix(auth): complete review findings for OIDC auth boundary

Move OpenShell/GetSandboxConfig from sandbox-secret-only to dual-auth
so CLI users can read sandbox settings with Bearer tokens while sandbox
supervisors continue using the shared secret.

Add sandbox secret interceptor to the inference bundle fetch path so
GetInferenceBundle works under OIDC-enabled gateways. Extract shared
interceptor constructor to avoid duplication.

Add GetSandboxConfig to the config:read scope map so scope enforcement
applies consistently when scopes are enabled.

Refactor OIDC metadata preservation into apply_oidc_gateway_metadata()
with explicit resume semantics — only preserve existing OIDC metadata
on real resume paths, not on fresh deployments.

Update architecture docs and testing guide to reflect the corrected
method classifications and add new test coverage for interceptor
injection, scope requirements, metadata preservation, and dual-auth
classification.

* refactor(auth): use oauth2 crate for CLI OIDC flows

Replace hand-written PKCE generation, authorization URL construction,
token exchange, client credentials, and token refresh with the oauth2
crate's typed API.

Eliminates sha2, hex, and getrandom dependencies from the CLI. The
custom urlencoded() helper and manual form POST logic are replaced by
BasicClient methods with proper type-state safety.

Discovery and the callback server remain custom since the oauth2 crate
does not provide OIDC discovery or a localhost redirect listener.

* refactor(auth): move server auth modules into auth/ directory

Group oidc.rs, authz.rs, identity.rs, and the auth HTTP endpoints
under src/auth/ module directory. No behavioral changes.

  auth/mod.rs      — module root, re-exports HTTP router
  auth/oidc.rs     — JWT validation, JWKS caching, method classification
  auth/authz.rs    — role and scope authorization policy
  auth/identity.rs — provider-agnostic Identity type
  auth/http.rs     — /auth/connect and /auth/oidc-config endpoints

* fix(auth): use RequestBody auth type for client credentials flow

The oauth2 crate defaults to BasicAuth (HTTP Basic header) but Keycloak
and most OIDC providers expect client_secret_post (credentials in the
request body). Set AuthType::RequestBody explicitly to match the
pre-refactor behavior.

Also re-export Identity, IdentityProvider, and JwksCache from the auth
module so ServerState's public API remains nameable by external consumers.

* fix(auth): forward OPENSHELL_OIDC_SCOPES through cluster bootstrap

Pass --oidc-scopes to gateway start so the metadata includes requested
scopes after cluster bootstrap. Without this, users had to manually
edit metadata.json to set scopes for gateway login.

Usage: OPENSHELL_OIDC_SCOPES="openshell:all" mise run cluster

* test(auth): add OIDC e2e tests for RBAC, scopes, and client credentials

Add 10 end-to-end tests covering OIDC authentication against a live
K3s cluster with Keycloak:

RBAC (5 tests): admin can create providers, user cannot, user can list
sandboxes, unauthenticated requests rejected, health probe works
without auth.

Scopes (4 tests): sandbox-scoped token can list sandboxes but not
providers, openshell:all grants full access, no-scopes token denied.

Client credentials (1 test): CI token via client_credentials grant.

Tests are opt-in via OPENSHELL_E2E_OIDC=1 and OPENSHELL_E2E_OIDC_SCOPES=1
env vars. They derive the Keycloak URL from gateway metadata to match
the server's configured issuer.

Run with:

  OPENSHELL_E2E_OIDC=1 OPENSHELL_E2E_OIDC_SCOPES=1 \
  PYTHONPATH=python uv run pytest e2e/python/oidc/ -v

* fix(docs): fix markdown lint errors in OIDC architecture docs

Add blank lines before lists and fenced code blocks to satisfy
markdownlint MD031 and MD032 rules.
2026-04-30 10:37:23 -07:00

297 lines
9.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/container-engine.sh"
# Normalize cluster name: lowercase, replace invalid chars with hyphens
normalize_name() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
}
MODE=${1:-build}
if [ "${MODE}" != "build" ] && [ "${MODE}" != "fast" ]; then
echo "usage: $0 [build|fast]" >&2
exit 1
fi
if [ -n "${IMAGE_TAG:-}" ]; then
IMAGE_TAG=${IMAGE_TAG}
else
IMAGE_TAG=dev
fi
ENV_FILE=.env
PUBLISHED_IMAGE_REPO_BASE_DEFAULT=ghcr.io/nvidia/openshell
LOCAL_REGISTRY_CONTAINER=openshell-local-registry
LOCAL_REGISTRY_ADDR=127.0.0.1:5000
if [ -n "${CI:-}" ] && [ -n "${CI_REGISTRY_IMAGE:-}" ]; then
IMAGE_REPO_BASE_DEFAULT=${CI_REGISTRY_IMAGE}
elif [ "${MODE}" = "fast" ]; then
IMAGE_REPO_BASE_DEFAULT=${LOCAL_REGISTRY_ADDR}/openshell
else
IMAGE_REPO_BASE_DEFAULT=${LOCAL_REGISTRY_ADDR}/openshell
fi
IMAGE_REPO_BASE=${IMAGE_REPO_BASE:-${OPENSHELL_REGISTRY:-${IMAGE_REPO_BASE_DEFAULT}}}
REGISTRY_HOST=${OPENSHELL_REGISTRY_HOST:-${IMAGE_REPO_BASE%%/*}}
REGISTRY_NAMESPACE_DEFAULT=${IMAGE_REPO_BASE#*/}
if [ "${REGISTRY_NAMESPACE_DEFAULT}" = "${IMAGE_REPO_BASE}" ]; then
REGISTRY_NAMESPACE_DEFAULT=openshell
fi
has_env_key() {
local key=$1
[ -f "${ENV_FILE}" ] || return 1
grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}=" "${ENV_FILE}"
}
append_env_if_missing() {
local key=$1
local value=$2
if has_env_key "${key}"; then
return
fi
if [ -f "${ENV_FILE}" ] && [ -s "${ENV_FILE}" ]; then
# Ensure file ends with newline before appending, but don't add extra blank line
if [ "$(tail -c1 "${ENV_FILE}" | wc -l)" -eq 0 ]; then
printf "\n" >>"${ENV_FILE}"
fi
fi
printf "%s=%s\n" "${key}" "${value}" >>"${ENV_FILE}"
}
port_is_in_use() {
local port=$1
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
return $?
fi
if command -v nc >/dev/null 2>&1; then
nc -z 127.0.0.1 "${port}" >/dev/null 2>&1
return $?
fi
(echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1
}
pick_random_port() {
local lower=20000
local upper=60999
local attempts=256
local port
for _ in $(seq 1 "${attempts}"); do
port=$((RANDOM % (upper - lower + 1) + lower))
if ! port_is_in_use "${port}"; then
echo "${port}"
return 0
fi
done
echo "Error: could not find a free port after ${attempts} attempts." >&2
return 1
}
CLUSTER_NAME=${CLUSTER_NAME:-$(basename "$PWD")}
CLUSTER_NAME=$(normalize_name "${CLUSTER_NAME}")
if [ -n "${GATEWAY_PORT:-}" ]; then
RESOLVED_GATEWAY_PORT=${GATEWAY_PORT}
elif [ "${MODE}" = "fast" ]; then
RESOLVED_GATEWAY_PORT=$(pick_random_port)
else
RESOLVED_GATEWAY_PORT=8080
fi
OPENSHELL_GATEWAY=${OPENSHELL_GATEWAY:-${CLUSTER_NAME}}
GATEWAY_PORT=${RESOLVED_GATEWAY_PORT}
append_env_if_missing "GATEWAY_PORT" "${GATEWAY_PORT}"
append_env_if_missing "OPENSHELL_GATEWAY" "${OPENSHELL_GATEWAY}"
export CLUSTER_NAME
export GATEWAY_PORT
export OPENSHELL_GATEWAY
is_local_registry_host() {
[ "${REGISTRY_HOST}" = "127.0.0.1:5000" ] || [ "${REGISTRY_HOST}" = "localhost:5000" ]
}
registry_reachable() {
curl -4 -fsS --max-time 2 "http://127.0.0.1:5000/v2/" >/dev/null 2>&1 || \
curl -4 -fsS --max-time 2 "http://localhost:5000/v2/" >/dev/null 2>&1
}
wait_for_registry_ready() {
local attempts=${1:-20}
local delay_s=${2:-1}
local i
for i in $(seq 1 "${attempts}"); do
if registry_reachable; then
return 0
fi
sleep "${delay_s}"
done
return 1
}
ensure_local_registry() {
if ce inspect "${LOCAL_REGISTRY_CONTAINER}" >/dev/null 2>&1; then
local proxy_remote_url
proxy_remote_url=$(ce inspect "${LOCAL_REGISTRY_CONTAINER}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | awk -F= '/^REGISTRY_PROXY_REMOTEURL=/{print $2; exit}' || true)
if [ -n "${proxy_remote_url}" ]; then
ce rm -f "${LOCAL_REGISTRY_CONTAINER}" >/dev/null 2>&1 || true
fi
fi
if ! ce inspect "${LOCAL_REGISTRY_CONTAINER}" >/dev/null 2>&1; then
# --restart=always: on Docker this is managed by the Docker daemon and
# survives reboots when the daemon is enabled at boot. On rootless Podman
# it is handled by conmon within the current login session; it does NOT
# survive user logout or reboot without a systemd user unit. This is
# acceptable here because ensure_local_registry() recreates the container
# on every cluster bootstrap invocation if it is missing.
ce run -d --restart=always --name "${LOCAL_REGISTRY_CONTAINER}" -p 5000:5000 registry:2 >/dev/null
else
if ! ce ps --filter "name=^${LOCAL_REGISTRY_CONTAINER}$" --filter "status=running" -q | grep -q .; then
ce start "${LOCAL_REGISTRY_CONTAINER}" >/dev/null
fi
port_map=$(ce port "${LOCAL_REGISTRY_CONTAINER}" 5000/tcp 2>/dev/null || true)
case "${port_map}" in
*:5000*)
;;
*)
ce rm -f "${LOCAL_REGISTRY_CONTAINER}" >/dev/null 2>&1 || true
# See --restart=always note above in this function.
ce run -d --restart=always --name "${LOCAL_REGISTRY_CONTAINER}" -p 5000:5000 registry:2 >/dev/null
;;
esac
fi
if wait_for_registry_ready 20 1; then
return
fi
if registry_reachable; then
return
fi
echo "Error: local registry is not reachable at ${REGISTRY_HOST}." >&2
echo " Ensure a registry is running on port 5000 (e.g. ${CONTAINER_ENGINE} run -d --name openshell-local-registry -p 5000:5000 registry:2)." >&2
ce ps -a >&2 || true
ce logs "${LOCAL_REGISTRY_CONTAINER}" >&2 || true
exit 1
}
REGISTRY_ENDPOINT_DEFAULT=${REGISTRY_HOST}
if is_local_registry_host; then
REGISTRY_ENDPOINT_DEFAULT=host.docker.internal:5000
fi
REGISTRY_INSECURE_DEFAULT=false
if is_local_registry_host; then
REGISTRY_INSECURE_DEFAULT=true
fi
export OPENSHELL_REGISTRY_HOST=${OPENSHELL_REGISTRY_HOST:-${REGISTRY_HOST}}
export OPENSHELL_REGISTRY_ENDPOINT=${OPENSHELL_REGISTRY_ENDPOINT:-${REGISTRY_ENDPOINT_DEFAULT}}
export OPENSHELL_REGISTRY_NAMESPACE=${OPENSHELL_REGISTRY_NAMESPACE:-${REGISTRY_NAMESPACE_DEFAULT}}
export OPENSHELL_REGISTRY_INSECURE=${OPENSHELL_REGISTRY_INSECURE:-${REGISTRY_INSECURE_DEFAULT}}
export IMAGE_REPO_BASE
export IMAGE_TAG
if [ -n "${CI:-}" ] && [ -n "${CI_REGISTRY:-}" ] && [ -n "${CI_REGISTRY_USER:-}" ] && [ -n "${CI_REGISTRY_PASSWORD:-}" ]; then
printf '%s' "${CI_REGISTRY_PASSWORD}" | ce login -u "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"
export OPENSHELL_REGISTRY_USERNAME=${OPENSHELL_REGISTRY_USERNAME:-${CI_REGISTRY_USER}}
export OPENSHELL_REGISTRY_PASSWORD=${OPENSHELL_REGISTRY_PASSWORD:-${CI_REGISTRY_PASSWORD}}
fi
if is_local_registry_host; then
ensure_local_registry
fi
CONTAINER_NAME="openshell-cluster-${CLUSTER_NAME}"
VOLUME_NAME="openshell-cluster-${CLUSTER_NAME}"
if [ "${MODE}" = "fast" ]; then
if ce inspect "${CONTAINER_NAME}" >/dev/null 2>&1 || ce volume inspect "${VOLUME_NAME}" >/dev/null 2>&1; then
echo "Recreating cluster '${CLUSTER_NAME}' from scratch..."
openshell gateway destroy --name "${CLUSTER_NAME}"
fi
fi
if [ "${SKIP_IMAGE_PUSH:-}" = "1" ]; then
echo "Skipping image push (SKIP_IMAGE_PUSH=1; images already in registry)."
elif [ "${MODE}" = "build" ] || [ "${MODE}" = "fast" ]; then
tasks/scripts/cluster-push-component.sh gateway
fi
# Build the cluster image so it contains the latest Helm chart, manifests,
# and entrypoint from the working tree. This ensures the k3s container
# always starts with the correct chart version.
if [ "${SKIP_CLUSTER_IMAGE_BUILD:-}" != "1" ]; then
tasks/scripts/docker-build-image.sh cluster
fi
# In fast/build modes, use the locally-built cluster image rather than the
# remote distribution registry image. The local image is built by
# `docker-build-image.sh cluster` and contains the bundled Helm chart and
# manifests from the current working tree.
if [ -z "${OPENSHELL_CLUSTER_IMAGE:-}" ]; then
export OPENSHELL_CLUSTER_IMAGE="openshell/cluster:${IMAGE_TAG}"
fi
DEPLOY_CMD=(openshell gateway start --name "${CLUSTER_NAME}" --port "${GATEWAY_PORT}")
if [ "${CLUSTER_GPU:-0}" = "1" ]; then
DEPLOY_CMD+=(--gpu)
fi
if [ -n "${GATEWAY_HOST:-}" ]; then
DEPLOY_CMD+=(--gateway-host "${GATEWAY_HOST}")
# Ensure the gateway host resolves from the current environment.
# On Linux CI runners host.docker.internal is not set automatically
# (it's a Docker Desktop feature). If the hostname doesn't resolve,
# add it via the Docker bridge gateway IP.
if ! getent hosts "${GATEWAY_HOST}" >/dev/null 2>&1; then
BRIDGE_IP=$(ce_network_gateway)
if [ -n "${BRIDGE_IP}" ]; then
echo "Adding /etc/hosts entry: ${BRIDGE_IP} ${GATEWAY_HOST}"
echo "${BRIDGE_IP} ${GATEWAY_HOST}" >> /etc/hosts
fi
fi
fi
if [ -n "${OPENSHELL_OIDC_ISSUER:-}" ]; then
DEPLOY_CMD+=(--oidc-issuer "${OPENSHELL_OIDC_ISSUER}")
[ -n "${OPENSHELL_OIDC_AUDIENCE:-}" ] && DEPLOY_CMD+=(--oidc-audience "${OPENSHELL_OIDC_AUDIENCE}")
[ -n "${OPENSHELL_OIDC_ROLES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-roles-claim "${OPENSHELL_OIDC_ROLES_CLAIM}")
[ -n "${OPENSHELL_OIDC_ADMIN_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-admin-role "${OPENSHELL_OIDC_ADMIN_ROLE}")
[ -n "${OPENSHELL_OIDC_USER_ROLE:-}" ] && DEPLOY_CMD+=(--oidc-user-role "${OPENSHELL_OIDC_USER_ROLE}")
[ -n "${OPENSHELL_OIDC_SCOPES_CLAIM:-}" ] && DEPLOY_CMD+=(--oidc-scopes-claim "${OPENSHELL_OIDC_SCOPES_CLAIM}")
[ -n "${OPENSHELL_OIDC_SCOPES:-}" ] && DEPLOY_CMD+=(--oidc-scopes "${OPENSHELL_OIDC_SCOPES}")
fi
"${DEPLOY_CMD[@]}"
# Clear the fast-deploy state file so the next incremental deploy
# recalculates from scratch. This prevents stale fingerprints from a
# prior session from masking changes that the bootstrap has already baked
# into the freshly pushed images.
DEPLOY_FAST_STATE_FILE=${DEPLOY_FAST_STATE_FILE:-.cache/cluster-deploy-fast.state}
rm -f "${DEPLOY_FAST_STATE_FILE}"
echo ""
echo "Cluster '${CLUSTER_NAME}' is ready."