Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d6390b7
Add a consentHashService
May 11, 2021
2f18a55
Move DB logic to ConsentHashRepository
May 20, 2021
9ec2176
Move consent queries to existing repository
MKodde May 2, 2022
39c0310
Extract ConsentHashServiceInterface
MKodde May 2, 2022
b07fc4c
Integrate upstream consent changes
MKodde May 2, 2022
7a893f3
Add comment to getUnstableAttributesHash
MKodde May 3, 2022
7a1f8f3
Add the `attribute_stable` column to `consent`
MKodde May 3, 2022
f171be3
Add ConsentVersion value object
MKodde May 3, 2022
09dffbd
Integrate stable attribute hash requirements
MKodde May 3, 2022
cc70350
Support NameId objects in consent hash generator
MKodde May 4, 2022
37805e8
Optimize the consent was given methods
MKodde May 5, 2022
f998ae5
Retrieve old/new style attribute hash in one go
MKodde May 5, 2022
ff2af3f
feat(consent): implement consent hash versioning
kayjoosten Mar 6, 2026
069c5db
test(consent): add edge-case coverage
kayjoosten Mar 6, 2026
f05b277
fix(consent): polish follow-up fixes and test configuration
kayjoosten Mar 16, 2026
260c436
feat(consent): add feature toggle for stable hash migration
kayjoosten Mar 17, 2026
8fc8735
refactor: convert ConsentType to a PHP 8.1 backed enum (#1929)
kayjoosten Mar 25, 2026
2cc0385
Drop redundant unserialize/serialize deep copies in ConsentHashService
johanib Mar 25, 2026
dd45410
Merge remote-tracking branch 'origin/feature/stabilize-consent-hash' …
johanib Mar 26, 2026
29cf835
Add description of `feature_stable_consent_hash_migration` to CHANGEL…
johanib Mar 26, 2026
85916c5
Cleanup: Remove unused parameters and properties
johanib Mar 26, 2026
13ce6bc
Make empty-attribute stripping explicit and self-documenting in Conse…
johanib Mar 26, 2026
e93351b
Encapsulate stable consent hash normalisation in a typed value object
johanib Mar 26, 2026
9535caf
Expand test coverage of consent hashing
johanib Mar 26, 2026
a67d076
Annontate deprecated code that will be removed after the next release
johanib Mar 26, 2026
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Changes:
* The `0000-00-00 00:00:00` is added for clarity/consistency, as this is probably the default behaviour of your database already.
* Removed unused index `consent.deleted_at`. Delete this from your production database if it's there.

* Stabilized consent checks
* In order to make the consent hashes more robust, a more consistent way of hashing the user attributes has been introduced
* This feature automatically migrates from the old hashes to the new hashes, cleaning up the old hash.
* However, if blue/green deployments are used or if you want to keep the option open to roll back the EB release, keep the `feature_stable_consent_hash_migration` set to false in order to preserve the old consent hashes.
* Once the new release is fully rolled out, set `feature_stable_consent_hash_migration` to true. This will clean up the old consent hashes upon login. In the next EB release, the old consent hash column will be deleted.

## 7.1.0
SRAM integration

Expand Down
1 change: 1 addition & 0 deletions config/packages/engineblock_features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ parameters:
eb.stepup.sfo.override_engine_entityid: "%feature_stepup_sfo_override_engine_entityid%"
eb.stepup.send_user_attributes: "%feature_stepup_send_user_attributes%"
eb.feature_enable_sram_interrupt: "%feature_enable_sram_interrupt%"
eb.stable_consent_hash_migration: "%feature_stable_consent_hash_migration%"
1 change: 1 addition & 0 deletions config/packages/parameters.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ parameters:
feature_stepup_sfo_override_engine_entityid: false
feature_stepup_send_user_attributes: false
feature_enable_sram_interrupt: false
feature_stable_consent_hash_migration: false

##########################################################################################
## PROFILE SETTINGS
Expand Down
3 changes: 1 addition & 2 deletions config/services/compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ services:
engineblock.compat.corto_model_consent_factory:
class: EngineBlock_Corto_Model_Consent_Factory
arguments:
- "@engineblock.compat.corto_filter_command_factory"
- "@engineblock.compat.database_connection_factory"
- "@engineblock.service.consent.ConsentHashService"

engineblock.compat.saml2_id_generator:
public: true
Expand Down
2 changes: 1 addition & 1 deletion config/services/controllers/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
- '@security.token_storage'
- '@security.access.decision_manager'
- '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration'
- '@OpenConext\EngineBlock\Service\ConsentService'
- '@OpenConext\EngineBlock\Service\Consent\ConsentService'

OpenConext\EngineBlockBundle\Controller\Api\DeprovisionController:
arguments:
Expand Down
9 changes: 8 additions & 1 deletion config/services/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,14 @@ services:
- '@OpenConext\EngineBlock\Metadata\LoaRepository'
- '@logger'

OpenConext\EngineBlock\Service\ConsentService:
engineblock.service.consent.ConsentHashService:
class: OpenConext\EngineBlock\Service\Consent\ConsentHashService
public: false
arguments:
- '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository'
- '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration'

OpenConext\EngineBlock\Service\Consent\ConsentService:
arguments:
- '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository'
- '@OpenConext\EngineBlock\Service\MetadataService'
Expand Down
4 changes: 2 additions & 2 deletions library/EngineBlock/Application/DiContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ public function getAuthenticationLoopGuard()
}

/**
* @return OpenConext\EngineBlock\Service\ConsentService
* @return OpenConext\EngineBlock\Service\Consent\ConsentService
*/
public function getConsentService()
{
return $this->container->get(\OpenConext\EngineBlock\Service\ConsentService::class);
return $this->container->get(\OpenConext\EngineBlock\Service\Consent\ConsentService::class);
}

/**
Expand Down
242 changes: 106 additions & 136 deletions library/EngineBlock/Corto/Model/Consent.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
* limitations under the License.
*/

use Doctrine\DBAL\Statement;
use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery;
use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters;
use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters;
use OpenConext\EngineBlock\Authentication\Value\ConsentVersion;
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
use OpenConext\EngineBlock\Authentication\Value\ConsentType;
use OpenConext\EngineBlock\Service\Consent\ConsentAttributes;
use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface;

class EngineBlock_Corto_Model_Consent
{
/**
* @var string
*/
private $_tableName;

/**
* @var bool
*/
Expand All @@ -37,15 +37,10 @@ class EngineBlock_Corto_Model_Consent
*/
private $_response;
/**
* @var array
* @var array All attributes as an associative array.
*/
private $_responseAttributes;

/**
* @var EngineBlock_Database_ConnectionFactory
*/
private $_databaseConnectionFactory;

/**
* A reflection of the eb.run_all_manipulations_prior_to_consent feature flag
*
Expand All @@ -61,63 +56,82 @@ class EngineBlock_Corto_Model_Consent
private $_consentEnabled;

/**
* @param string $tableName
* @param bool $mustStoreValues
* @param EngineBlock_Saml2_ResponseAnnotationDecorator $response
* @param array $responseAttributes
* @param EngineBlock_Database_ConnectionFactory $databaseConnectionFactory
* @var ConsentHashServiceInterface
*/
private $_hashService;

/**
* @param bool $amPriorToConsentEnabled Is the run_all_manipulations_prior_to_consent feature enabled or not
* @param bool $consentEnabled Is the feature_enable_consent feature enabled or not
*/
public function __construct(
$tableName,
$mustStoreValues,
bool $mustStoreValues,
EngineBlock_Saml2_ResponseAnnotationDecorator $response,
array $responseAttributes,
EngineBlock_Database_ConnectionFactory $databaseConnectionFactory,
$amPriorToConsentEnabled,
$consentEnabled
)
{
$this->_tableName = $tableName;
bool $amPriorToConsentEnabled,
bool $consentEnabled,
ConsentHashServiceInterface $hashService
) {
$this->_mustStoreValues = $mustStoreValues;
$this->_response = $response;
$this->_responseAttributes = $responseAttributes;
$this->_databaseConnectionFactory = $databaseConnectionFactory;
$this->_amPriorToConsentEnabled = $amPriorToConsentEnabled;
$this->_hashService = $hashService;
$this->_consentEnabled = $consentEnabled;
}

public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider)
public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider): ConsentVersion
{
return !$this->_consentEnabled ||
$this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT);
if (!$this->_consentEnabled) {
// Consent disabled: treat as already given (stable — no upgrade needed)
return ConsentVersion::stable();
}
return $this->_hasStoredConsent($serviceProvider, ConsentType::Explicit);
}

public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider)
/**
* Although the user has given consent previously we want to upgrade the deprecated unstable consent
* to the new stable consent type.
* https://www.pivotaltracker.com/story/show/176513931
*
* The caller must pass the ConsentVersion already retrieved by explicitConsentWasGivenFor or
* implicitConsentWasGivenFor to avoid a second identical DB query.
*
* @deprecated Remove after stable consent hash is running in production
*/
public function upgradeAttributeHashFor(ServiceProvider $serviceProvider, ConsentType $consentType, ConsentVersion $consentVersion): void
{
return !$this->_consentEnabled ||
$this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT);
if (!$this->_consentEnabled) {
return;
}
if ($consentVersion->isUnstable()) {
$this->_updateConsent($serviceProvider, $consentType);
}
}

public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider): ConsentVersion
{
if (!$this->_consentEnabled) {
return ConsentVersion::stable();
}
return $this->_hasStoredConsent($serviceProvider, ConsentType::Implicit);
}

public function giveExplicitConsentFor(ServiceProvider $serviceProvider)
public function giveExplicitConsentFor(ServiceProvider $serviceProvider): bool
{
return !$this->_consentEnabled ||
$this->_storeConsent($serviceProvider, ConsentType::TYPE_EXPLICIT);
$this->_storeConsent($serviceProvider, ConsentType::Explicit);
}

public function giveImplicitConsentFor(ServiceProvider $serviceProvider)
public function giveImplicitConsentFor(ServiceProvider $serviceProvider): bool
{
return !$this->_consentEnabled ||
$this->_storeConsent($serviceProvider, ConsentType::TYPE_IMPLICIT);
$this->_storeConsent($serviceProvider, ConsentType::Implicit);
}

/**
* @return Doctrine\DBAL\Connection
*/
protected function _getConsentDatabaseConnection()
public function countTotalConsent(): int
{
return $this->_databaseConnectionFactory->create();
$consentUid = $this->_getConsentUid();
return $this->_hashService->countTotalConsent($consentUid);
}

protected function _getConsentUid()
Expand All @@ -129,116 +143,72 @@ protected function _getConsentUid()
return $this->_response->getNameIdValue();
}

protected function _getAttributesHash($attributes)
/** @deprecated Remove after stable consent hash is running in production */
protected function _getAttributesHash($attributes): string
{
$hashBase = NULL;
if ($this->_mustStoreValues) {
ksort($attributes);
$hashBase = serialize($attributes);
} else {
$names = array_keys($attributes);
sort($names);
$hashBase = implode('|', $names);
}
return sha1($hashBase);
return $this->_hashService->getUnstableAttributesHash($attributes, $this->_mustStoreValues);
}

private function _storeConsent(ServiceProvider $serviceProvider, $consentType)
protected function _getStableAttributesHash($attributes): string
{
$dbh = $this->_getConsentDatabaseConnection();
if (!$dbh) {
return false;
}
$consentAttributes = $this->_mustStoreValues
? ConsentAttributes::withValues($attributes)
: ConsentAttributes::namesOnly($attributes);

return $this->_hashService->getStableConsentHash($consentAttributes);
}

private function _storeConsent(ServiceProvider $serviceProvider, ConsentType $consentType): bool
{
$consentUuid = $this->_getConsentUid();
if(! is_string($consentUuid)){
if (!is_string($consentUuid)) {
return false;
}

$query = "INSERT INTO consent (hashed_user_id, service_id, attribute, consent_type, consent_date, deleted_at)
VALUES (?, ?, ?, ?, NOW(), '0000-00-00 00:00:00')
ON DUPLICATE KEY UPDATE attribute=VALUES(attribute), consent_type=VALUES(consent_type), consent_date=NOW()";
$parameters = array(
sha1($consentUuid),
$serviceProvider->entityId,
$this->_getAttributesHash($this->_responseAttributes),
$consentType,
$parameters = new ConsentStoreParameters(
hashedUserId: sha1($consentUuid),
serviceId: $serviceProvider->entityId,
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
consentType: $consentType->value,
attributeHash: $this->_getAttributesHash($this->_responseAttributes),
);

$statement = $dbh->prepare($query);
if (!$statement) {
throw new EngineBlock_Exception(
"Unable to create a prepared statement to insert consent?!",
EngineBlock_Exception::CODE_CRITICAL
);
}
return $this->_hashService->storeConsentHash($parameters);
}

assert($statement instanceof Statement);
try{
foreach ($parameters as $index => $parameter){
$statement->bindValue($index + 1, $parameter);
}

$statement->executeStatement();
}catch (\Doctrine\DBAL\Exception $e){
throw new EngineBlock_Corto_Module_Services_Exception(
sprintf('Error storing consent: "%s"', var_export($e->getMessage(), true)),
EngineBlock_Exception::CODE_CRITICAL
);
/** @deprecated Remove after stable consent hash is running in production */
private function _updateConsent(ServiceProvider $serviceProvider, ConsentType $consentType): bool
{
$consentUid = $this->_getConsentUid();
if (!is_string($consentUid)) {
return false;
}
return true;

$parameters = new ConsentUpdateParameters(
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
attributeHash: $this->_getAttributesHash($this->_responseAttributes),
hashedUserId: sha1($consentUid),
serviceId: $serviceProvider->entityId,
consentType: $consentType->value,
);

return $this->_hashService->updateConsentHash($parameters);
}

private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType)
private function _hasStoredConsent(ServiceProvider $serviceProvider, ConsentType $consentType): ConsentVersion
{
try {
$dbh = $this->_getConsentDatabaseConnection();
if (!$dbh) {
return false;
}

$attributesHash = $this->_getAttributesHash($this->_responseAttributes);

$consentUuid = $this->_getConsentUid();
if (!is_string($consentUuid)) {
return false;
}

$query = "
SELECT *
FROM {$this->_tableName}
WHERE hashed_user_id = ?
AND service_id = ?
AND attribute = ?
AND consent_type = ?
AND deleted_at IS NULL
";
$hashedUserId = sha1($consentUuid);
$parameters = array(
$hashedUserId,
$serviceProvider->entityId,
$attributesHash,
$consentType,
);

$statement = $dbh->prepare($query);
assert($statement instanceof Statement);
foreach ($parameters as $position => $parameter) {
$statement->bindValue($position + 1, $parameter);
}
$rows = $statement->executeQuery();

if ($rows->rowCount() < 1) {
// No stored consent found
return false;
}

return true;
} catch (PDOException $e) {
throw new EngineBlock_Corto_ProxyServer_Exception(
sprintf('Consent retrieval failed! Error: "%s"', $e->getMessage()),
EngineBlock_Exception::CODE_ALERT
);
$consentUid = $this->_getConsentUid();
if (!is_string($consentUid)) {
return ConsentVersion::notGiven();
}

$query = new ConsentHashQuery(
hashedUserId: sha1($consentUid),
serviceId: $serviceProvider->entityId,
attributeHash: $this->_getAttributesHash($this->_responseAttributes),
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
consentType: $consentType->value,
);
return $this->_hashService->retrieveConsentHash($query);
}
}
Loading