Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
import org.apache.pinot.common.version.PinotVersion;
import org.apache.pinot.controller.api.ControllerAdminApiApplication;
import org.apache.pinot.controller.api.access.AccessControlFactory;
import org.apache.pinot.controller.api.access.InMemorySessionManager;
import org.apache.pinot.controller.api.access.SessionManager;
import org.apache.pinot.controller.api.events.MetadataEventNotifierFactory;
import org.apache.pinot.controller.api.resources.ControllerFilePathProvider;
import org.apache.pinot.controller.api.resources.InvalidControllerConfigException;
Expand Down Expand Up @@ -746,6 +748,34 @@ private void setUpPinotController() {
final MetadataEventNotifierFactory metadataEventNotifierFactory =
MetadataEventNotifierFactory.loadFactory(_config.subset(METADATA_EVENT_NOTIFIER_PREFIX), _helixResourceManager);

// Create a singleton SessionManager for session-based UI authentication.
//
// IMPORTANT: This is a per-process in-memory store. In a multi-controller deployment behind
// a load balancer, a session token minted on controller-A is not known to controller-B, and
// a logout request routed to controller-B will not invalidate the token held by controller-A.
// To preserve the server-side logout guarantee, configure the load balancer with sticky
// sessions (e.g., consistent hashing on the session cookie or source IP) so that all requests
// from a given browser are always routed to the same controller. Without sticky sessions,
// session-based UI authentication degrades to best-effort: login and most API calls will work
// (the LB will route them to the controller that knows the session), but logout may silently
// fail if it hits a different controller.
//
// The session TTL is set to the configured session timeout plus a 120s grace period.
// The grace period absorbs clock skew and in-flight requests: a request that arrives at
// the server just before the browser cookie's Max-Age expires will still see a valid
// server-side session. Both the server session and the browser cookie Max-Age are set to
// serverSessionTtlSeconds at login, so they expire at the same absolute time.
//
// Note: CONTROLLER_UI_SESSION_INACTIVITY_TIMEOUT_SECONDS controls the session *ceiling*
// (loginTime + timeout + 120s), not a sliding inactivity window. The UI enforces inactivity
// detection client-side (JavaScript timer that redirects to login after timeout seconds of
// no user interaction). The server does not extend the session on each request.
long uiInactivityTimeoutSeconds = _config.getProperty(
ControllerConf.CONTROLLER_UI_SESSION_INACTIVITY_TIMEOUT_SECONDS,
ControllerConf.DEFAULT_UI_SESSION_INACTIVITY_TIMEOUT_SECONDS);
long serverSessionTtlSeconds = uiInactivityTimeoutSeconds + 120;
final SessionManager sessionManager = new InMemorySessionManager(serverSessionTtlSeconds);

@xiangfu0 xiangfu0 Jul 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates one in-memory session store per controller process. Pinot controllers are commonly deployed behind a VIP/LB; a token minted on controller A will be unknown on controller B, and logout routed to B will not revoke the token minted on A. That breaks the server-side logout/invalidation guarantee and makes session mode unreliable without sticky routing. Please use a shared session store or gate/document this feature on sticky sessions, with a test for cross-controller logout/validation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a per-controller in-memory session store, so logout invalidation is not authoritative in a normal multi-controller deployment. If login/session use controller A but logout is routed to controller B, controller A keeps accepting the old cookie until TTL, which violates the server-side invalidation guarantee. Please use a shared/versioned session store or make session auth fail closed unless sticky routing is explicitly configured and documented for this auth mode.


LOGGER.info("Controller download url base: {}", _config.generateVipUrl());
LOGGER.info("Injecting configuration and resource managers to the API context");
// register all the controller objects for injection to jersey resources
Expand Down Expand Up @@ -775,6 +805,7 @@ protected void configure() {
bind(_storageQuotaChecker).to(StorageQuotaChecker.class);
bind(_resourceUtilizationManager).to(ResourceUtilizationManager.class);
bind(controllerStartTime).named(ControllerAdminApiApplication.START_TIME);
bind(sessionManager).to(SessionManager.class);

bindAsContract(PinotTableReloadService.class).in(Singleton.class);
bindAsContract(PinotTableReloadStatusReporter.class).in(Singleton.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,18 @@ public static long getRandomInitialDelayInSeconds() {
public static final String INGEST_FROM_URI_ALLOW_LOCAL_FILE_SYSTEM =
"controller.ingestFromURI.allowLocalFileSystem";
public static final String ACCESS_CONTROL_FACTORY_CLASS = "controller.admin.access.control.factory.class";

/** When {@code true}, enables session-based UI auth (HttpOnly cookie / POST /auth/login). Default: {@code false}. */
public static final String CONTROLLER_UI_SESSION_ENABLED = "controller.ui.session.authentication.enabled";

/** When {@code true}, the session cookie is flagged Secure (HTTPS only). Default: {@code true}. */
public static final String CONTROLLER_UI_SESSION_COOKIE_SECURE = "controller.ui.session.cookie.secure";

/** Inactivity timeout in seconds before the UI session expires. Server TTL = this + 120s. Default: {@code 300}. */
public static final String CONTROLLER_UI_SESSION_INACTIVITY_TIMEOUT_SECONDS =
"controller.ui.session.inactivity.timeout.seconds";
public static final long DEFAULT_UI_SESSION_INACTIVITY_TIMEOUT_SECONDS = 300L;

public static final String ACCESS_CONTROL_USERNAME = "access.control.init.username";
public static final String ACCESS_CONTROL_PASSWORD = "access.control.init.password";
public static final String LINEAGE_MANAGER_CLASS = "controller.lineage.manager.class";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.apache.pinot.common.swagger.SwaggerSetupUtils;
import org.apache.pinot.controller.ControllerConf;
import org.apache.pinot.controller.api.access.AuthenticationFilter;
import org.apache.pinot.controller.api.access.SessionAuthenticationFilter;
import org.apache.pinot.core.api.ServiceAutoDiscoveryFeature;
import org.apache.pinot.core.transport.ListenerConfig;
import org.apache.pinot.core.util.ListenerConfigUtil;
Expand Down Expand Up @@ -90,6 +91,9 @@ public ControllerAdminApiApplication(ControllerConf conf) {
register(SwaggerSerializers.class);
register(new CorsFilter());
register(AuthenticationFilter.class);
// Register session authentication filter (priority AUTHENTICATION-10, runs before AuthenticationFilter).
// Validates the HttpOnly session cookie for SESSION workflow. No-op when session mode is disabled.
register(SessionAuthenticationFilter.class);
register(AuditLogFilter.class);
_managedAsyncExecutor = createManagedAsyncExecutor();
register(new ManagedAsyncExecutorServiceProvider(_managedAsyncExecutor));
Expand Down Expand Up @@ -147,7 +151,9 @@ public void filter(ContainerRequestContext containerRequestContext,
throws IOException {
containerResponseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
containerResponseContext.getHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS, DELETE");
containerResponseContext.getHeaders().add("Access-Control-Allow-Headers", "*");
containerResponseContext.getHeaders()
.add("Access-Control-Allow-Headers",
"Authorization, Content-Type, Accept, Origin, X-Requested-With, Database, If-Match");
if (containerRequestContext.getMethod().equals("OPTIONS")) {
containerResponseContext.setStatus(HttpServletResponse.SC_OK);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
public interface AccessControl extends FineGrainedAccessControl {
String WORKFLOW_NONE = "NONE";
String WORKFLOW_BASIC = "BASIC";
/** Session-based workflow: credentials validated once at login; subsequent requests use an HttpOnly cookie. */
String WORKFLOW_SESSION = "SESSION";

/**
* Return whether the client has permission to the given table
Expand Down Expand Up @@ -78,22 +80,39 @@ default AuthWorkflowInfo getAuthWorkflowInfo() {
/**
* Container for authentication workflow info for the Pinot UI. May be extended by implementations.
*
* Auth workflow info hold any configuration necessary to execute a UI workflow. We currently foresee supporting NONE
* (auth disabled) and BASIC (basic auth with username and password)
* Auth workflow info holds any configuration necessary to execute a UI workflow. Supports NONE
* (auth disabled), BASIC (basic auth with username and password), and SESSION (session-based
* UI authentication with HttpOnly cookie).
*/
class AuthWorkflowInfo {
String _workflow;
/** UI inactivity timeout in seconds. -1 means not applicable (non-SESSION workflows). */
long _inactivityTimeoutSeconds = -1;

public AuthWorkflowInfo(String workflow) {
_workflow = workflow;
}

public AuthWorkflowInfo(String workflow, long inactivityTimeoutSeconds) {
_workflow = workflow;
_inactivityTimeoutSeconds = inactivityTimeoutSeconds;
}

public String getWorkflow() {
return _workflow;
}

public void setWorkflow(String workflow) {
_workflow = workflow;
}

/** Returns the UI inactivity timeout in seconds, or -1 if not applicable. */
public long getInactivityTimeoutSeconds() {
return _inactivityTimeoutSeconds;
}

public void setInactivityTimeoutSeconds(long inactivityTimeoutSeconds) {
_inactivityTimeoutSeconds = inactivityTimeoutSeconds;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ResourceInfo;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriInfo;
import org.apache.pinot.common.auth.AuthProviderUtils;
import org.apache.pinot.common.utils.DatabaseUtils;
import org.apache.pinot.controller.ControllerConf;
import org.apache.pinot.core.auth.FineGrainedAuthUtils;
import org.apache.pinot.core.auth.ManualAuthorization;
import org.glassfish.grizzly.http.server.Request;
Expand All @@ -51,7 +53,8 @@
@javax.ws.rs.ext.Provider
public class AuthenticationFilter implements ContainerRequestFilter {
private static final Set<String> UNPROTECTED_PATHS =
new HashSet<>(Arrays.asList("", "help", "auth/info", "auth/verify", "auth/verify/v2", "health"));
new HashSet<>(Arrays.asList("", "help", "auth/info", "auth/verify", "auth/verify/v2", "auth/login",
"auth/logout", "auth/session", "auth/session/renew", "health"));
private static final String KEY_TABLE_NAME = "tableName";
private static final String KEY_TABLE_NAME_WITH_TYPE = "tableNameWithType";
private static final String KEY_SCHEMA_NAME = "schemaName";
Expand All @@ -62,12 +65,22 @@ public class AuthenticationFilter implements ContainerRequestFilter {
@Inject
AccessControlFactory _accessControlFactory;

@Inject
SessionManager _sessionManager;

@Inject
ControllerConf _controllerConf;

@Context
ResourceInfo _resourceInfo;

@Context
HttpHeaders _httpHeaders;

// Cached result of isSessionEnabled() — computed lazily on first request and never changes.
// volatile ensures all threads see the result once it is written.
private volatile Boolean _sessionEnabled;

@Override
public void filter(ContainerRequestContext requestContext)
throws IOException {
Expand All @@ -83,6 +96,18 @@ public void filter(ContainerRequestContext requestContext)
return;
}

// SESSION WORKFLOW: If session mode is enabled and the request carries a valid session cookie,
// retrieve the stored Basic-auth token and inject it as an Authorization header. This lets
// validatePermission() and FineGrainedAuthUtils below run with the session user's credentials
// instead of skipping authorization entirely.
if (isSessionEnabled()) {
Cookie sessionCookie = requestContext.getCookies().get(SessionManager.SESSION_COOKIE_NAME);
if (sessionCookie != null && sessionCookie.getValue() != null) {
_sessionManager.getBasicAuthToken(sessionCookie.getValue()).ifPresent(
authToken -> requestContext.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, authToken));
}
}

// check if authentication is required implicitly
if (accessControl.protectAnnotatedOnly() && !endpointMethod.isAnnotationPresent(Authenticate.class)) {
return;
Expand Down Expand Up @@ -151,6 +176,23 @@ private static String extractTableName(MultivaluedMap<String, String> mmap) {
return null;
}

private boolean isSessionEnabled() {
if (_sessionEnabled == null) {
// Compute once and cache. Double-checked locking is safe here because the result
// is idempotent: all threads compute the same value from the same injected beans.
boolean enabled = _sessionManager != null && (
(_controllerConf != null
&& _controllerConf.getProperty(ControllerConf.CONTROLLER_UI_SESSION_ENABLED, false))
// Also enable when the factory itself advertises SESSION workflow (e.g.
// SessionBasicAuthAccessControlFactory configured without the explicit flag).
|| (_accessControlFactory != null
&& AccessControl.WORKFLOW_SESSION.equals(
_accessControlFactory.create().getAuthWorkflowInfo().getWorkflow())));
_sessionEnabled = enabled;
}
return _sessionEnabled;
}

private static boolean isBaseFile(String path) {
return !path.contains("/") && path.contains(".");
}
Expand Down
Loading
Loading