wikimedia/mediawiki-core

View on GitHub
includes/session/SessionProvider.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php
/**
 * MediaWiki session provider base class
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @ingroup Session
 */

namespace MediaWiki\Session;

use ErrorPageError;
use InvalidArgumentException;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Config\Config;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Language\Language;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Request\WebRequest;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MWRestrictions;
use Psr\Log\LoggerInterface;
use Stringable;

/**
 * A SessionProvider provides SessionInfo and support for Session
 *
 * A SessionProvider is responsible for taking a WebRequest and determining
 * the authenticated session that it's a part of. It does this by returning an
 * SessionInfo object with basic information about the session it thinks is
 * associated with the request, namely the session ID and possibly the
 * authenticated user the session belongs to.
 *
 * The SessionProvider also provides for updating the WebResponse with
 * information necessary to provide the client with data that the client will
 * send with later requests, and for populating the Vary and Key headers with
 * the data necessary to correctly vary the cache on these client requests.
 *
 * An important part of the latter is indicating whether it even *can* tell the
 * client to include such data in future requests, via the persistsSessionId()
 * and canChangeUser() methods. The cases are (in order of decreasing
 * commonness):
 *  - Cannot persist ID, no changing User: The request identifies and
 *    authenticates a particular local user, and the client cannot be
 *    instructed to include an arbitrary session ID with future requests. For
 *    example, OAuth or SSL certificate auth.
 *  - Can persist ID and can change User: The client can be instructed to
 *    return at least one piece of arbitrary data, that being the session ID.
 *    The user identity might also be given to the client, otherwise it's saved
 *    in the session data. For example, cookie-based sessions.
 *  - Can persist ID but no changing User: The request uniquely identifies and
 *    authenticates a local user, and the client can be instructed to return an
 *    arbitrary session ID with future requests. For example, HTTP Digest
 *    authentication might somehow use the 'opaque' field as a session ID
 *    (although getting MediaWiki to return 401 responses without breaking
 *    other stuff might be a challenge).
 *  - Cannot persist ID but can change User: I can't think of a way this
 *    would make sense.
 *
 * Note that many methods that are technically "cannot persist ID" could be
 * turned into "can persist ID but not change User" using a session cookie,
 * as implemented by ImmutableSessionProviderWithCookie. If doing so, different
 * session cookie names should be used for different providers to avoid
 * collisions.
 *
 * @stable to extend
 * @ingroup Session
 * @since 1.27
 * @see https://www.mediawiki.org/wiki/Manual:SessionManager_and_AuthManager
 */
abstract class SessionProvider implements Stringable, SessionProviderInterface {

    /** @var LoggerInterface */
    protected $logger;

    /** @var Config */
    protected $config;

    /** @var SessionManager */
    protected $manager;

    /** @var HookContainer */
    private $hookContainer;

    /** @var HookRunner */
    private $hookRunner;

    /** @var UserNameUtils */
    protected $userNameUtils;

    /** @var int Session priority. Used for the default newSessionInfo(), but
     * could be used by subclasses too.
     */
    protected $priority;

    /**
     * @stable to call
     */
    public function __construct() {
        $this->priority = SessionInfo::MIN_PRIORITY + 10;
    }

    /**
     * Initialise with dependencies of a SessionProvider
     *
     * @since 1.37
     * @internal In production code SessionManager will initialize the
     * SessionProvider, in tests SessionProviderTestTrait must be used.
     *
     * @param LoggerInterface $logger
     * @param Config $config
     * @param SessionManager $manager
     * @param HookContainer $hookContainer
     * @param UserNameUtils $userNameUtils
     */
    public function init(
        LoggerInterface $logger,
        Config $config,
        SessionManager $manager,
        HookContainer $hookContainer,
        UserNameUtils $userNameUtils
    ) {
        $this->logger = $logger;
        $this->config = $config;
        $this->manager = $manager;
        $this->hookContainer = $hookContainer;
        $this->hookRunner = new HookRunner( $hookContainer );
        $this->userNameUtils = $userNameUtils;
        $this->postInitSetup();
    }

    /**
     * A provider can override this to do any necessary setup after init()
     * is called.
     *
     * @since 1.37
     * @stable to override
     */
    protected function postInitSetup() {
    }

    /**
     * Sets a logger instance on the object.
     *
     * @deprecated since 1.37. For extension-defined session providers
     * that were using this method to trigger other work, please override
     * SessionProvider::postInitSetup instead. If your extension
     * was using this to explicitly change the logger of an existing
     * SessionProvider object, please file a report on phabricator
     * - there is no non-deprecated way to do this anymore.
     * @param LoggerInterface $logger
     */
    public function setLogger( LoggerInterface $logger ) {
        wfDeprecated( __METHOD__, '1.37' );
        $this->logger = $logger;
    }

    /**
     * Set configuration
     *
     * @deprecated since 1.37. For extension-defined session providers
     * that were using this method to trigger other work, please override
     * SessionProvider::postInitSetup instead. If your extension
     * was using this to explicitly change the Config of an existing
     * SessionProvider object, please file a report on phabricator
     * - there is no non-deprecated way to do this anymore.
     * @param Config $config
     */
    public function setConfig( Config $config ) {
        wfDeprecated( __METHOD__, '1.37' );
        $this->config = $config;
    }

    /**
     * Get the config
     *
     * @since 1.37
     * @return Config
     */
    protected function getConfig() {
        return $this->config;
    }

    /**
     * Set the session manager
     *
     * @deprecated since 1.37. For extension-defined session providers
     * that were using this method to trigger other work, please override
     * SessionProvider::postInitSetup instead. If your extension
     * was using this to explicitly change the SessionManager of an existing
     * SessionProvider object, please file a report on phabricator
     * - there is no non-deprecated way to do this anymore.
     * @param SessionManager $manager
     */
    public function setManager( SessionManager $manager ) {
        wfDeprecated( __METHOD__, '1.37' );
        $this->manager = $manager;
    }

    /**
     * Get the session manager
     * @return SessionManager
     */
    public function getManager() {
        return $this->manager;
    }

    /**
     * @internal
     * @deprecated since 1.37. For extension-defined session providers
     * that were using this method to trigger other work, please override
     * SessionProvider::postInitSetup instead. If your extension
     * was using this to explicitly change the HookContainer of an existing
     * SessionProvider object, please file a report on phabricator
     * - there is no non-deprecated way to do this anymore.
     * @param HookContainer $hookContainer
     */
    public function setHookContainer( $hookContainer ) {
        wfDeprecated( __METHOD__, '1.37' );
        $this->hookContainer = $hookContainer;
        $this->hookRunner = new HookRunner( $hookContainer );
    }

    /**
     * Get the HookContainer
     *
     * @return HookContainer
     */
    protected function getHookContainer(): HookContainer {
        return $this->hookContainer;
    }

    /**
     * Get the HookRunner
     *
     * @internal This is for use by core only. Hook interfaces may be removed
     *   without notice.
     * @since 1.35
     * @return HookRunner
     */
    protected function getHookRunner(): HookRunner {
        return $this->hookRunner;
    }

    /**
     * Provide session info for a request
     *
     * If no session exists for the request, return null. Otherwise return an
     * SessionInfo object identifying the session.
     *
     * If multiple SessionProviders provide sessions, the one with highest
     * priority wins. In case of a tie, an exception is thrown.
     * SessionProviders are encouraged to make priorities user-configurable
     * unless only max-priority makes sense.
     *
     * @warning This will be called early in the MediaWiki setup process,
     *  before $wgUser, $wgLang, $wgOut, $wgTitle, the global parser, and
     *  corresponding pieces of the main RequestContext are set up! If you try
     *  to use these, things *will* break.
     * @note The SessionProvider must not attempt to auto-create users.
     *  MediaWiki will do this later (when it's safe) if the chosen session has
     *  a user with a valid name but no ID.
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param WebRequest $request
     * @return SessionInfo|null
     */
    abstract public function provideSessionInfo( WebRequest $request );

    /**
     * Provide session info for a new, empty session
     *
     * Return null if such a session cannot be created. This base
     * implementation assumes that it only makes sense if a session ID can be
     * persisted and changing users is allowed.
     * @stable to override
     *
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param string|null $id ID to force for the new session
     * @return SessionInfo|null
     *  If non-null, must return true for $info->isIdSafe(); pass true for
     *  $data['idIsSafe'] to ensure this.
     */
    public function newSessionInfo( $id = null ) {
        if ( $this->canChangeUser() && $this->persistsSessionId() ) {
            return new SessionInfo( $this->priority, [
                'id' => $id,
                'provider' => $this,
                'persisted' => false,
                'idIsSafe' => true,
            ] );
        }
        return null;
    }

    /**
     * Merge saved session provider metadata
     *
     * This method will be used to compare the metadata returned by
     * provideSessionInfo() with the saved metadata (which has been returned by
     * provideSessionInfo() the last time the session was saved), and merge the two
     * into the new saved metadata, or abort if the current request is not a valid
     * continuation of the session.
     *
     * The default implementation checks that anything in both arrays is
     * identical, then returns $providedMetadata.
     * @stable to override
     *
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param array $savedMetadata Saved provider metadata
     * @param array $providedMetadata Provided provider metadata (from the SessionInfo)
     * @return array Resulting metadata
     * @throws MetadataMergeException If the metadata cannot be merged.
     *  Such exceptions will be handled by SessionManager and are a safe way of rejecting
     *  a suspicious or incompatible session. The provider is expected to write an
     *  appropriate message to its logger.
     */
    public function mergeMetadata( array $savedMetadata, array $providedMetadata ) {
        foreach ( $providedMetadata as $k => $v ) {
            if ( array_key_exists( $k, $savedMetadata ) && $savedMetadata[$k] !== $v ) {
                $e = new MetadataMergeException( "Key \"$k\" changed" );
                $e->setContext( [
                    'old_value' => $savedMetadata[$k],
                    'new_value' => $v,
                ] );
                throw $e;
            }
        }
        return $providedMetadata;
    }

    /**
     * Validate a loaded SessionInfo and refresh provider metadata
     *
     * This is similar in purpose to the 'SessionCheckInfo' hook, and also
     * allows for updating the provider metadata. On failure, the provider is
     * expected to write an appropriate message to its logger.
     * @stable to override
     *
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param SessionInfo $info Any changes by mergeMetadata() will already be reflected here.
     * @param WebRequest $request
     * @param array|null &$metadata Provider metadata, may be altered.
     * @return bool Return false to reject the SessionInfo after all.
     */
    public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
        return true;
    }

    /**
     * Indicate whether self::persistSession() can save arbitrary session IDs
     *
     * If false, any session passed to self::persistSession() will have an ID
     * that was originally provided by self::provideSessionInfo().
     *
     * If true, the provider may be passed sessions with arbitrary session IDs,
     * and will be expected to manipulate the request in such a way that future
     * requests will cause self::provideSessionInfo() to provide a SessionInfo
     * with that ID.
     *
     * For example, a session provider for OAuth would function by matching the
     * OAuth headers to a particular user, and then would use self::hashToSessionId()
     * to turn the user and OAuth client ID (and maybe also the user token and
     * client secret) into a session ID, and therefore can't easily assign that
     * user+client a different ID. Similarly, a session provider for SSL client
     * certificates would function by matching the certificate to a particular
     * user, and then would use self::hashToSessionId() to turn the user and
     * certificate fingerprint into a session ID, and therefore can't easily
     * assign a different ID either. On the other hand, a provider that saves
     * the session ID into a cookie can easily just set the cookie to a
     * different value.
     *
     * @note For use by \MediaWiki\Session\SessionBackend only
     * @return bool
     */
    abstract public function persistsSessionId();

    /**
     * Indicate whether the user associated with the request can be changed
     *
     * If false, any session passed to self::persistSession() will have a user
     * that was originally provided by self::provideSessionInfo(). Further,
     * self::provideSessionInfo() may only provide sessions that have a user
     * already set.
     *
     * If true, the provider may be passed sessions with arbitrary users, and
     * will be expected to manipulate the request in such a way that future
     * requests will cause self::provideSessionInfo() to provide a SessionInfo
     * with that ID. This can be as simple as not passing any 'userInfo' into
     * SessionInfo's constructor, in which case SessionInfo will load the user
     * from the saved session's metadata.
     *
     * For example, a session provider for OAuth or SSL client certificates
     * would function by matching the OAuth headers or certificate to a
     * particular user, and thus would return false here since it can't
     * arbitrarily assign those OAuth credentials or that certificate to a
     * different user. A session provider that shoves information into cookies,
     * on the other hand, could easily do so.
     *
     * @note For use by \MediaWiki\Session\SessionBackend only
     * @return bool
     */
    abstract public function canChangeUser();

    /**
     * Returns the duration (in seconds) for which users will be remembered when
     * Session::setRememberUser() is set. Null means setting the remember flag will
     * have no effect (and endpoints should not offer that option).
     * @stable to override
     * @return int|null
     */
    public function getRememberUserDuration() {
        return null;
    }

    /**
     * Notification that the session ID was reset
     *
     * No need to persist here, persistSession() will be called if appropriate.
     * @stable to override
     *
     * @note For use by \MediaWiki\Session\SessionBackend only
     * @param SessionBackend $session Session to persist
     * @param string $oldId Old session ID
     * @codeCoverageIgnore
     */
    public function sessionIdWasReset( SessionBackend $session, $oldId ) {
    }

    /**
     * Persist a session into a request/response
     *
     * For example, you might set cookies for the session's ID, user ID, user
     * name, and user token on the passed request.
     *
     * To correctly persist a user independently of the session ID, the
     * provider should persist both the user ID (or name, but preferably the
     * ID) and the user token. When reading the data from the request, it
     * should construct a User object from the ID/name and then verify that the
     * User object's token matches the token included in the request. Should
     * the tokens not match, an anonymous user *must* be passed to
     * SessionInfo::__construct().
     *
     * When persisting a user independently of the session ID,
     * $session->shouldRememberUser() should be checked first. If this returns
     * false, the user token *must not* be saved to cookies. The user name
     * and/or ID may be persisted, and should be used to construct an
     * unverified UserInfo to pass to SessionInfo::__construct().
     *
     * A backend that cannot persist session ID or user info should implement
     * this as a no-op.
     *
     * @note For use by \MediaWiki\Session\SessionBackend only
     * @param SessionBackend $session Session to persist
     * @param WebRequest $request Request into which to persist the session
     */
    abstract public function persistSession( SessionBackend $session, WebRequest $request );

    /**
     * Remove any persisted session from a request/response
     *
     * For example, blank and expire any cookies set by self::persistSession().
     *
     * A backend that cannot persist session ID or user info should implement
     * this as a no-op.
     *
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param WebRequest $request Request from which to remove any session data
     */
    abstract public function unpersistSession( WebRequest $request );

    /**
     * Prevent future sessions for the user
     *
     * If the provider is capable of returning a SessionInfo with a verified
     * UserInfo for the named user in some manner other than by validating
     * against $user->getToken(), steps must be taken to prevent that from
     * occurring in the future. This might add the username to a list, or
     * it might just delete whatever authentication credentials would allow
     * such a session in the first place (e.g. remove all OAuth grants or
     * delete record of the SSL client certificate).
     *
     * The intention is that the named account will never again be usable for
     * normal login (i.e. there is no way to undo the prevention of access).
     *
     * Note that the passed user name might not exist locally (i.e.
     * UserIdentity::getId() === 0); the name should still be
     * prevented, if applicable.
     *
     * @stable to override
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param string $username
     */
    public function preventSessionsForUser( $username ) {
        if ( !$this->canChangeUser() ) {
            throw new \BadMethodCallException(
                __METHOD__ . ' must be implemented when canChangeUser() is false'
            );
        }
    }

    /**
     * Invalidate existing sessions for a user
     *
     * If the provider has its own equivalent of CookieSessionProvider's Token
     * cookie (and doesn't use User::getToken() to implement it), it should
     * reset whatever token it does use here.
     *
     * @stable to override
     * @note For use by \MediaWiki\Session\SessionManager only
     * @param User $user
     */
    public function invalidateSessionsForUser( User $user ) {
    }

    /**
     * Return the HTTP headers that need varying on.
     *
     * The return value is such that someone could theoretically do this:
     * @code
     * foreach ( $provider->getVaryHeaders() as $header => $_ ) {
     *   $outputPage->addVaryHeader( $header );
     * }
     * @endcode
     *
     * @stable to override
     * @note For use by \MediaWiki\Session\SessionManager only
     * @return array<string,null>
     */
    public function getVaryHeaders() {
        return [];
    }

    /**
     * Return the list of cookies that need varying on.
     * @stable to override
     * @note For use by \MediaWiki\Session\SessionManager only
     * @return string[]
     */
    public function getVaryCookies() {
        return [];
    }

    /**
     * Get a suggested username for the login form
     * @stable to override
     * @note For use by \MediaWiki\Session\SessionBackend only
     * @param WebRequest $request
     * @return string|null
     */
    public function suggestLoginUsername( WebRequest $request ) {
        return null;
    }

    /**
     * Fetch the rights allowed the user when the specified session is active.
     *
     * This is mainly meant for allowing the user to restrict access to the account
     * by certain methods; you probably want to use this with GrantsInfo. The returned
     * rights will be intersected with the user's actual rights.
     *
     * @stable to override
     * @param SessionBackend $backend
     * @return null|string[] Allowed user rights, or null to allow all.
     */
    public function getAllowedUserRights( SessionBackend $backend ) {
        if ( $backend->getProvider() !== $this ) {
            // Not that this should ever happen...
            throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
        }

        return null;
    }

    /**
     * Fetch any restrictions imposed on logins or actions when this
     * session is active.
     *
     * @since 1.42
     * @stable to override
     * @return MWRestrictions|null
     */
    public function getRestrictions( ?array $providerMetadata ): ?MWRestrictions {
        return null;
    }

    /**
     * @note Only override this if it makes sense to instantiate multiple
     *  instances of the provider. Value returned must be unique across
     *  configured providers. If you override this, you'll likely need to
     *  override self::describeMessage() as well.
     * @return string
     */
    public function __toString() {
        return static::class;
    }

    /**
     * Return a Message identifying this session type
     *
     * This default implementation takes the class name, lowercases it,
     * replaces backslashes with dashes, and prefixes 'sessionprovider-' to
     * determine the message key. For example, MediaWiki\Session\CookieSessionProvider
     * produces 'sessionprovider-mediawiki-session-cookiesessionprovider'.
     *
     * @stable to override
     * @note If self::__toString() is overridden, this will likely need to be
     *  overridden as well.
     * @warning This will be called early during MediaWiki startup. Do not
     *  use $wgUser, $wgLang, $wgOut, the global Parser, or their equivalents via
     *  RequestContext from this method!
     * @return Message
     */
    protected function describeMessage() {
        return wfMessage(
            'sessionprovider-' . str_replace( '\\', '-', strtolower( static::class ) )
        );
    }

    /**
     * @inheritDoc
     * @stable to override
     */
    public function describe( Language $lang ) {
        $msg = $this->describeMessage();
        $msg->inLanguage( $lang );
        if ( $msg->isDisabled() ) {
            $msg = wfMessage( 'sessionprovider-generic', (string)$this )->inLanguage( $lang );
        }
        return $msg->plain();
    }

    /**
     * @inheritDoc
     * @stable to override
     */
    public function whyNoSession() {
        return null;
    }

    /**
     * Most session providers require protection against CSRF attacks (usually via CSRF tokens)
     *
     * @stable to override
     * @return bool false
     */
    public function safeAgainstCsrf() {
        return false;
    }

    /**
     * Returns true if this provider is exempt from autocreate user permissions check
     *
     * By default returns false, meaning this provider respects the normal rights
     * of anonymous user creation. When true the permission checks will be bypassed
     * and the user will always be created (subject to other limitations, like read
     * only db and such).
     *
     * @stable to override
     * @since 1.42
     */
    public function canAlwaysAutocreate(): bool {
        return false;
    }

    /**
     * Hash data as a session ID
     *
     * Generally this will only be used when self::persistsSessionId() is false and
     * the provider has to base the session ID on the verified user's identity
     * or other static data. The SessionInfo should then typically have the
     * 'forceUse' flag set to avoid persistent session failure if validation of
     * the stored data fails.
     *
     * @param string $data
     * @param string|null $key Defaults to $this->getConfig()->get( MainConfigNames::SecretKey )
     * @return string
     */
    final protected function hashToSessionId( $data, $key = null ) {
        if ( !is_string( $data ) ) {
            throw new InvalidArgumentException(
                '$data must be a string, ' . get_debug_type( $data ) . ' was passed'
            );
        }
        if ( $key !== null && !is_string( $key ) ) {
            throw new InvalidArgumentException(
                '$key must be a string or null, ' . get_debug_type( $key ) . ' was passed'
            );
        }

        $hash = \MWCryptHash::hmac( "$this\n$data",
            $key ?: $this->getConfig()->get( MainConfigNames::SecretKey ), false );
        if ( strlen( $hash ) < 32 ) {
            // Should never happen, even md5 is 128 bits
            // @codeCoverageIgnoreStart
            throw new \UnexpectedValueException( 'Hash function returned less than 128 bits' );
            // @codeCoverageIgnoreEnd
        }
        if ( strlen( $hash ) >= 40 ) {
            $hash = \Wikimedia\base_convert( $hash, 16, 32, 32 );
        }
        return substr( $hash, -32 );
    }

    /**
     * Throw an exception, later. Needed because during session initialization the framework
     * isn't quite ready to handle an exception.
     *
     * This should be called from provideSessionInfo() to fail in
     * a user-friendly way when a session mechanism is used in a way it's not supposed to be used
     * (e.g. invalid credentials or a non-API request when the session provider only supports
     * API requests), and the returned SessionInfo should be returned by provideSessionInfo().
     *
     * @param string $key Key for the error message
     * @param mixed ...$params Parameters as strings.
     * @return SessionInfo An anonymous session info with maximum priority, to force an
     *   anonymous session in case throwing the exception doesn't happen.
     */
    protected function makeException( $key, ...$params ): SessionInfo {
        $msg = wfMessage( $key, $params );

        if ( defined( 'MW_API' ) ) {
            $this->hookContainer->register(
                'ApiBeforeMain',
                // @phan-suppress-next-line PhanPluginNeverReturnFunction Closures should not get doc
                static function () use ( $msg ) {
                    throw ApiUsageException::newWithMessage( null, $msg );
                }
            );
        } elseif ( defined( 'MW_REST_API' ) ) {
            // There are no suitable hooks in the REST API (T252591)
        } else {
            $this->hookContainer->register(
                'BeforeInitialize',
                // @phan-suppress-next-line PhanPluginNeverReturnFunction Closures should not get doc
                static function () use ( $msg ) {
                    RequestContext::getMain()->getOutput()->setStatusCode( 400 );
                    throw new ErrorPageError( 'errorpagetitle', $msg );
                }
            );
            // Disable file cache, which would be looked up before the BeforeInitialize hook call.
            $this->hookContainer->register(
                'HTMLFileCache__useFileCache',
                static function () {
                    return false;
                }
            );
        }

        $id = $this->hashToSessionId( 'bogus' );
        return new SessionInfo( SessionInfo::MAX_PRIORITY, [
            'provider' => $this,
            'id' => $id,
            'userInfo' => UserInfo::newAnonymous(),
            'persisted' => false,
        ] );
    }

}