Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3d3e7eb
docs(longhorn): design spec for Keycloak-gated UI exposure via Envoy …
tylerpotts May 22, 2026
160631f
docs(longhorn): implementation plan for Keycloak-gated UI exposure
tylerpotts May 22, 2026
efd7f58
feat(provider): add LonghornEnabled flag to InfraSettings
tylerpotts May 22, 2026
b8c17b2
feat(provider): expose LonghornEnabled per-provider in InfraSettings
tylerpotts May 22, 2026
ad17acb
feat(argocd): add LonghornSSOConfig to FoundationalConfig
tylerpotts May 22, 2026
6409708
feat(argocd): provision longhorn-oidc-client-secret in keycloak and l…
tylerpotts May 22, 2026
1d5de94
feat(argocd): provision Longhorn OIDC secrets during foundational ins…
tylerpotts May 22, 2026
8b394ef
feat(argocd): thread LonghornEnabled into TemplateData
tylerpotts May 22, 2026
8b9ae37
docs(argocd): correct LonghornEnabled comment to match implementation
tylerpotts May 22, 2026
1288a66
feat(argocd): add Longhorn UI HTTPRoute template
tylerpotts May 22, 2026
e769f77
test(argocd): skip empty-rendered routes in HTTPS-listener test
tylerpotts May 22, 2026
6cefc2c
feat(argocd): add Longhorn UI SecurityPolicy template
tylerpotts May 22, 2026
1dcc856
feat(argocd): add securitypolicies Application to sync policies/ dir
tylerpotts May 22, 2026
f298373
feat(argocd): add longhorn.<domain> to gateway certificate dnsNames
tylerpotts May 22, 2026
3e37485
feat(argocd): register Longhorn Keycloak client in realm-setup job
tylerpotts May 22, 2026
81fbec0
test(argocd): anchor longhorn callback URL assertion to catch path mu…
tylerpotts May 22, 2026
d9e773c
feat(deploy): generate Longhorn OIDC client secret and wire foundatio…
tylerpotts May 22, 2026
1e391cf
docs(deploy): correct Longhorn secret comment to match actual gating
tylerpotts May 22, 2026
fe77185
docs(longhorn): simplify conditional gate to LonghornEnabled-only
tylerpotts May 22, 2026
aebcdec
docs(longhorn): design spec for group-based Longhorn UI authorization
tylerpotts May 25, 2026
01d4b3d
docs(longhorn): plan to restrict UI access to longhorn-admins group
tylerpotts May 25, 2026
2bb5510
feat(argocd): restrict Longhorn UI to longhorn-admins via JWT-claim a…
tylerpotts May 25, 2026
2864a60
fix(lint): extract constants for repeated label keys and AWS region
tylerpotts May 25, 2026
f12c3f0
fix(longhorn): HTTPRoute backendRef must use longhorn-frontend port 8…
tylerpotts May 25, 2026
d95f26c
fix(keycloak): update existing groups mapper instead of swallowing error
tylerpotts May 25, 2026
db49586
refactor(longhorn): drop the longhorn-viewers group
tylerpotts May 25, 2026
8252d02
fix(keycloak): replace groups mapper via delete+create for determinis…
tylerpotts May 25, 2026
fa97a97
fix(longhorn): match group claim by path form since nebari-operator o…
tylerpotts May 25, 2026
af071e7
docs: drop longhorn UI spec and plan documents from the PR
tylerpotts May 26, 2026
24893f7
Merge branch 'main' into tpotts/longhorn-ui-gateway
tylerpotts May 29, 2026
232faf1
fix(argocd): pass gitConfig arg to WriteAllToGit in tests
tylerpotts May 29, 2026
bbf5fd8
fix(deploy): repair botched main merge and restore Longhorn OIDC wiring
tylerpotts May 29, 2026
447b4a9
Merge remote-tracking branch 'origin/main' into tpotts/longhorn-ui-ga…
tylerpotts Jun 1, 2026
c9e4450
fix(argocd): skip securitypolicies app and policies manifest when Lon…
tylerpotts Jun 9, 2026
bbc10b3
Merge remote-tracking branch 'origin/main' into tpotts/longhorn-ui-ga…
tylerpotts Jun 18, 2026
5a2a52c
Merge remote-tracking branch 'origin/main' into tpotts/longhorn-ui-ga…
tylerpotts Jun 22, 2026
9ebffdb
refactor(aws): inline single-use us-east-1 region literal
tylerpotts Jun 24, 2026
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
85 changes: 79 additions & 6 deletions pkg/argocd/foundational.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ const (

// NebariFoundationalPartOf is the value of the app.kubernetes.io/part-of label for foundational resources.
NebariFoundationalPartOf = "nebari-foundational"

// LabelKeyPartOf is the standard Kubernetes label key identifying the higher-level app a resource belongs to.
LabelKeyPartOf = "app.kubernetes.io/part-of"

// LabelKeyManagedBy is the standard Kubernetes label key identifying the controller/tool that manages a resource.
LabelKeyManagedBy = "app.kubernetes.io/managed-by"

// LabelValueManagedBy is the value used for the app.kubernetes.io/managed-by label on resources NIC provisions.
LabelValueManagedBy = "nebari-infrastructure-core"

// LonghornDefaultNamespace is the namespace where Longhorn (and its UI) is deployed.
LonghornDefaultNamespace = "longhorn-system"

// LonghornOIDCClientSecretName is the name of the Kubernetes secret holding the
// pre-generated OIDC client secret for the Longhorn UI Keycloak client. The same
// value is written into both the keycloak namespace (read by realm-setup-job) and
// the longhorn-system namespace (read by the SecurityPolicy that fronts the UI).
LonghornOIDCClientSecretName = "longhorn-oidc-client-secret" //nolint:gosec // Secret name reference, not a credential
)

// FoundationalConfig holds configuration for foundational services
Expand All @@ -42,6 +60,9 @@ type FoundationalConfig struct {
// ArgoCD SSO configuration
ArgoCD ArgoCDSSOConfig

// Longhorn UI SSO configuration
Longhorn LonghornSSOConfig

// LandingPage configuration
LandingPage LandingPageConfig

Expand Down Expand Up @@ -78,6 +99,14 @@ type ArgoCDSSOConfig struct {
ClientSecret string // Pre-generated OIDC client secret for ArgoCD's Keycloak integration
}

// LonghornSSOConfig holds Longhorn UI SSO configuration.
// ClientSecret is the pre-generated OIDC client secret used by the Envoy Gateway
// SecurityPolicy that protects longhorn.<domain>. Empty when Longhorn UI exposure
// is disabled — either because Longhorn is not installed or Keycloak is not enabled.
type LonghornSSOConfig struct {
ClientSecret string
}

// InstallFoundationalServices installs foundational services via GitOps.
// This function handles the bootstrap phase:
// 1. Creates the ArgoCD Project for foundational services
Expand Down Expand Up @@ -151,6 +180,19 @@ func InstallFoundationalServices(ctx context.Context, cfg *config.NebariConfig,
span.RecordError(err)
return fmt.Errorf("failed to create landing page secrets: %w", err)
}

// Create namespace + dual OIDC client-secret Secret for Longhorn UI exposure.
// No-op when foundationalCfg.Longhorn.ClientSecret == "" (Longhorn disabled or Keycloak off).
if foundationalCfg.Longhorn.ClientSecret != "" {
if err := createNamespace(ctx, k8sClient, LonghornDefaultNamespace); err != nil {
span.RecordError(err)
return fmt.Errorf("failed to create Longhorn namespace: %w", err)
}
if err := createLonghornSecrets(ctx, k8sClient, foundationalCfg.Longhorn); err != nil {
span.RecordError(err)
return fmt.Errorf("failed to create Longhorn secrets: %w", err)
}
}
}

// 3. Apply root App-of-Apps if git configuration is available
Expand Down Expand Up @@ -287,8 +329,8 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
Name: "nebari-realm-admin-credentials",
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
LabelKeyPartOf: NebariFoundationalPartOf,
LabelKeyManagedBy: LabelValueManagedBy,
},
},
Type: corev1.SecretTypeOpaque,
Expand All @@ -308,8 +350,8 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
Name: "argocd-oidc-client-secret",
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
LabelKeyPartOf: NebariFoundationalPartOf,
LabelKeyManagedBy: LabelValueManagedBy,
},
},
Type: corev1.SecretTypeOpaque,
Expand All @@ -324,6 +366,37 @@ func createKeycloakSecrets(ctx context.Context, client kubernetes.Interface, key
return nil
}

// createLonghornSecrets writes the OIDC client secret used to protect the
// Longhorn UI into both the keycloak namespace (for realm-setup-job) and the
// longhorn-system namespace (for the Envoy Gateway SecurityPolicy). When
// longhornSSO.ClientSecret is empty, nothing is created.
func createLonghornSecrets(ctx context.Context, client kubernetes.Interface, longhornSSO LonghornSSOConfig) error {
if longhornSSO.ClientSecret == "" {
return nil
}

for _, ns := range []string{KeycloakDefaultNamespace, LonghornDefaultNamespace} {
if err := createSecret(ctx, client, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: LonghornOIDCClientSecretName,
Namespace: ns,
Labels: map[string]string{
LabelKeyPartOf: NebariFoundationalPartOf,
LabelKeyManagedBy: LabelValueManagedBy,
},
},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{
"client-secret": longhornSSO.ClientSecret,
},
}); err != nil {
return fmt.Errorf("failed to create %s in %s: %w", LonghornOIDCClientSecretName, ns, err)
}
}

return nil
}

// createLandingPageSecrets creates the required secrets for the nebari-landing service
func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface, landingCfg LandingPageConfig) error {
namespace := NebariSystemNamespace
Expand All @@ -335,8 +408,8 @@ func createLandingPageSecrets(ctx context.Context, client kubernetes.Interface,
Name: NebariLandingRedisSecretName,
Namespace: namespace,
Labels: map[string]string{
"app.kubernetes.io/part-of": NebariFoundationalPartOf,
"app.kubernetes.io/managed-by": "nebari-infrastructure-core",
LabelKeyPartOf: NebariFoundationalPartOf,
LabelKeyManagedBy: LabelValueManagedBy,
},
},
Type: corev1.SecretTypeOpaque,
Expand Down
80 changes: 80 additions & 0 deletions pkg/argocd/foundational_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,86 @@ func TestCreateKeycloakSecrets_SkipsArgoCDSecretWhenEmpty(t *testing.T) {
}
}

func TestCreateLonghornSecrets(t *testing.T) {
ctx := context.Background()

t.Run("creates client-secret in both keycloak and longhorn-system when enabled", func(t *testing.T) {
nsKC := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "keycloak"}}
nsLH := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "longhorn-system"}}
client := fake.NewSimpleClientset(nsKC, nsLH) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests

err := createLonghornSecrets(ctx, client, LonghornSSOConfig{ClientSecret: "longhorn-secret-xyz"})
if err != nil {
t.Fatalf("createLonghornSecrets() error = %v", err)
}

for _, ns := range []string{"keycloak", "longhorn-system"} {
sec, err := client.CoreV1().Secrets(ns).Get(ctx, "longhorn-oidc-client-secret", metav1.GetOptions{})
if err != nil {
t.Fatalf("failed to get longhorn-oidc-client-secret in %s: %v", ns, err)
}
if got := getSecretValue(sec, "client-secret"); got != "longhorn-secret-xyz" {
t.Errorf("client-secret in %s = %q, want %q", ns, got, "longhorn-secret-xyz")
}
}
})

t.Run("creates no secret when ClientSecret is empty", func(t *testing.T) {
nsKC := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "keycloak"}}
nsLH := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "longhorn-system"}}
client := fake.NewSimpleClientset(nsKC, nsLH) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests

err := createLonghornSecrets(ctx, client, LonghornSSOConfig{ClientSecret: ""})
if err != nil {
t.Fatalf("createLonghornSecrets() error = %v", err)
}

for _, ns := range []string{"keycloak", "longhorn-system"} {
_, err := client.CoreV1().Secrets(ns).Get(ctx, "longhorn-oidc-client-secret", metav1.GetOptions{})
if err == nil {
t.Errorf("expected longhorn-oidc-client-secret to not exist in %s, but it does", ns)
}
}
})
}

func TestCreateKeycloakAndLonghornSecrets_Together(t *testing.T) {
ctx := context.Background()

nsKC := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "keycloak"}}
nsLH := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "longhorn-system"}}
client := fake.NewSimpleClientset(nsKC, nsLH) //nolint:staticcheck // SA1019: NewSimpleClientset is deprecated but still functional for tests

kcCfg := KeycloakConfig{
Enabled: true,
AdminUsername: "admin",
AdminPassword: "kcpw",
DBPassword: "dbpw",
PostgresAdminPassword: "pgadmin",
PostgresUserPassword: "pguser",
}
argoSSO := ArgoCDSSOConfig{ClientSecret: "argocd-secret-abc"}
longhornSSO := LonghornSSOConfig{ClientSecret: "longhorn-secret-xyz"}

if err := createKeycloakSecrets(ctx, client, kcCfg, argoSSO); err != nil {
t.Fatalf("createKeycloakSecrets() error = %v", err)
}
if err := createLonghornSecrets(ctx, client, longhornSSO); err != nil {
t.Fatalf("createLonghornSecrets() error = %v", err)
}

// ArgoCD client secret only lives in keycloak
if _, err := client.CoreV1().Secrets("keycloak").Get(ctx, "argocd-oidc-client-secret", metav1.GetOptions{}); err != nil {
t.Errorf("argocd-oidc-client-secret missing from keycloak ns: %v", err)
}
// Longhorn client secret lives in both
for _, ns := range []string{"keycloak", "longhorn-system"} {
if _, err := client.CoreV1().Secrets(ns).Get(ctx, "longhorn-oidc-client-secret", metav1.GetOptions{}); err != nil {
t.Errorf("longhorn-oidc-client-secret missing from %s ns: %v", ns, err)
}
}
}

func TestNewK8sClient(t *testing.T) {
t.Run("fails with invalid kubeconfig", func(t *testing.T) {
_, err := newK8sClient([]byte("invalid kubeconfig"))
Expand Down
37 changes: 37 additions & 0 deletions pkg/argocd/templates/apps/securitypolicies.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
apiVersion: argoproj.io/v1alpha1
Comment thread
tylerpotts marked this conversation as resolved.
kind: Application
metadata:
name: securitypolicies
namespace: argocd
labels:
app.kubernetes.io/part-of: nebari-foundational
app.kubernetes.io/managed-by: nebari-infrastructure-core
annotations:
argocd.argoproj.io/sync-wave: "3"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: foundational

source:
repoURL: {{ .GitRepoURL }}
targetRevision: {{ .GitBranch }}
path: {{ if .GitPath }}{{ .GitPath }}/{{ end }}manifests/networking/policies

destination:
server: https://kubernetes.default.svc

syncPolicy:
automated:
prune: true
selfHeal: true
allowEmpty: false
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
59 changes: 56 additions & 3 deletions pkg/argocd/templates/manifests/keycloak/realm-setup-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ spec:
key: client-secret
- name: DOMAIN
value: {{ .Domain }}
{{- if .LonghornEnabled }}
- name: LONGHORN_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: longhorn-oidc-client-secret
key: client-secret
{{- end }}
command:
- /bin/bash
- -c
Expand Down Expand Up @@ -103,12 +110,23 @@ spec:
fi

if [ -n "$GROUPS_SCOPE_ID" ]; then
echo "Adding group-membership mapper to groups scope (id=$GROUPS_SCOPE_ID)..."
$KCADM create client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models -r nebari \
# We do NOT manage the group-membership mapper's config here.
# nebari-operator's data-science-pack RBAC bootstrap reconciles
# the mapper to full.path=true on every sync (it needs the path
# form for its own group-lookup-by-path logic). Anything we set
# here gets overwritten. Consumers of the groups claim (e.g.
# Envoy SecurityPolicy on the longhorn UI) must match against
# the path form ("/group-name") rather than the bare name.
#
# Best-effort: create the mapper with sensible defaults if no
# mapper exists yet (e.g. brand-new groups scope we created
# ourselves), but don't fight existing config.
$KCADM create "client-scopes/$GROUPS_SCOPE_ID/protocol-mappers/models" -r nebari \
-s name=group-membership \
-s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s 'config={"full.path":"false","introspection.token.claim":"true","userinfo.token.claim":"true","id.token.claim":"true","access.token.claim":"true","claim.name":"groups"}' || echo "Mapper may already exist"
-s 'config={"full.path":"true","introspection.token.claim":"true","userinfo.token.claim":"true","id.token.claim":"true","access.token.claim":"true","claim.name":"groups","multivalued":"true"}' \
2>/dev/null || true

echo "Ensuring groups is a realm default client scope..."
$KCADM update realms/nebari/default-default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true
Expand Down Expand Up @@ -149,4 +167,39 @@ spec:
-s realm=nebari -s userId=$ADMIN_USER_ID -s groupId=$ADMINS_GROUP_ID -n || true
fi

{{- if .LonghornEnabled }}

echo "Creating Longhorn OIDC client..."
$KCADM create clients -r nebari \
-s clientId=longhorn \
-s enabled=true \
-s protocol=openid-connect \
-s publicClient=false \
-s "secret=$LONGHORN_CLIENT_SECRET" \
-s "redirectUris=[\"https://longhorn.$DOMAIN/oauth2/callback\"]" \
-s directAccessGrantsEnabled=false \
-s standardFlowEnabled=true || echo "Client may already exist"

# Attach groups scope to longhorn client so id_token carries group claims
LONGHORN_CLIENT_ID=$($KCADM get clients -r nebari --fields id,clientId | \
grep -B1 '"clientId" *: *"longhorn"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p')

if [ -n "$LONGHORN_CLIENT_ID" ] && [ -n "$GROUPS_SCOPE_ID" ]; then
echo "Adding groups scope to longhorn client..."
$KCADM update clients/$LONGHORN_CLIENT_ID/default-client-scopes/$GROUPS_SCOPE_ID -r nebari || true
fi

echo "Creating Longhorn admins group..."
$KCADM create groups -r nebari -s name=longhorn-admins || echo "Group may already exist"

echo "Adding admin user to longhorn-admins group..."
LONGHORN_ADMINS_GROUP_ID=$($KCADM get groups -r nebari --fields id,name | \
grep -B1 '"name" *: *"longhorn-admins"' | sed -n 's/.*"id" *: *"\([^"]*\)".*/\1/p')

if [ -n "$ADMIN_USER_ID" ] && [ -n "$LONGHORN_ADMINS_GROUP_ID" ]; then
$KCADM update users/$ADMIN_USER_ID/groups/$LONGHORN_ADMINS_GROUP_ID -r nebari \
-s realm=nebari -s userId=$ADMIN_USER_ID -s groupId=$LONGHORN_ADMINS_GROUP_ID -n || true
fi
{{- end }}

echo "Realm setup complete!"
Loading
Loading