Skip to content

feat: add authentication support for StaticSite CRD #111

@jensens

Description

@jensens

Goal

Allow users to password-protect their static sites via the StaticSite CRD.

Decisions

  • MVP: BasicAuth
  • Future phases: ForwardAuth reference, IP AllowList
  • Secret location: User namespace (operator copies to system namespace)

Quick Comparison

Method Complexity Dependencies Use Case
BasicAuth Low None Simple password protection
ForwardAuth Low Pre-existing middleware SSO via oauth2-proxy
IP AllowList Low None Office/VPN access

CRD Design (All Methods)

apiVersion: pages.kup6s.com/v1alpha1
kind: StaticSite
spec:
  repo: https://github.com/user/site
  auth:                          # Only ONE method can be active
    # Option 1: BasicAuth
    basicAuth:
      secretRef:
        name: my-site-auth       # htpasswd secret in user namespace
      realm: "Restricted"        # Optional, shown in browser dialog
      removeHeader: true         # Optional, strip Authorization header

    # Option 2: ForwardAuth (reference existing middleware)
    forwardAuth:
      middlewareRef: oauth2-proxy  # "name" or "namespace/name"

    # Option 3: IP AllowList
    ipAllowList:
      sourceRange:
        - "192.168.1.0/24"
        - "10.0.0.1"
      ipStrategy:                # Optional
        depth: 1                 # X-Forwarded-For position

Middleware Chain Order

IngressRoute -> [auth? -> ipAllowList? -> stripPrefix? -> addPrefix] -> nginx

Authentication middlewares run FIRST, before path manipulation.


Phase 1: BasicAuth (MVP)

Challenge: Cross-Namespace Secret Reference

Traefik middlewares can only reference secrets in their own namespace.

Solution: Operator copies user's secret to system namespace with name {namespace}--{site}-basicauth.

Go Types (pkg/apis/v1alpha1/types.go)

// Add to StaticSiteSpec
Auth *AuthConfig `json:"auth,omitempty"`

type AuthConfig struct {
    BasicAuth   *BasicAuthConfig   `json:"basicAuth,omitempty"`
    ForwardAuth *ForwardAuthConfig `json:"forwardAuth,omitempty"`
    IPAllowList *IPAllowListConfig `json:"ipAllowList,omitempty"`
}

type BasicAuthConfig struct {
    SecretRef    LocalSecretReference `json:"secretRef"`
    Realm        string               `json:"realm,omitempty"`        // default: "Restricted"
    RemoveHeader *bool                `json:"removeHeader,omitempty"` // default: true
}

type LocalSecretReference struct {
    Name string `json:"name"`
}

CRD Schema (Helm chart CRD YAML)

auth:
  type: object
  properties:
    basicAuth:
      type: object
      required: [secretRef]
      properties:
        secretRef:
          type: object
          required: [name]
          properties:
            name:
              type: string
        realm:
          type: string
          default: "Restricted"
        removeHeader:
          type: boolean
          default: true

Controller Logic (pkg/controller/staticsite.go)

Add new functions:

  • reconcileAuth() - dispatch to appropriate auth method
  • reconcileBasicAuth() - copy secret, create middleware
  • cleanupAuthResources() - remove auth resources on deletion/change

Update reconcileIngressRoute() to insert auth middleware first in chain.

RBAC Changes

ClusterRole (clusterrole-operator.yaml):

- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "watch", "list"]  # Read user secrets

Role (role-operator.yaml):

- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "create", "update", "delete"]  # Manage copied secrets

Secret Format

Traefik BasicAuth requires htpasswd format:

# Create htpasswd file
htpasswd -nB myuser > users

# Create secret (Opaque with 'users' key)
kubectl create secret generic my-auth --from-file=users

The secret must have a users key with htpasswd-formatted content.


Phase 2: ForwardAuth Reference

Go Types

type ForwardAuthConfig struct {
    MiddlewareRef string `json:"middlewareRef"` // "name" or "namespace/name"
}

Controller Logic

  • Parse middlewareRef into namespace/name
  • Validate middleware exists (optional)
  • Add reference to IngressRoute middlewares array
  • No middleware creation - just reference

Cross-Namespace Note

Referencing middlewares in other namespaces requires Traefik allowCrossNamespace=true.


Phase 3: IP AllowList

Go Types

type IPAllowListConfig struct {
    SourceRange []string    `json:"sourceRange"`
    IPStrategy  *IPStrategy `json:"ipStrategy,omitempty"`
}

type IPStrategy struct {
    Depth       int      `json:"depth,omitempty"`
    ExcludedIPs []string `json:"excludedIPs,omitempty"`
}

Controller Logic

Creates Traefik ipAllowList middleware in system namespace.


Files to Modify

File Changes
pkg/apis/v1alpha1/types.go Add AuthConfig structs
pkg/controller/staticsite.go Add reconcileAuth, update middleware chain
charts/kup6s-pages/crds/staticsites.pages.kup6s.com.yaml Add auth schema
charts/kup6s-pages/templates/clusterrole-operator.yaml Add secret read
charts/kup6s-pages/templates/role-operator.yaml Add secret CRUD

Status Tracking

Update ManagedResources in types.go:

type ManagedResources struct {
    // ... existing ...
    BasicAuthMiddleware string `json:"basicAuthMiddleware,omitempty"`
    BasicAuthSecret     string `json:"basicAuthSecret,omitempty"`
    IPAllowMiddleware   string `json:"ipAllowMiddleware,omitempty"`
}

Mutual Exclusion

Only ONE auth method can be active. Add validation in controller:

func validateAuthConfig(auth *AuthConfig) error {
    count := 0
    if auth.BasicAuth != nil { count++ }
    if auth.ForwardAuth != nil { count++ }
    if auth.IPAllowList != nil { count++ }
    if count > 1 {
        return fmt.Errorf("only one auth method allowed")
    }
    return nil
}

Test Scenarios

BasicAuth

  1. Valid secret -> middleware created, auth works
  2. Secret not found -> Error phase
  3. Secret updates -> copied secret updates
  4. Auth removed -> middleware + copied secret deleted
  5. Site deleted -> all auth resources cleaned up

ForwardAuth

  1. Valid reference -> added to IngressRoute
  2. Cross-namespace reference -> works with allowCrossNamespace
  3. Invalid format -> CRD validation error

IP AllowList

  1. Single IP -> access allowed
  2. CIDR range -> range allowed
  3. Invalid CIDR -> validation error

Verification

  1. Create StaticSite with auth.basicAuth.secretRef
  2. Verify middleware: kubectl get middleware -n kup6s-pages
  3. Verify copied secret: kubectl get secret -n kup6s-pages
  4. Access site -> browser prompts for credentials
  5. Remove auth -> resources cleaned up

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions