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
97 changes: 91 additions & 6 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/json"
stderrors "errors"
"flag"
"fmt"
"io"
Expand All @@ -13,6 +14,7 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
Expand All @@ -28,6 +30,12 @@ import (
"github.com/sirupsen/logrus"
)

// errAccessDenied is the sentinel returned by authenticateAndAuthorize when a
// user is neither in the per-analysis allowed-users list nor flagged as an
// admin in their session. Callers should compare with errors.Is rather than
// matching error strings.
var errAccessDenied = stderrors.New("access denied")

var log = logrus.WithFields(logrus.Fields{
"service": "vice-proxy",
"art-id": "vice-proxy",
Expand All @@ -40,6 +48,7 @@ const (
sessionName = "proxy-session"
sessionKey = "proxy-session-key"
keycloakSidKey = "keycloak-sid"
adminFlagKey = "is-admin" // session value flagging entitlement-bearing admins

// permissionsFilePath is the path to the ConfigMap-mounted allowed-users file.
permissionsFilePath = "/etc/vice-permissions/allowed-users"
Expand All @@ -64,6 +73,7 @@ type VICEProxy struct {
jwksCertsURL string // The resolved JWKS certs URL, set during initialization.
activeSessions sync.Map // Tracks valid Keycloak session IDs; entries removed on logout.
allowedUsers sync.Map // In-memory set of usernames allowed to access this analysis.
adminEntitlements []string // Entitlement-claim values that grant admin access regardless of allowedUsers.
}

// loadAllowedUsers reads the allowed-users file and populates the in-memory set.
Expand Down Expand Up @@ -250,6 +260,64 @@ func extractStringClaim(token jwt.Token, claim string) string {
return s
}

// extractStringSliceClaim returns the value of a JWT claim that is expected
// to be an array of strings. Returns nil if the claim is missing or the
// underlying value is not a string-array. The Keycloak JSON decoder may
// surface the values as []any (each element a string) or []string
// depending on the path, so both shapes are handled.
func extractStringSliceClaim(token jwt.Token, claim string) []string {
raw, ok := token.Get(claim)
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}

// parseAdminEntitlements splits a comma-separated entitlement list, trims
// whitespace, and drops empty entries.
func parseAdminEntitlements(raw string) []string {
var out []string
for part := range strings.SplitSeq(raw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}

// computeIsAdmin reports whether a token's entitlement claim grants admin
// access — i.e., any of its values match the configured adminEntitlements
// allowlist. Captured at OAuth-callback time and persisted in the session
// so subsequent requests don't have to re-parse the JWT.
func (c *VICEProxy) computeIsAdmin(token jwt.Token) bool {
return anyMatch(extractStringSliceClaim(token, "entitlement"), c.adminEntitlements)
}

// anyMatch reports whether any element of a is also present in b. Both
// inputs are typically very small (≤10 entries each) so a linear scan is
// adequate and avoids the allocation of a set.
func anyMatch(a, b []string) bool {
for _, v := range a {
if slices.Contains(b, v) {
return true
}
}
return false
}

// HandleAuthorizationCode accepts an authorization code in the query string and uses it to obtain an access token.
func (c *VICEProxy) HandleAuthorizationCode(w http.ResponseWriter, r *http.Request) {
log.Debug("validating an authorization code received from Keycloak")
Expand Down Expand Up @@ -391,9 +459,16 @@ func (c *VICEProxy) HandleAuthorizationCode(w http.ResponseWriter, r *http.Reque
return
}

// Check ConfigMap-based authorization: the user must be in the allowed-users list.
if !c.isUserAllowed(usernameStr) {
log.Errorf("user %s not in allowed-users list for analysis %s", usernameStr, c.resourceName)
// Determine admin status from the JWT's entitlement claim. Admins bypass
// the per-analysis allowed-users list — they can access any running
// analysis. Captured at login and stored in the session so subsequent
// requests don't need to re-parse the JWT.
isAdmin := c.computeIsAdmin(token)

// Check ConfigMap-based authorization: the user must be in the allowed-users
// list, or be an admin (entitlement-bearer).
if !c.isUserAllowed(usernameStr) && !isAdmin {
log.Errorf("user %s not in allowed-users list and not an admin for analysis %s", usernameStr, c.resourceName)
http.Error(w, "access denied", http.StatusForbidden)
return
}
Expand All @@ -405,6 +480,7 @@ func (c *VICEProxy) HandleAuthorizationCode(w http.ResponseWriter, r *http.Reque
log.Warnf("failed to get session store, creating new session: %v", err)
}
s.Values[sessionKey] = usernameStr
s.Values[adminFlagKey] = isAdmin

// Extract and store the Keycloak session ID for session tracking.
// Try the standard "sid" claim first, then fall back to "session_state" which
Expand Down Expand Up @@ -761,9 +837,15 @@ func (c *VICEProxy) authenticateAndAuthorize(w http.ResponseWriter, r *http.Requ
}
}

// Check ConfigMap-based authorization: the user must be in the allowed-users list.
if !c.isUserAllowed(username) {
return "", fmt.Errorf("user %s is not in the allowed-users list", username)
// Read admin flag captured at login. Default false (untyped/missing).
isAdmin, _ := session.Values[adminFlagKey].(bool)

// Check ConfigMap-based authorization: the user must be in the allowed-users
// list, or be an admin (entitlement-bearer captured at login). Wrap the
// sentinel so callers can detect access denial with errors.Is without
// string-matching the message.
if !c.isUserAllowed(username) && !isAdmin {
return "", fmt.Errorf("user %s: %w", username, errAccessDenied)
}

// CRITICAL: Don't reset session for WebSocket upgrades (would corrupt the upgrade handshake)
Expand Down Expand Up @@ -853,6 +935,7 @@ func main() {
keycloakClientID := os.Getenv("KEYCLOAK_CLIENT_ID")
keycloakClientSecret := os.Getenv("KEYCLOAK_CLIENT_SECRET")
disableAuth := strings.EqualFold(os.Getenv("DISABLE_AUTH"), "true")
adminEntitlements := parseAdminEntitlements(os.Getenv("ADMIN_ENTITLEMENTS"))

// Validate required Keycloak env vars when auth is enabled to fail fast
// rather than producing confusing URL construction errors at login time.
Expand Down Expand Up @@ -941,6 +1024,7 @@ func main() {
log.Infof("write timeout is %s", *encodedWriteTimeout)
log.Infof("idle timeout is %s", *encodedIdleTimeout)
log.Infof("authentication disabled: %v", disableAuth)
log.Infof("admin entitlements: %v", adminEntitlements)

for _, origin := range corsOrigins {
log.Infof("CORS origin: %s", origin)
Expand Down Expand Up @@ -1004,6 +1088,7 @@ func main() {
sessionStore: sessionStore,
ssoClient: *client,
disableAuth: disableAuth,
adminEntitlements: adminEntitlements,
}

// Load the initial allowed-users list from the permissions ConfigMap mount.
Expand Down
Loading
Loading