diff --git a/main.go b/main.go index baa21c4..8c6f097 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "encoding/json" + stderrors "errors" "flag" "fmt" "io" @@ -13,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -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", @@ -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" @@ -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. @@ -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") @@ -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 } @@ -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 @@ -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) @@ -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. @@ -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) @@ -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. diff --git a/main_test.go b/main_test.go index c2449f4..9244574 100644 --- a/main_test.go +++ b/main_test.go @@ -14,6 +14,7 @@ import ( "github.com/gorilla/sessions" "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/jwt" "github.com/stretchr/testify/assert" ) @@ -220,6 +221,261 @@ func TestLoadAllowedUsersFromFile(t *testing.T) { assert.False(proxy.isUserAllowed("eve@iplantcollaborative.org")) } +func TestParseAdminEntitlements(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"single", "core-services", []string{"core-services"}}, + {"multiple", "core-services,tito-admins,dev", []string{"core-services", "tito-admins", "dev"}}, + {"whitespace around entries", " core-services , tito-admins ", []string{"core-services", "tito-admins"}}, + {"empty entries dropped", "core-services,,tito-admins,", []string{"core-services", "tito-admins"}}, + {"only commas", ",,,", nil}, + {"only whitespace", " ", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, parseAdminEntitlements(tt.in)) + }) + } +} + +func TestAnyMatch(t *testing.T) { + tests := []struct { + name string + a, b []string + want bool + }{ + {"both empty", nil, nil, false}, + {"first empty", nil, []string{"x"}, false}, + {"second empty", []string{"x"}, nil, false}, + {"single match", []string{"x"}, []string{"x"}, true}, + {"no overlap", []string{"a", "b"}, []string{"c", "d"}, false}, + {"partial overlap", []string{"a", "b", "c"}, []string{"x", "b", "y"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, anyMatch(tt.a, tt.b)) + }) + } +} + +func TestExtractStringSliceClaim(t *testing.T) { + tests := []struct { + name string + setClaim func(tok jwt.Token) + claimName string + want []string + }{ + { + name: "missing claim", + setClaim: func(tok jwt.Token) {}, + claimName: "entitlement", + want: nil, + }, + { + name: "string slice value", + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []string{"core-services", "dev"}) }, + claimName: "entitlement", + want: []string{"core-services", "dev"}, + }, + { + name: "interface slice value (json-decoded shape)", + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"core-services", "dev"}) }, + claimName: "entitlement", + want: []string{"core-services", "dev"}, + }, + { + name: "interface slice with non-string elements skipped", + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"a", 42, "b"}) }, + claimName: "entitlement", + want: []string{"a", "b"}, + }, + { + name: "wrong claim type", + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", "not-a-list") }, + claimName: "entitlement", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tok := jwt.New() + tt.setClaim(tok) + assert.Equal(t, tt.want, extractStringSliceClaim(tok, tt.claimName)) + }) + } +} + +func TestComputeIsAdmin(t *testing.T) { + tests := []struct { + name string + adminEntitlements []string + setClaim func(tok jwt.Token) // optional; nil means leave the entitlement claim unset + want bool + }{ + { + name: "matching entitlement", + adminEntitlements: []string{"core-services"}, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"core-services"}) }, + want: true, + }, + { + name: "non-matching entitlement", + adminEntitlements: []string{"core-services"}, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"users"}) }, + want: false, + }, + { + name: "any-matching among many", + adminEntitlements: []string{"core-services", "tito-admins"}, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"users", "tito-admins"}) }, + want: true, + }, + { + name: "json-decoded interface-slice shape", + adminEntitlements: []string{"core-services"}, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"core-services"}) }, + want: true, + }, + { + name: "string-slice shape", + adminEntitlements: []string{"core-services"}, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []string{"core-services"}) }, + want: true, + }, + { + name: "missing entitlement claim", + adminEntitlements: []string{"core-services"}, + setClaim: nil, + want: false, + }, + { + name: "empty admin allowlist", + adminEntitlements: nil, + setClaim: func(tok jwt.Token) { _ = tok.Set("entitlement", []any{"core-services"}) }, + want: false, + }, + { + name: "both empty", + adminEntitlements: nil, + setClaim: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proxy := getVICEProxy() + proxy.adminEntitlements = tt.adminEntitlements + + tok := jwt.New() + if tt.setClaim != nil { + tt.setClaim(tok) + } + + assert.Equal(t, tt.want, proxy.computeIsAdmin(tok)) + }) + } +} + +// requestWithSession returns an HTTP request whose cookies carry the given +// proxy-session values. Done via a save/replay round-trip on the proxy's +// CookieStore so the resulting cookie matches what the production code path +// would have written. +func requestWithSession(t *testing.T, proxy *VICEProxy, username string, isAdmin bool) *http.Request { + t.Helper() + rec := httptest.NewRecorder() + src := httptest.NewRequest("GET", "http://example.com/test", nil) + + s, err := proxy.sessionStore.Get(src, sessionName) + if err != nil { + t.Fatalf("get session: %v", err) + } + s.Values[sessionKey] = username + s.Values[adminFlagKey] = isAdmin + if err := s.Save(src, rec); err != nil { + t.Fatalf("save session: %v", err) + } + + dst := httptest.NewRequest("GET", "http://example.com/test", nil) + for _, c := range rec.Result().Cookies() { + dst.AddCookie(c) + } + return dst +} + +func TestAuthenticateAndAuthorizeAdminBypass(t *testing.T) { + tests := []struct { + name string + username string + isAdmin bool + preStore []string // usernames to seed into allowedUsers + wantOK bool + wantUser string + wantErrIs error // expected sentinel for errors.Is; nil when wantOK + }{ + { + name: "in allowed-users, not admin → allowed", + username: "alice", + isAdmin: false, + preStore: []string{"alice@iplantcollaborative.org"}, + wantOK: true, + wantUser: "alice", + }, + { + name: "in allowed-users and admin → allowed", + username: "alice", + isAdmin: true, + preStore: []string{"alice@iplantcollaborative.org"}, + wantOK: true, + wantUser: "alice", + }, + { + name: "not in allowed-users but admin → allowed (new path)", + username: "carol", + isAdmin: true, + preStore: []string{"alice@iplantcollaborative.org"}, + wantOK: true, + wantUser: "carol", + }, + { + name: "not in allowed-users and not admin → denied", + username: "carol", + isAdmin: false, + preStore: []string{"alice@iplantcollaborative.org"}, + wantOK: false, + wantErrIs: errAccessDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proxy := getVICEProxy() + proxy.resourceName = "test-analysis" + for _, u := range tt.preStore { + proxy.allowedUsers.Store(u, true) + } + + req := requestWithSession(t, proxy, tt.username, tt.isAdmin) + rec := httptest.NewRecorder() + + gotUser, err := proxy.authenticateAndAuthorize(rec, req) + if tt.wantOK { + assert.NoError(t, err) + assert.Equal(t, tt.wantUser, gotUser) + } else { + assert.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } + }) + } +} + // generateTestJWKS creates a JWKS JSON response containing an RSA public key. func generateTestJWKS(t *testing.T) []byte { t.Helper()