Add auth service config setting for user's default time zone (#24381)

* Add auth service config setting for user's default time zone

The authentication backends for Active Directory, LDAP, OIDC, Okta, and SAML
previously set the time zone for +newly synchronized users to the value of
the `root_timezone` config file setting. ("UTC" by default)

This change introduces a configurable "default user time zone" setting for
all authentication backends. The default value is unset, meaning that the
browser's time zone will be used by default.

Other changes:

- Adjust the notification text for AD/LDAP configurations to reflect
  reality. Default roles are only set for new users, not on login.
- Add note to UPGRADING.md

* Add changelog entry

* Remove unneeded default-user-timezone-select class

* Show "Browser's time zone" when no zone is selected
This commit is contained in:
Bernd Ahlers
2025-12-05 15:03:16 +01:00
committed by GitHub
parent b9536c5365
commit f268d5261b
18 changed files with 108 additions and 30 deletions

View File

@@ -1,12 +1,20 @@
Upgrading to Graylog 7.1.x
==========================
## User Session Termination
All user sessions will be terminated when upgrading because the internal storage format for sessions has been changed.
Users will have to log in again.
## Breaking Changes
tbd
### External Authentication Services: Changed Default User Time Zone
The authentication backends for Active Directory, LDAP, OIDC, Okta, and SAML previously set the time zone for
newly synchronized users to the value of the `root_timezone` config file setting. ("UTC" by default)
Graylog 7.1 introduces a configurable "default user time zone" setting for all authentication backends.
The default value is unset, meaning that the browser's time zone will be used by default.
## Configuration File Changes

View File

@@ -0,0 +1,4 @@
type = "c"
message = "Add auth service config setting for user's default time zone. (see upgrade notes)"
pulls = ["24381", "graylog-plugin-enterprise#12641"]

View File

@@ -29,7 +29,9 @@ import org.mongojack.Id;
import org.mongojack.ObjectId;
import javax.annotation.Nullable;
import java.time.ZoneId;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import static org.apache.commons.lang3.StringUtils.isBlank;
@@ -42,6 +44,7 @@ public abstract class AuthServiceBackendDTO implements BuildableMongoEntity<Auth
public static final String FIELD_TITLE = "title";
public static final String FIELD_DESCRIPTION = "description";
private static final String FIELD_DEFAULT_ROLES = "default_roles";
private static final String FIELD_DEFAULT_USER_TIMEZONE = "default_user_timezone";
private static final String FIELD_CONFIG = "config";
@Id
@@ -59,6 +62,9 @@ public abstract class AuthServiceBackendDTO implements BuildableMongoEntity<Auth
@JsonProperty(FIELD_DEFAULT_ROLES)
public abstract Set<String> defaultRoles();
@JsonProperty(FIELD_DEFAULT_USER_TIMEZONE)
public abstract Optional<ZoneId> defaultUserTimezone();
@NotNull
@JsonProperty(FIELD_CONFIG)
public abstract AuthServiceBackendConfig config();
@@ -96,7 +102,8 @@ public abstract class AuthServiceBackendDTO implements BuildableMongoEntity<Auth
public static Builder create() {
return new AutoValue_AuthServiceBackendDTO.Builder()
.description("")
.defaultRoles(Collections.emptySet());
.defaultRoles(Collections.emptySet())
.defaultUserTimezone(null);
}
@Id
@@ -113,6 +120,9 @@ public abstract class AuthServiceBackendDTO implements BuildableMongoEntity<Auth
@JsonProperty(FIELD_DEFAULT_ROLES)
public abstract Builder defaultRoles(Set<String> defaultRoles);
@JsonProperty(FIELD_DEFAULT_USER_TIMEZONE)
public abstract Builder defaultUserTimezone(@Nullable ZoneId defaultUserTimezone);
@JsonProperty(FIELD_CONFIG)
public abstract Builder config(AuthServiceBackendConfig config);

View File

@@ -16,6 +16,7 @@
*/
package org.graylog.security.authservice;
import jakarta.inject.Inject;
import org.graylog2.plugin.database.ValidationException;
import org.graylog2.plugin.database.users.User;
import org.graylog2.shared.users.UserService;
@@ -25,9 +26,6 @@ import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.Collections;
import java.util.Map;
@@ -35,15 +33,12 @@ public class ProvisionerService {
private static final Logger LOG = LoggerFactory.getLogger(ProvisionerService.class);
private final UserService userService;
private final DateTimeZone rootTimeZone;
private final Map<String, ProvisionerAction.Factory<? extends ProvisionerAction>> provisionerActionFactories;
@Inject
public ProvisionerService(UserService userService,
@Named("root_timezone") DateTimeZone rootTimeZone,
Map<String, ProvisionerAction.Factory<? extends ProvisionerAction>> provisionerActionFactories) {
this.userService = userService;
this.rootTimeZone = rootTimeZone;
this.provisionerActionFactories = provisionerActionFactories;
}
@@ -142,8 +137,10 @@ public class ProvisionerService {
// Set fields there that should not be overridden by the authentication service provisioning
user.setRoleIds(userDetails.defaultRoles());
user.setPermissions(Collections.emptyList());
// TODO: Does the timezone need to be configurable per auth service backend?
user.setTimeZone(rootTimeZone);
// Default to null for the user's time zone so the UI will use the browser's time zone by default.
user.setTimeZone(userDetails.timezone()
.map(zoneId -> DateTimeZone.forID(zoneId.getId()))
.orElse(null));
// TODO: Does the session timeout need to be configurable per auth service backend?
user.setSessionTimeoutMs(UserConfiguration.DEFAULT_VALUES.globalSessionTimeoutInterval().toMillis());

View File

@@ -20,6 +20,7 @@ import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableSet;
import javax.annotation.Nullable;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
@@ -46,6 +47,8 @@ public abstract class UserDetails {
public abstract Optional<String> lastName();
public abstract Optional<ZoneId> timezone();
/**
* Some authentication backends only currently support the fullName attribute (and not firstName and lastName),
* so it is still optionally available here. Prefer use of only firstName and lastName when available.
@@ -98,6 +101,8 @@ public abstract class UserDetails {
public abstract Builder lastName(@Nullable String lastName);
public abstract Builder timezone(@Nullable ZoneId timezone);
/**
* Starting in Graylog 4.1, use of this method is deprecated.
* Prefer use of the {@link #firstName()} and {@link #lastName()} methods instead when possible. This way,
@@ -119,7 +124,7 @@ public abstract class UserDetails {
// Either a fullName, or a firstName/lastName are required.
final boolean missingFirstOrLast = !userDetails.firstName().isPresent()
|| !userDetails.lastName().isPresent();
|| !userDetails.lastName().isPresent();
if (missingFirstOrLast && !userDetails.fullName().isPresent()) {
throw new IllegalArgumentException("Either a firstName/lastName or a fullName are required.");

View File

@@ -22,6 +22,7 @@ import com.google.inject.assistedinject.Assisted;
import com.unboundid.ldap.sdk.Filter;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPException;
import jakarta.inject.Inject;
import org.graylog.security.authservice.AuthServiceBackend;
import org.graylog.security.authservice.AuthServiceBackendDTO;
import org.graylog.security.authservice.AuthServiceCredentials;
@@ -39,9 +40,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import jakarta.inject.Inject;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Collections;
@@ -122,6 +120,7 @@ public class ADAuthServiceBackend implements AuthServiceBackend {
.fullName(userEntry.fullName())
.email(userEntry.email())
.defaultRoles(backend.defaultRoles())
.timezone(backend.defaultUserTimezone().orElse(null))
.build());
return Optional.of(AuthenticationDetails.builder().userDetails(userDetails).build());

View File

@@ -21,6 +21,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.inject.assistedinject.Assisted;
import com.unboundid.ldap.sdk.LDAPConnection;
import com.unboundid.ldap.sdk.LDAPException;
import jakarta.inject.Inject;
import org.graylog.security.authservice.AuthServiceBackend;
import org.graylog.security.authservice.AuthServiceBackendDTO;
import org.graylog.security.authservice.AuthServiceCredentials;
@@ -38,9 +39,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import jakarta.inject.Inject;
import java.security.GeneralSecurityException;
import java.util.Collections;
import java.util.HashMap;
@@ -102,6 +100,7 @@ public class LDAPAuthServiceBackend implements AuthServiceBackend {
.fullName(userEntry.fullName())
.email(userEntry.email())
.defaultRoles(backend.defaultRoles())
.timezone(backend.defaultUserTimezone().orElse(null))
.build());
return Optional.of(AuthenticationDetails.builder().userDetails(userDetails).build());

View File

@@ -35,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -64,7 +65,7 @@ public class ProvisionerServiceTest {
@BeforeEach
public void setUp() throws Exception {
provisionerService = new ProvisionerService(userService, DateTimeZone.UTC, new HashMap<>());
provisionerService = new ProvisionerService(userService, new HashMap<>());
}
@Test
@@ -90,6 +91,7 @@ public class ProvisionerServiceTest {
provisionerService.provision(userDetails);
verify(userService, times(1)).save(isA(User.class));
verify(user, times(1)).setFirstLastFullNames(eq(FIRST_NAME), eq(LAST_NAME));
verify(user, times(1)).setTimeZone((DateTimeZone) isNull());
}
@Test
@@ -114,5 +116,6 @@ public class ProvisionerServiceTest {
provisionerService.provision(userDetails);
verify(userService, times(1)).save(isA(User.class));
verify(user, times(1)).setFullName(FULL_NAME);
verify(user, times(1)).setTimeZone((DateTimeZone) isNull());
}
}

View File

@@ -49,7 +49,7 @@ const UserSyncSection = ({ authenticationBackend, roles, excludedFields = {} }:
userUniqueIdAttribute,
emailAttributes,
} = authenticationBackend.config;
const { defaultRoles = Immutable.List() } = authenticationBackend;
const { defaultRoles = Immutable.List(), defaultUserTimezone } = authenticationBackend;
return (
<SectionComponent
@@ -66,6 +66,7 @@ const UserSyncSection = ({ authenticationBackend, roles, excludedFields = {} }:
<ReadOnlyFormGroup label="ID Attribute" value={userUniqueIdAttribute} />
)}
<ReadOnlyFormGroup label="Default Roles" value={rolesList(defaultRoles, roles)} />
<ReadOnlyFormGroup label="Default User Time Zone" value={defaultUserTimezone || "Browser's time zone"} />
</SectionComponent>
);
};

View File

@@ -115,6 +115,7 @@ const _prepareSubmitPayload =
const formValues = overrideFormValues ?? getUpdatedFormsValues();
const {
defaultRoles = '',
defaultUserTimezone,
description,
serverHost,
serverPort,
@@ -136,6 +137,7 @@ const _prepareSubmitPayload =
title,
description,
default_roles: defaultRoles.split(','),
default_user_timezone: defaultUserTimezone,
config: {
servers: [{ host: serverHost, port: serverPort }],
system_user_dn: systemUserDn,

View File

@@ -23,6 +23,7 @@ export type WizardFormValues = {
title?: string;
description?: string;
defaultRoles?: string;
defaultUserTimezone?: string;
groupSearchBase?: string;
groupSearchPattern?: string;
serverHost?: string;

View File

@@ -15,15 +15,15 @@
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import * as React from 'react';
import type * as Immutable from 'immutable';
import { useContext } from 'react';
import type * as Immutable from 'immutable';
import type { FormikProps } from 'formik';
import { Formik, Form, Field } from 'formik';
import styled from 'styled-components';
import type Role from 'logic/roles/Role';
import { validateField, formHasErrors } from 'util/FormsUtils';
import { FormikFormGroup, Select, InputList } from 'components/common';
import { FormikFormGroup, Select, InputList, TimezoneSelect } from 'components/common';
import { Alert, Button, ButtonToolbar, Row, Col, Panel, Input } from 'components/bootstrap';
import { getPathnameWithoutId } from 'util/URLUtils';
import useSendTelemetry from 'logic/telemetry/useSendTelemetry';
@@ -38,6 +38,7 @@ export const STEP_KEY = 'user-synchronization';
// to be able to associate backend validation errors with the form
export const FORM_VALIDATION = {
defaultRoles: { required: true },
defaultUserTimezone: {},
userFullNameAttribute: { required: true },
userNameAttribute: { required: true },
emailAttributes: {},
@@ -180,9 +181,8 @@ const UserSyncStep = ({
<Row>
<Col sm={9} smOffset={3}>
<Panel bsStyle="info">
Changing the static role assignment will only affect new users created via{' '}
{stepsState.authBackendMeta.serviceTitle}! Existing user accounts will be updated on their next login,
or if you edit their roles manually.
Changing the default role and time zone assignments will only affect new users created via{' '}
{stepsState.authBackendMeta.serviceTitle}!
</Panel>
</Col>
</Row>
@@ -209,6 +209,23 @@ const UserSyncStep = ({
)}
</Field>
<Field name="defaultUserTimezone">
{({ field: { name, value, onChange } }) => (
<Input
id="default-user-timezone-select"
help={help.defaultUserTimezone}
label="Default User Time Zone"
labelClassName="col-sm-3"
wrapperClassName="col-sm-9">
<TimezoneSelect
value={value || "Browser's time zone"}
name="timezone"
onChange={(newValue) => onChange({ target: { name, value: newValue } })}
/>
</Input>
)}
</Field>
<Row>
<Col sm={9} smOffset={3}>
<Alert bsStyle="info">

View File

@@ -24,6 +24,7 @@ export default ({
title,
description,
defaultRoles = Immutable.List(),
defaultUserTimezone,
config: {
servers = [],
systemUserDn,
@@ -40,6 +41,7 @@ export default ({
title,
description,
defaultRoles: defaultRoles.join(),
defaultUserTimezone: defaultUserTimezone,
serverHost: servers[0].host,
serverPort: servers[0].port,
systemUserDn,

View File

@@ -67,6 +67,7 @@ export const HELP = {
interface
</span>
),
defaultUserTimezone: <span>Choose the default time zone for new users.</span>,
};
export const AUTH_BACKEND_META = {

View File

@@ -73,6 +73,7 @@ export const HELP = {
interface
</span>
),
defaultUserTimezone: <span>Choose the default time zone for new users.</span>,
emailAttributes: (
<span>
Which LDAP attribute to use for the user&apos;s email address, e.g. <code>mail</code>.<br />

View File

@@ -26,6 +26,7 @@ type InternalState = {
title: string;
description: string;
defaultRoles: Immutable.List<string>;
defaultUserTimezone: string;
config: DirectoryServiceBackendConfig | OktaBackendConfig;
};
@@ -38,6 +39,7 @@ export type AuthenticationBackendJSON = {
title: string;
description: string;
default_roles: Array<string>;
default_user_timezone: string;
config: DirectoryServiceBackendConfig | OktaBackendConfig;
};
@@ -69,6 +71,7 @@ export default class AuthenticationBackend {
title: InternalState['title'],
description: InternalState['description'],
defaultRoles: InternalState['defaultRoles'],
defaultUserTimezone: InternalState['defaultUserTimezone'],
config: InternalState['config'],
) {
this._value = {
@@ -76,6 +79,7 @@ export default class AuthenticationBackend {
title,
description,
defaultRoles,
defaultUserTimezone,
config,
};
}
@@ -96,12 +100,16 @@ export default class AuthenticationBackend {
return this._value.defaultRoles;
}
get defaultUserTimezone(): InternalState['defaultUserTimezone'] {
return this._value.defaultUserTimezone;
}
get config(): InternalState['config'] {
return this._value.config;
}
toBuilder(): Builder {
const { id, title, description, defaultRoles, config } = this._value;
const { id, title, description, defaultRoles, defaultUserTimezone, config } = this._value;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return new Builder(
@@ -110,13 +118,14 @@ export default class AuthenticationBackend {
title,
description,
defaultRoles,
defaultUserTimezone,
config,
}),
);
}
toJSON() {
const { id, title, description, defaultRoles = Immutable.List(), config } = this._value;
const { id, title, description, defaultRoles = Immutable.List(), defaultUserTimezone, config } = this._value;
const formattedConfig = configToJson(config);
@@ -125,16 +134,31 @@ export default class AuthenticationBackend {
title,
description,
default_roles: defaultRoles.toJS(),
default_user_timezone: defaultUserTimezone,
config: formattedConfig,
};
}
static fromJSON(value: AuthenticationBackendJSON) {
const { id, title, description, default_roles: defaultRoles, config } = value;
const {
id,
title,
description,
default_roles: defaultRoles,
default_user_timezone: defaultUserTimezone,
config,
} = value;
const formattedConfig = configFromJson(config);
return new AuthenticationBackend(id, title, description, Immutable.List(defaultRoles), formattedConfig);
return new AuthenticationBackend(
id,
title,
description,
Immutable.List(defaultRoles),
defaultUserTimezone,
formattedConfig,
);
}
static builder(): Builder {
@@ -173,8 +197,8 @@ class Builder {
}
build(): AuthenticationBackend {
const { id, title, description, defaultRoles, config } = this.value.toObject();
const { id, title, description, defaultRoles, defaultUserTimezone, config } = this.value.toObject();
return new AuthenticationBackend(id, title, description, defaultRoles, config);
return new AuthenticationBackend(id, title, description, defaultRoles, defaultUserTimezone, config);
}
}

View File

@@ -53,6 +53,7 @@ export type DirectoryServiceBackendConfigJson = {
export type DirectoryServiceBackend = {
id: AuthenticationBackend['id'];
defaultRoles: AuthenticationBackend['defaultRoles'];
defaultUserTimezone: AuthenticationBackend['defaultUserTimezone'];
title: AuthenticationBackend['title'];
description: AuthenticationBackend['description'];
config: DirectoryServiceBackendConfig;
@@ -62,6 +63,7 @@ export type WizardSubmitPayload = {
title: AuthenticationBackendJSON['title'];
description: AuthenticationBackendJSON['description'];
default_roles: AuthenticationBackendJSON['default_roles'];
default_user_timezone: AuthenticationBackendJSON['default_user_timezone'];
config: DirectoryServiceBackendConfigJson & {
system_user_password:
| (string | { keep_value: true } | { delete_value: true } | { set_value: string | undefined })

View File

@@ -109,6 +109,8 @@ export interface SharedBackendProps {
defaultRoles: AuthenticationBackend['defaultRoles'];
defaultUserTimezone: AuthenticationBackend['defaultUserTimezone'];
title: AuthenticationBackend['title'];
description: AuthenticationBackend['description'];