diff --git a/components/ILIAS/AuthShibboleth/classes/User/class.shibUser.php b/components/ILIAS/AuthShibboleth/classes/User/class.shibUser.php index d664ee23330b..370d6aa2a31a 100755 --- a/components/ILIAS/AuthShibboleth/classes/User/class.shibUser.php +++ b/components/ILIAS/AuthShibboleth/classes/User/class.shibUser.php @@ -17,6 +17,7 @@ *********************************************************************/ use ILIAS\Refinery\String\UTFNormal; +use ILIAS\Data\Privacy\Source\Sources; /** * Class shibUser @@ -71,13 +72,13 @@ public function updateFields(): void $this->setDepartment($this->shibServerData->getDepartment()); } if ($shibConfig->getUpdateStreet()) { - $this->setStreet($this->shibServerData->getStreet()); + $this->setAddressFieldFromShibServer('street', $this->shibServerData->getStreet()); } if ($shibConfig->getUpdateZipcode()) { - $this->setZipcode($this->shibServerData->getZipcode()); + $this->setAddressFieldFromShibServer('zipcode', $this->shibServerData->getZipcode()); } if ($shibConfig->getUpdateCountry()) { - $this->setCountry($this->shibServerData->getCountry()); + $this->setAddressFieldFromShibServer('country', $this->shibServerData->getCountry()); } if ($shibConfig->getUpdatePhoneOffice()) { $this->setPhoneOffice($this->shibServerData->getPhoneOffice()); @@ -118,9 +119,9 @@ public function createFields(): void $this->setUTitle($this->shibServerData->getTitle()); $this->setInstitution($this->shibServerData->getInstitution()); $this->setDepartment($this->shibServerData->getDepartment()); - $this->setStreet($this->shibServerData->getStreet()); - $this->setZipcode($this->shibServerData->getZipcode()); - $this->setCountry($this->shibServerData->getCountry()); + $this->setAddressFieldFromShibServer('street', $this->shibServerData->getStreet()); + $this->setAddressFieldFromShibServer('zipcode', $this->shibServerData->getZipcode()); + $this->setAddressFieldFromShibServer('country', $this->shibServerData->getCountry()); $this->setPhoneOffice($this->shibServerData->getPhoneOffice()); $this->setPhoneHome($this->shibServerData->getPhoneHome()); $this->setPhoneMobile($this->shibServerData->getPhoneMobile()); @@ -226,4 +227,31 @@ protected static function getUsrIdByExtId(string $ext_id): ?int return ($usr !== null && isset($usr->usr_id)) ? (int) $usr->usr_id : null; } + + private function setAddressFieldFromShibServer(string $field, string $value): void + { + $profile_data = $this->getProfileData(); + $address = $profile_data->getPostalAddress(); + if ($address === null) { + // bare instance without a bound postal address — deprecated setter path + match ($field) { + 'street' => $this->setStreet($value), + 'zipcode' => $this->setZipcode($value), + 'country' => $this->setCountry($value), + }; + return; + } + + global $DIC; + $source = $DIC[Sources::class]->externalApi('shibboleth', $field); + $this->replaceProfileData( + $profile_data->withPostalAddress( + match ($field) { + 'street' => $address->withStreet($value, $source), + 'zipcode' => $address->withZipcode($value, $source), + 'country' => $address->withCountry($value, $source), + } + ) + ); + } } diff --git a/components/ILIAS/Certificate/PRIVACY_DATA.md b/components/ILIAS/Certificate/PRIVACY_DATA.md new file mode 100644 index 000000000000..98bb2bb4e6b0 --- /dev/null +++ b/components/ILIAS/Certificate/PRIVACY_DATA.md @@ -0,0 +1,31 @@ +# Privacy Data Usage – Certificate + +> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on 2026-07-23. +> Do not edit manually – re-run the generator after code changes. + +## Summary + +| Category | Count | +|---|---| +| Stored in DB | 0 | +| Displayed to user | 0 | +| Passed to component | 1 | +| Technical processing | 0 | +| Unmigrated (legacy access) | 0 | + +## Passed to other components + +_Data resolved and forwarded to another component._ + +| Type | Target component | Reason | File | +|---|---|---|---| +| `PostalAddress` | `Certificate` | certificate_placeholders | `Certificate/classes/Placeholder/Values/class.ilDefaultPlaceholderValues.php:146` | + +## GDPR relevance + +| GDPR aspect | Details | +|---|---| +| Personal data categories | `PostalAddress` | +| Data recipients | `Certificate` | +| Legal basis | _To be filled in manually_ | +| Retention period | _To be filled in manually_ | diff --git a/components/ILIAS/Certificate/classes/Placeholder/Values/class.ilDefaultPlaceholderValues.php b/components/ILIAS/Certificate/classes/Placeholder/Values/class.ilDefaultPlaceholderValues.php index 93a139a1c21e..2f099708340f 100755 --- a/components/ILIAS/Certificate/classes/Placeholder/Values/class.ilDefaultPlaceholderValues.php +++ b/components/ILIAS/Certificate/classes/Placeholder/Values/class.ilDefaultPlaceholderValues.php @@ -36,6 +36,7 @@ class ilDefaultPlaceholderValues implements ilCertificatePlaceholderValues private readonly ilCertificateUtilHelper $utilHelper; private readonly ilUserDefinedFieldsPlaceholderValues $userDefinedFieldsPlaceholderValues; private readonly Factory $uuid_factory; + private readonly \ILIAS\Data\Privacy\Purpose\Purposes $purposes; private ?ilLanguage $user_language = null; @@ -48,7 +49,8 @@ public function __construct( ?ilCertificateUtilHelper $utilHelper = null, ?ilUserDefinedFieldsPlaceholderValues $userDefinedFieldsPlaceholderValues = null, ?ILIAS\Data\UUID\Factory $uuid_factory = null, - ?int $birthdayDateFormat = null + ?int $birthdayDateFormat = null, + ?ILIAS\Data\Privacy\Purpose\Purposes $purposes = null ) { $this->objectHelper = $objectHelper ?? new ilCertificateObjectHelper(); $this->dateHelper = $dateHelper ?? new ilCertificateDateHelper(); @@ -76,6 +78,7 @@ public function __construct( new ilCertificateUtilHelper() ); $this->uuid_factory = $uuid_factory ?? new ILIAS\Data\UUID\Factory(); + $this->purposes = $purposes ?? $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class]; $this->placeholder = [ 'CERTIFICATE_ID' => '', @@ -140,10 +143,13 @@ public function getPlaceholderValues(int $userId, int $objId): array $placeholder['USER_BIRTHDAY'] = $this->utilHelper->prepareFormOutput((trim($birthday))); $placeholder['USER_INSTITUTION'] = $this->utilHelper->prepareFormOutput((trim($user->getInstitution()))); $placeholder['USER_DEPARTMENT'] = $this->utilHelper->prepareFormOutput((trim($user->getDepartment()))); - $placeholder['USER_STREET'] = $this->utilHelper->prepareFormOutput((trim($user->getStreet()))); - $placeholder['USER_CITY'] = $this->utilHelper->prepareFormOutput((trim($user->getCity()))); - $placeholder['USER_ZIPCODE'] = $this->utilHelper->prepareFormOutput((trim($user->getZipcode()))); - $placeholder['USER_COUNTRY'] = $this->utilHelper->prepareFormOutput((trim($user->getCountry()))); + $postal_address = $user->getProfileData()->getPostalAddress()?->resolve( + $this->purposes->passToComponent('Certificate', 'certificate_placeholders') + ); + $placeholder['USER_STREET'] = $this->utilHelper->prepareFormOutput(trim($postal_address?->street ?? '')); + $placeholder['USER_CITY'] = $this->utilHelper->prepareFormOutput(trim($postal_address?->city ?? '')); + $placeholder['USER_ZIPCODE'] = $this->utilHelper->prepareFormOutput(trim($postal_address?->zipcode ?? '')); + $placeholder['USER_COUNTRY'] = $this->utilHelper->prepareFormOutput(trim($postal_address?->country ?? '')); $placeholder['USER_MATRICULATION'] = $this->utilHelper->prepareFormOutput((trim($user->getMatriculation()))); $placeholder['DATE'] = $this->utilHelper->prepareFormOutput((trim($this->dateHelper->formatDate( time(), diff --git a/components/ILIAS/Certificate/tests/ilDefaultPlaceholderValuesTest.php b/components/ILIAS/Certificate/tests/ilDefaultPlaceholderValuesTest.php index d19a258a65e2..10b65ac7a54d 100755 --- a/components/ILIAS/Certificate/tests/ilDefaultPlaceholderValuesTest.php +++ b/components/ILIAS/Certificate/tests/ilDefaultPlaceholderValuesTest.php @@ -37,10 +37,7 @@ public function testGetPlaceholderValues(): void 'getBirthday', 'getInstitution', 'getDepartment', - 'getStreet', - 'getCity', - 'getZipcode', - 'getCountry', + 'getProfileData', 'getMatriculation' ] ) @@ -83,20 +80,8 @@ public function testGetPlaceholderValues(): void ->willReturn(''); $objectMock->expects($this->once()) - ->method('getStreet') - ->willReturn(''); - - $objectMock->expects($this->once()) - ->method('getCity') - ->willReturn(''); - - $objectMock->expects($this->once()) - ->method('getZipcode') - ->willReturn(''); - - $objectMock->expects($this->once()) - ->method('getCountry') - ->willReturn(''); + ->method('getProfileData') + ->willReturn(new ILIAS\User\Profile\Data()); $objectMock->expects($this->once()) ->method('getMatriculation') @@ -164,7 +149,8 @@ public function testGetPlaceholderValues(): void $utilHelper, $userDefinePlaceholderMock, $uuid_factory_mock, - 2 + 2, + new ILIAS\Data\Privacy\Purpose\Purposes() ); $placeHolderObject->setUserLanguage($language); @@ -253,7 +239,8 @@ public function testGetPlaceholderValuesForPreview(): void $utilHelper, $userDefinePlaceholderMock, $uuid_factory_mock, - 2 + 2, + new ILIAS\Data\Privacy\Purpose\Purposes() ); $result = $placeHolderObject->getPlaceholderValuesForPreview( diff --git a/components/ILIAS/Data/Data.php b/components/ILIAS/Data/Data.php index ca5254068677..1117783b2df3 100644 --- a/components/ILIAS/Data/Data.php +++ b/components/ILIAS/Data/Data.php @@ -34,5 +34,31 @@ public function init( ): void { $provide[\ILIAS\Data\Factory::class] = static fn() => new \ILIAS\Data\Factory(); + + $define[] = \ILIAS\Data\Privacy\Services::class; + + $implement[\ILIAS\Data\Privacy\Services::class] = static fn() => + new \ILIAS\Data\Privacy\ServicesImpl( + $internal[\ILIAS\Data\Privacy\Logger\CompositeLogger::class], + $internal[\ILIAS\Data\Privacy\Source\Sources::class], + $internal[\ILIAS\Data\Privacy\Purpose\Purposes::class], + ); + + $internal[\ILIAS\Data\Privacy\Logger\CompositeLogger::class] = static fn() => + new \ILIAS\Data\Privacy\Logger\CompositeLogger( + $seek[\ILIAS\Data\Privacy\Logger\PrivacyLogger::class] + ); + + $internal[\ILIAS\Data\Privacy\Source\Sources::class] = static fn() => + new \ILIAS\Data\Privacy\Source\Sources(); + + $internal[\ILIAS\Data\Privacy\Purpose\Purposes::class] = static fn() => + new \ILIAS\Data\Privacy\Purpose\Purposes(); + + $provide[\ILIAS\Data\Privacy\Source\Sources::class] = static fn() => + $internal[\ILIAS\Data\Privacy\Source\Sources::class]; + + $provide[\ILIAS\Data\Privacy\Purpose\Purposes::class] = static fn() => + $internal[\ILIAS\Data\Privacy\Purpose\Purposes::class]; } } diff --git a/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveCollector.php b/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveCollector.php new file mode 100644 index 000000000000..000d7c22a17c --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveCollector.php @@ -0,0 +1,202 @@ +, + * component: string, + * line: int + * }> + */ +final class PrivacyResolveCollector implements Collector +{ + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, Scope $scope): ?array + { + if (!$node->name instanceof Node\Identifier) { + return null; + } + if ($node->name->name !== 'resolve') { + return null; + } + + $caller_type = $scope->getType($node->var); + $privacy_base = new ObjectType(\ILIAS\Data\Privacy\PrivacyDataType::class); + if (!$privacy_base->isSuperTypeOf($caller_type)->yes()) { + return null; + } + + $purpose_class = 'unknown'; + $purpose_args = []; + $arg = $node->args[0] ?? null; + if ($arg instanceof Node\Arg) { + [$purpose_class, $purpose_args] = $this->extractPurposeInfo($arg->value, $scope); + } + + return [ + 'privacy_type' => $caller_type->describe(VerbosityLevel::typeOnly()), + 'purpose_class' => $purpose_class, + 'purpose_args' => $purpose_args, + 'component' => $this->extractComponent($scope->getFile()), + 'line' => $node->getLine(), + ]; + } + + /** + * @return array{string, list} + */ + private function extractPurposeInfo(Node\Expr $expr, Scope $scope): array + { + // purposes built via the Purposes factory: the call's return type + // is the concrete purpose class, the call arguments carry the data + if ($expr instanceof Node\Expr\MethodCall && $expr->name instanceof Node\Identifier) { + foreach ($scope->getType($expr)->getObjectClassNames() as $class) { + if (str_starts_with($class, 'ILIAS\\Data\\Privacy\\Purpose\\')) { + return [ + basename(str_replace('\\', '/', $class)), + $this->extractArgs($expr->args, $scope), + ]; + } + } + + // caller type unresolvable (e.g. factory obtained from the + // untyped legacy container): map the factory method name to + // its declared return type + $method = $expr->name->name; + if (method_exists(\ILIAS\Data\Privacy\Purpose\Purposes::class, $method)) { + $return_type = new \ReflectionMethod(\ILIAS\Data\Privacy\Purpose\Purposes::class, $method) + ->getReturnType(); + if ($return_type instanceof \ReflectionNamedType + && is_a($return_type->getName(), \ILIAS\Data\Privacy\Purpose\Purpose::class, true) + ) { + return [ + basename(str_replace('\\', '/', $return_type->getName())), + $this->extractArgs($expr->args, $scope), + ]; + } + } + } + + if (!$expr instanceof Node\Expr\New_) { + return ['dynamic', [$scope->getType($expr)->describe(VerbosityLevel::typeOnly())]]; + } + if (!$expr->class instanceof Node\Name) { + return ['unknown', []]; + } + + $class = $scope->resolveName($expr->class); + $short = basename(str_replace('\\', '/', $class)); + + return [$short, $this->extractArgs($expr->args, $scope)]; + } + + /** + * @param array $raw_args + * @return list + */ + private function extractArgs(array $raw_args, Scope $scope): array + { + $args = []; + foreach ($raw_args as $arg) { + if (!$arg instanceof Node\Arg) { + continue; + } + $known_source = $this->tryDescribeKnownSourceCall($arg->value, $scope); + if ($known_source !== null) { + $args[] = $known_source; + } elseif ($arg->value instanceof Node\Scalar\String_) { + $args[] = $arg->value->value; + } elseif ($arg->value instanceof Node\Expr\New_ + && $arg->value->class instanceof Node\Name + ) { + $inner_args = array_map( + static fn($a): string => $a instanceof Node\Arg && $a->value instanceof Node\Scalar\String_ + ? $a->value->value + : '?', + $arg->value->args + ); + $args[] = basename(str_replace('\\', '/', (string) $arg->value->class)) + . '(' . implode(', ', $inner_args) . ')'; + } else { + $args[] = $scope->getType($arg->value)->describe(VerbosityLevel::typeOnly()); + } + } + + return $args; + } + + /** + * The KnownSources catalogue getters are pure, parameterless value + * factories — evaluate them to render the actual table/columns in + * the report instead of just the return type. + */ + private function tryDescribeKnownSourceCall(Node\Expr $expr, Scope $scope): ?string + { + if (!$expr instanceof Node\Expr\MethodCall || !$expr->name instanceof Node\Identifier) { + return null; + } + + foreach ($scope->getType($expr->var)->getObjectClassNames() as $class) { + if (!str_starts_with($class, 'ILIAS\\Data\\Privacy\\Source\\Known\\')) { + continue; + } + $method = $expr->name->name; + if (!method_exists($class, $method)) { + continue; + } + if (new \ReflectionMethod($class, $method)->getNumberOfRequiredParameters() > 0) { + continue; + } + $result = (new $class())->{$method}(); + if ($result instanceof \ILIAS\Data\Privacy\Source\Source) { + return $result->describe(); + } + } + + return null; + } + + private function extractComponent(string $file_path): string + { + if (preg_match('#components/ILIAS/([^/]+)/#', str_replace('\\', '/', $file_path), $m) === 1) { + return $m[1]; + } + return 'Unknown'; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveReportRule.php b/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveReportRule.php new file mode 100644 index 000000000000..6569f91dab21 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveReportRule.php @@ -0,0 +1,64 @@ + + */ +final class PrivacyResolveReportRule implements Rule +{ + public const string MARKER = 'PRIVACY-RESOLVE '; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $errors = []; + foreach ($node->get(PrivacyResolveCollector::class) as $file => $entries) { + foreach ($entries as $entry) { + $errors[] = RuleErrorBuilder::message( + self::MARKER . json_encode($entry, JSON_THROW_ON_ERROR) + ) + ->file($file) + ->line($entry['line']) + ->identifier('ilias.privacy.resolveReport') + ->build(); + } + } + return $errors; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/CollectorResultParser.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/CollectorResultParser.php new file mode 100644 index 000000000000..8771a5fef651 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/CollectorResultParser.php @@ -0,0 +1,70 @@ +, component: string, file: string, line: int}> + */ + public function parse(string $json_file): array + { + $raw = file_get_contents($json_file); + if ($raw === false) { + throw new \RuntimeException("Cannot read {$json_file}."); + } + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + $entries = []; + foreach ($data['files'] ?? [] as $file => $file_result) { + foreach ($file_result['messages'] ?? [] as $message) { + $text = $message['message'] ?? ''; + if (!str_starts_with($text, PrivacyResolveReportRule::MARKER)) { + continue; + } + $payload = json_decode( + substr($text, strlen(PrivacyResolveReportRule::MARKER)), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($payload)) { + continue; + } + $entries[] = [ + 'privacy_type' => $payload['privacy_type'] ?? 'unknown', + 'purpose_class' => $payload['purpose_class'] ?? 'unknown', + 'purpose_args' => $payload['purpose_args'] ?? [], + 'component' => $payload['component'] ?? 'Unknown', + 'file' => (string) $file, + 'line' => $payload['line'] ?? 0, + ]; + } + } + return $entries; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/ComponentReport.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/ComponentReport.php new file mode 100644 index 000000000000..93a3ce6abf9f --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/ComponentReport.php @@ -0,0 +1,58 @@ +> + */ + private array $by_category = []; + + public function add(ResolveEntry $entry): void + { + $this->by_category[$entry->category->name][] = $entry; + } + + public function isEmpty(): bool + { + return $this->by_category === []; + } + + /** + * @return list + */ + public function get(EntryCategory $category): array + { + return $this->by_category[$category->name] ?? []; + } + + /** + * @return list + */ + public function all(): array + { + return array_merge(...array_values($this->by_category) ?: [[]]); + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/EntryCategory.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/EntryCategory.php new file mode 100644 index 000000000000..c765486109ae --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/EntryCategory.php @@ -0,0 +1,30 @@ +format('Y-m-d'); + $md = "# Privacy Data Usage – {$component}\n\n"; + $md .= "> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on {$now}. \n"; + $md .= "> Do not edit manually – re-run the generator after code changes.\n\n"; + + if ($report->isEmpty()) { + return $md . "_This component does not resolve any privacy-protected data._\n"; + } + + $md .= "## Summary\n\n"; + $md .= $this->table(['Category', 'Count'], [ + ['Stored in DB', (string) count($report->get(EntryCategory::Store))], + ['Displayed to user', (string) count($report->get(EntryCategory::Display))], + ['Passed to component', (string) count($report->get(EntryCategory::Pass))], + ['Technical processing', (string) count($report->get(EntryCategory::Technical))], + ['Unmigrated (legacy access)', (string) count($report->get(EntryCategory::Legacy))], + ]); + + if ($report->get(EntryCategory::Store) !== []) { + $md .= "\n## Stored data\n\n_Data resolved for persistence in a database table._\n\n"; + $md .= $this->table( + ['Type', 'Target table · column', 'File'], + array_map(fn(ResolveEntry $e): array => [ + "`{$e->privacy_type}`", + '`' . implode(' · ', $e->purpose_args) . '`', + $this->link($e), + ], $report->get(EntryCategory::Store)) + ); + } + + if ($report->get(EntryCategory::Display) !== []) { + $md .= "\n## Displayed to user\n\n_Data resolved for rendering in the UI._\n\n"; + $md .= $this->table( + ['Type', 'UI context', 'File'], + array_map(fn(ResolveEntry $e): array => [ + "`{$e->privacy_type}`", + '`' . ($e->purpose_args[0] ?? '—') . '`', + $this->link($e), + ], $report->get(EntryCategory::Display)) + ); + } + + if ($report->get(EntryCategory::Pass) !== []) { + $md .= "\n## Passed to other components\n\n_Data resolved and forwarded to another component._\n\n"; + $md .= $this->table( + ['Type', 'Target component', 'Reason', 'File'], + array_map(fn(ResolveEntry $e): array => [ + "`{$e->privacy_type}`", + '`' . ($e->purpose_args[0] ?? '—') . '`', + $e->purpose_args[1] ?? '—', + $this->link($e), + ], $report->get(EntryCategory::Pass)) + ); + } + + if ($report->get(EntryCategory::Technical) !== []) { + $md .= "\n## Technical processing\n\n_Data resolved for internal computation. Not persisted or displayed._\n\n"; + $md .= $this->table( + ['Type', 'Operation', 'File'], + array_map(fn(ResolveEntry $e): array => [ + "`{$e->privacy_type}`", + '`' . ($e->purpose_args[0] ?? '—') . '`', + $this->link($e), + ], $report->get(EntryCategory::Technical)) + ); + } + + if ($report->get(EntryCategory::Legacy) !== []) { + $md .= "\n## Unmigrated (legacy access)\n\n"; + $md .= "_Resolves through deprecated code paths without a classified purpose." + . " Each entry is a migration TODO._\n\n"; + $md .= $this->table( + ['Type', 'Hint', 'File'], + array_map(fn(ResolveEntry $e): array => [ + "`{$e->privacy_type}`", + '`' . ($e->purpose_args[0] ?? '—') . '`', + $this->link($e), + ], $report->get(EntryCategory::Legacy)) + ); + } + + $md .= "\n## GDPR relevance\n\n"; + $types = array_values(array_unique(array_map( + static fn(ResolveEntry $e): string => "`{$e->privacy_type}`", + $report->all() + ))); + $targets = array_values(array_unique(array_filter(array_map( + static fn(ResolveEntry $e): string => implode(' · ', $e->purpose_args), + $report->get(EntryCategory::Store) + )))); + $recipients = array_values(array_unique(array_filter(array_map( + static fn(ResolveEntry $e): string => $e->purpose_args[0] ?? '', + $report->get(EntryCategory::Pass) + )))); + + $rows = [['Personal data categories', implode(', ', $types)]]; + if ($targets !== []) { + $rows[] = ['Storage locations', implode(', ', array_map(static fn($t): string => "`{$t}`", $targets))]; + } + if ($recipients !== []) { + $rows[] = ['Data recipients', implode(', ', array_map(static fn($r): string => "`{$r}`", $recipients))]; + } + $rows[] = ['Legal basis', '_To be filled in manually_']; + $rows[] = ['Retention period', '_To be filled in manually_']; + + return $md . $this->table(['GDPR aspect', 'Details'], $rows); + } + + /** + * @param list $headers + * @param list> $rows + */ + private function table(array $headers, array $rows): string + { + if ($rows === []) { + return "_None._\n"; + } + + $line = static fn(array $cells): string => '| ' . implode(' | ', $cells) . " |\n"; + $separator = '|' . str_repeat('---|', count($headers)) . "\n"; + + return $line($headers) . $separator . implode('', array_map($line, $rows)); + } + + private function link(ResolveEntry $entry): string + { + $relative = preg_replace('#.*/components/ILIAS/#', '', str_replace('\\', '/', $entry->file)); + return "`{$relative}:{$entry->line}`"; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/PrivacyDocGenerator.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/PrivacyDocGenerator.php new file mode 100644 index 000000000000..f779e5eec441 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/PrivacyDocGenerator.php @@ -0,0 +1,71 @@ + component => written file path + */ + public function run(): array + { + $parser = new CollectorResultParser(); + $classifier = new PurposeClassifier(); + $renderer = new MarkdownRenderer(); + + /** @var array $reports */ + $reports = []; + foreach ($parser->parse($this->phpstan_output_file) as $raw) { + $component = $raw['component']; + if ($this->filter_component !== null && $component !== $this->filter_component) { + continue; + } + $reports[$component] ??= new ComponentReport(); + $reports[$component]->add($classifier->classify($raw)); + } + ksort($reports); + + $written = []; + foreach ($reports as $component => $report) { + $path = "{$this->components_base_dir}/{$component}/{$this->target_filename}"; + if (!$this->dry_run) { + file_put_contents($path, $renderer->render($component, $report)); + } + $written[$component] = $path; + } + return $written; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/PurposeClassifier.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/PurposeClassifier.php new file mode 100644 index 000000000000..a427655c7723 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/PurposeClassifier.php @@ -0,0 +1,59 @@ + EntryCategory::Store, + 'DisplayToUser' => EntryCategory::Display, + 'PassToComponent' => EntryCategory::Pass, + 'TechnicalProcessing' => EntryCategory::Technical, + 'LegacyAccess' => EntryCategory::Legacy, + ]; + + /** + * @param array{privacy_type: string, purpose_class: string, purpose_args: list, file: string, line: int} $raw + */ + public function classify(array $raw): ResolveEntry + { + $short = $this->short($raw['purpose_class']); + + return new ResolveEntry( + privacy_type: $this->short($raw['privacy_type']), + purpose_class: $short, + purpose_args: $raw['purpose_args'], + category: self::MAP[$short] ?? EntryCategory::Technical, + file: $raw['file'], + line: $raw['line'], + ); + } + + private function short(string $fqcn): string + { + // strip a generic suffix like "PostalAddress" + $fqcn = preg_replace('/<.*>$/', '', $fqcn) ?? $fqcn; + return basename(str_replace('\\', '/', $fqcn)); + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Generator/ResolveEntry.php b/components/ILIAS/Data/PHPStan/Privacy/Generator/ResolveEntry.php new file mode 100644 index 000000000000..135efcad93c4 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Generator/ResolveEntry.php @@ -0,0 +1,37 @@ + $purpose_args + */ + public function __construct( + public string $privacy_type, + public string $purpose_class, + public array $purpose_args, + public EntryCategory $category, + public string $file, + public int $line, + ) { + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Rules/NoRawValueAccessRule.php b/components/ILIAS/Data/PHPStan/Privacy/Rules/NoRawValueAccessRule.php new file mode 100644 index 000000000000..3b57b8836d0b --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Rules/NoRawValueAccessRule.php @@ -0,0 +1,79 @@ + + */ +final class NoRawValueAccessRule implements Rule +{ + private const array FORBIDDEN_FUNCTIONS = [ + 'var_dump', 'print_r', 'var_export', + 'json_encode', 'serialize', + ]; + + public function getNodeType(): string + { + return Node\Expr\FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name) { + return []; + } + + $function_name = strtolower((string) $node->name); + if (!in_array($function_name, self::FORBIDDEN_FUNCTIONS, true)) { + return []; + } + + $errors = []; + foreach ($node->args as $arg) { + if (!$arg instanceof Node\Arg) { + continue; + } + $type = $scope->getType($arg->value); + if (new ObjectType(\ILIAS\Data\Privacy\PrivacyDataType::class)->isSuperTypeOf($type)->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf( + 'PrivacyDataType passed directly to %s(). Call ->resolve() with a Purpose first.', + $function_name + ) + ) + ->line($node->getLine()) + ->identifier('ilias.privacy.rawAccess') + ->build(); + } + } + + return $errors; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Rules/PreferKnownSourcesRule.php b/components/ILIAS/Data/PHPStan/Privacy/Rules/PreferKnownSourcesRule.php new file mode 100644 index 000000000000..72d576b3792d --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Rules/PreferKnownSourcesRule.php @@ -0,0 +1,126 @@ + + */ +final class PreferKnownSourcesRule implements Rule +{ + private const array LOCATOR_CLASSES = [ + \ILIAS\Data\Privacy\Source\DbTableColumn::class, + \ILIAS\Data\Privacy\Source\DbTableColumns::class, + ]; + + private const string CATALOGUE_PATH = 'components/ILIAS/Data/src/Privacy/Source/Known'; + + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + $class = $scope->resolveName($node->class); + if (!in_array($class, self::LOCATOR_CLASSES, true)) { + return []; + } + + // the catalogue itself is the one legitimate place for literals + if (str_contains(str_replace('\\', '/', $scope->getFile()), self::CATALOGUE_PATH)) { + return []; + } + + if ($this->hasEscapeAnnotation($node, $scope)) { + return []; + } + + $literals = []; + foreach ($node->args as $arg) { + if ($arg instanceof Node\Arg && $arg->value instanceof Node\Scalar\String_) { + $literals[] = $arg->value->value; + } + } + + if ($literals === []) { + return []; + } + + $short = basename(str_replace('\\', '/', $class)); + + return [ + RuleErrorBuilder::message( + sprintf( + 'Direct %s("%s") — add this column to the KnownSources catalogue' + . ' or annotate with @privacy-undocumented.', + $short, + implode('", "', $literals) + ) + ) + ->line($node->getLine()) + ->identifier('ilias.privacy.unknownSource') + ->tip('Add a getter to components/ILIAS/Data/src/Privacy/Source/Known.') + ->build(), + ]; + } + + /** + * The escape hatch counts when the annotation is attached to the + * node itself, sits on the same line, or on the line directly above + * (parser comment attribution does not reach expressions nested in + * array items or argument lists). + */ + private function hasEscapeAnnotation(Node\Expr\New_ $node, Scope $scope): bool + { + foreach ($node->getComments() as $comment) { + if (str_contains($comment->getText(), '@privacy-undocumented')) { + return true; + } + } + + $lines = file($scope->getFile()); + if ($lines === false) { + return false; + } + foreach ([$node->getLine() - 1, $node->getLine() - 2] as $index) { + if (isset($lines[$index]) && str_contains($lines[$index], '@privacy-undocumented')) { + return true; + } + } + + return false; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/Rules/StoreInTableTargetRule.php b/components/ILIAS/Data/PHPStan/Privacy/Rules/StoreInTableTargetRule.php new file mode 100644 index 000000000000..05101b2c5428 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/Rules/StoreInTableTargetRule.php @@ -0,0 +1,83 @@ + + */ +final class StoreInTableTargetRule implements Rule +{ + public function getNodeType(): string + { + return Node\Expr\New_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->class instanceof Node\Name) { + return []; + } + + if ($scope->resolveName($node->class) !== \ILIAS\Data\Privacy\Purpose\StoreInTable::class) { + return []; + } + + $arg = $node->args[0] ?? null; + if (!$arg instanceof Node\Arg) { + return [ + RuleErrorBuilder::message('StoreInTable requires a DbTarget argument.') + ->line($node->getLine()) + ->identifier('ilias.privacy.storeInTableMissingTarget') + ->build(), + ]; + } + + $arg_type = $scope->getType($arg->value); + $expected_type = new ObjectType(\ILIAS\Data\Privacy\Source\DbTarget::class); + + if (!$expected_type->isSuperTypeOf($arg_type)->yes()) { + return [ + RuleErrorBuilder::message( + sprintf( + 'StoreInTable expects a DbTarget (DbTableColumn/DbTableColumns), got %s.' + . ' Use the KnownSources catalogue.', + $arg_type->describe(VerbosityLevel::typeOnly()) + ) + ) + ->line($node->getLine()) + ->identifier('ilias.privacy.storeInTableWrongTarget') + ->build(), + ]; + } + + return []; + } +} diff --git a/components/ILIAS/Data/PHPStan/Privacy/privacy-analysis.neon b/components/ILIAS/Data/PHPStan/Privacy/privacy-analysis.neon new file mode 100644 index 000000000000..14191a22712e --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/privacy-analysis.neon @@ -0,0 +1,22 @@ +# Collection-only configuration for the privacy documentation generator +# (scripts/Privacy/generate-privacy-docs.php). Collects every resolve() +# call on a PrivacyDataType and emits it as a report message so it +# survives into PHPStan's JSON output. +# +# Never include this in a linting configuration — the report rule emits +# one "error" per resolve() call by design. +services: + - + class: ILIAS\Data\Privacy\PHPStan\Collector\PrivacyResolveCollector + tags: [phpstan.collector] + - + class: ILIAS\Data\Privacy\PHPStan\Collector\PrivacyResolveReportRule + tags: [phpstan.rules.rule] + +parameters: + level: 0 + bootstrapFiles: + - ../../../../../scripts/PHPStan/constants.php + excludePaths: + - '*/tests/*' + - '*/test/*' diff --git a/components/ILIAS/Data/PHPStan/Privacy/privacy.neon b/components/ILIAS/Data/PHPStan/Privacy/privacy.neon new file mode 100644 index 000000000000..52e05bf209d6 --- /dev/null +++ b/components/ILIAS/Data/PHPStan/Privacy/privacy.neon @@ -0,0 +1,24 @@ +# Privacy data type linting rules. +# +# Include this configuration (or register the services below) in a PHPStan +# run to enforce correct usage of ILIAS\Data\Privacy types: +# +# ./vendor/composer/vendor/bin/phpstan analyse \ +# -c components/ILIAS/Data/PHPStan/Privacy/privacy.neon \ +# --autoload-file=vendor/composer/vendor/autoload.php \ +# components/ILIAS/ +services: + - + class: ILIAS\Data\Privacy\PHPStan\Rules\NoRawValueAccessRule + tags: [phpstan.rules.rule] + - + class: ILIAS\Data\Privacy\PHPStan\Rules\StoreInTableTargetRule + tags: [phpstan.rules.rule] + - + class: ILIAS\Data\Privacy\PHPStan\Rules\PreferKnownSourcesRule + tags: [phpstan.rules.rule] + +parameters: + level: 0 + bootstrapFiles: + - ../../../../../scripts/PHPStan/constants.php diff --git a/components/ILIAS/Data/README.md b/components/ILIAS/Data/README.md index 9b2e858ecde0..b6d0f304a501 100755 --- a/components/ILIAS/Data/README.md +++ b/components/ILIAS/Data/README.md @@ -28,6 +28,7 @@ interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt). * [OpenGraph Metadata](#opengraphmetadata) * [LanguageTag](#languagetag) * [Description](#description) +* [Privacy Data Types](#privacy-data-types) Other examples for data types that could (and maybe should) be added here: @@ -713,3 +714,19 @@ $describes_an_object = $df->object( ); ?> +``` + +## Privacy Data Types + +Privacy Data Types wrap personal data (e.g. a user's postal address) in +value objects that know where the value came from and hand out the raw +value only against an explicitly stated purpose. Every access is +reported to an audit logger and is statically analysable, so +per-component privacy documentation can be generated from the code. + +They are not obtained through the `Factory` of this component but +through the bootstrapped `ILIAS\Data\Privacy\Services`, which binds the +audit logger. + +See [src/Privacy/README.md](src/Privacy/README.md) for the concept, the +list of available types, sources and purposes, and usage examples. diff --git a/components/ILIAS/Data/src/Privacy/AbstractPrivacyDataType.php b/components/ILIAS/Data/src/Privacy/AbstractPrivacyDataType.php new file mode 100644 index 000000000000..f50dc7fbf245 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/AbstractPrivacyDataType.php @@ -0,0 +1,78 @@ + + */ +abstract readonly class AbstractPrivacyDataType implements PrivacyDataType +{ + /** + * @param T $value + */ + final public function __construct( + protected mixed $value, + protected Source $source, + protected ?PrivacyLogger $logger = null, + ) { + } + + public function resolve(Purpose $purpose): mixed + { + $this->logger?->log($this, $purpose); + return $this->value; + } + + public function getSource(): Source + { + return $this->source; + } + + /** + * Derives a new instance around a transformed value without resolving. + * + * Transformation is not disclosure: the raw value never leaves the + * type, therefore no purpose is required and nothing is logged. The + * given source replaces the current one (last write wins). + * + * @param T $value + */ + protected function withValue(mixed $value, Source $source): static + { + return new static($value, $source, $this->logger); + } + + public function __toString(): string + { + return static::class . '(***) from ' . $this->source->describe(); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Factory.php b/components/ILIAS/Data/src/Privacy/Factory.php new file mode 100644 index 000000000000..7fe3ebaa9644 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Factory.php @@ -0,0 +1,47 @@ +logger); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Logger/CompositeLogger.php b/components/ILIAS/Data/src/Privacy/Logger/CompositeLogger.php new file mode 100644 index 000000000000..248d957bb9b6 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Logger/CompositeLogger.php @@ -0,0 +1,53 @@ + + */ + private array $loggers; + + /** + * @param iterable $loggers + */ + public function __construct(iterable $loggers) + { + $this->loggers = is_array($loggers) + ? array_values($loggers) + : iterator_to_array($loggers, false); + } + + public function log(PrivacyDataType $data, Purpose $purpose): void + { + foreach ($this->loggers as $logger) { + $logger->log($data, $purpose); + } + } +} diff --git a/components/ILIAS/Data/src/Privacy/Logger/PrivacyLogger.php b/components/ILIAS/Data/src/Privacy/Logger/PrivacyLogger.php new file mode 100644 index 000000000000..fb58dae9e7be --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Logger/PrivacyLogger.php @@ -0,0 +1,40 @@ +ui_context; + } + + public function describe(): string + { + return "display_to_user:{$this->ui_context}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Purpose/LegacyAccess.php b/components/ILIAS/Data/src/Privacy/Purpose/LegacyAccess.php new file mode 100644 index 000000000000..79ec49dce376 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Purpose/LegacyAccess.php @@ -0,0 +1,51 @@ +hint; + } + + public function describe(): string + { + return "legacy:{$this->hint}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Purpose/PassToComponent.php b/components/ILIAS/Data/src/Privacy/Purpose/PassToComponent.php new file mode 100644 index 000000000000..c86333786d63 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Purpose/PassToComponent.php @@ -0,0 +1,55 @@ +component; + } + + public function getReason(): string + { + return $this->reason; + } + + public function describe(): string + { + return "pass_to:{$this->component} ({$this->reason})"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Purpose/Purpose.php b/components/ILIAS/Data/src/Privacy/Purpose/Purpose.php new file mode 100644 index 000000000000..4299dc95dbeb --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Purpose/Purpose.php @@ -0,0 +1,34 @@ +target; + } + + public function describe(): string + { + return 'store_in:' . $this->target->describe(); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Purpose/TechnicalProcessing.php b/components/ILIAS/Data/src/Privacy/Purpose/TechnicalProcessing.php new file mode 100644 index 000000000000..e445f166e139 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Purpose/TechnicalProcessing.php @@ -0,0 +1,46 @@ +operation; + } + + public function describe(): string + { + return "technical:{$this->operation}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/README.md b/components/ILIAS/Data/src/Privacy/README.md new file mode 100644 index 000000000000..664d86b7d797 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/README.md @@ -0,0 +1,305 @@ +# Privacy Data Types + +Privacy Data Types make personal data machine-readable at the PHP type +level. A wrapped value knows **where it came from** (its `Source`) and +hands out the raw value only through `resolve()`, stating **why it is +being accessed** (a `Purpose`). Every resolve call is reported to the +audit logger and is statically collectable, so per-component privacy +documentation can be generated from the code itself. + +This complements the handwritten `PRIVACY.md` files of the components: it +answers, from the code, which components store, display, and forward +personal data — the basis for GDPR documentation and Art. 15 access +requests. + +**Table of Contents** +* [Concept](#concept) +* [Scope](#scope) +* [Available types](#available-types) +* [Sources](#sources) +* [Purposes](#purposes) +* [Usage](#usage) + * [Obtaining the service](#obtaining-the-service) + * [Wrapping values (repositories)](#wrapping-values-repositories) + * [Resolving values (consumers)](#resolving-values-consumers) + * [Transforming without resolving](#transforming-without-resolving) + * [Testing](#testing) +* [Contributing a logger backend](#contributing-a-logger-backend) +* [Static analysis and generated documentation](#static-analysis-and-generated-documentation) +* [Migrating a component](#migrating-a-component) + +## Concept + +``` +PrivacyDataType + ├── getSource(): Source where does the value come from? + ├── resolve(Purpose): T release the raw value (logged) + └── __toString(): string masked, e.g. "…\PostalAddress(***) from usr_data.(street,…)" +``` + +Three properties fall out of this design: + +1. **Audit trail.** Every `resolve()` call is passed to the configured + `PrivacyLogger` with type, source, and purpose — never the raw value. +2. **Static analysability.** A PHPStan collector records every + `resolve()` call site, its purpose, and its component. A generator + turns this into per-component privacy documentation. +3. **Leak protection.** `__toString()` masks the value; PHPStan rules + forbid passing a wrapper to `var_dump()`, `json_encode()`, + `serialize()` and friends. + +Passing a wrapper around is free and unlogged — only *releasing the raw +value* requires a purpose. Prefer handing the wrapper itself to other +services and resolving as late as possible. + +## Scope + +Wrapped: personal data (address, email, phone, name fields, IDs, ...). + +Explicitly **not** wrapped: passwords and authentication secrets — those +must never travel through generic value objects at all. + +## Available types + +| Type | Wrapped value | Notes | +|---|---|---| +| `Types\PostalAddress` | `Types\PostalAddressValue` (street, city, zipcode, country) | The residential address of a user; compound value over four `usr_data` columns | + +Further types (`UserId`, `EmailAddress`, `PhoneNumber`, ...) will be +added as the respective data fields are migrated. The rollout +deliberately starts small — see the feature wiki entry for the staged +migration strategy. + +## Sources + +A `Source\Source` describes the origin of a value: + +| Source | Use when the value comes from | `describe()` | +|---|---|---| +| `DbTableColumn` | a single database column | `usr_data.street` | +| `DbTableColumns` | several columns of one table forming one compound value | `usr_data.(street,city,zipcode,country)` | +| `UserInput` | a form or other direct user input | `user_input:profile_form` | +| `ExternalApi` | an external system (LDAP, Shibboleth, XML import, ...) | `api:shibboleth.homePostalAddress` | +| `SessionData` | the current session | `session:user_id` | +| `LegacySource` | an unmigrated write path where the origin is unknown — migration TODO | `legacy:ilObjUser::setStreet` | + +Do **not** instantiate `DbTableColumn`/`DbTableColumns` with string +literals in consuming code. Use the named getters of the +`Source\Known\KnownSources` catalogue (`$privacy->sources()`), so that +every known personal data column is registered in exactly one place. +This is enforced by a PHPStan rule; the escape hatch for genuinely +undocumented columns is a `@privacy-undocumented` annotation. + +`DbTableColumn` and `DbTableColumns` implement `Source\DbTarget` and can +therefore also act as the target of a `StoreInTable` purpose. + +## Purposes + +A `Purpose\Purpose` states why the raw value is needed, at the point of +access: + +| Purpose | Use when | `describe()` | +|---|---|---| +| `StoreInTable` | persisting to a database table (target column(s) required) | `store_in:usr_data.(street,…)` | +| `DisplayToUser` | rendering in the UI | `display_to_user:public_profile` | +| `PassToComponent` | handing the raw value to another component (prefer passing the wrapper instead!) | `pass_to:Mail (signature)` | +| `TechnicalProcessing` | hashing, comparison, condition checks — no output | `technical:pseudonymisation` | +| `LegacyAccess` | an unmigrated read path where the purpose is unknown — migration TODO, never use in new code | `legacy:profile_data_getter` | + +## Usage + +### Obtaining the service + +The entry point is the `ILIAS\Data\Privacy\Services` interface. It is +defined and implemented by the Data component bootstrap. + +Modern component (component bootstrap): + +```php +// MyComponent.php +$internal[MyRepository::class] = static fn() => + new MyRepository( + $use[\ILIAS\Data\Privacy\Services::class], + // ... + ); +``` + +The source and purpose factories are also available individually — +via `$pull` in a component bootstrap or via their FQN keys in the +legacy container: + +```php +// MyComponent.php +$internal[MyRepository::class] = static fn() => + new MyRepository( + $pull[\ILIAS\Data\Privacy\Source\Sources::class], + $pull[\ILIAS\Data\Privacy\Purpose\Purposes::class], + // ... + ); + +// legacy code +$sources = $DIC[\ILIAS\Data\Privacy\Source\Sources::class]; +$purposes = $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class]; +``` + +`Sources` offers the ad-hoc constructors (`userInput()`, +`externalApi()`, `sessionData()`, `legacy()`) and inherits the +`KnownSources` catalogue (`$sources->user()->postalAddress()`). +`Purposes` offers `storeInTable()`, `displayToUser()`, +`passToComponent()`, `technicalProcessing()`, and `legacyAccess()`. +Prefer the factories over `new` at call sites; direct instantiation +remains supported (and is used by detached value objects such as +`Profile\Data` for their legacy markers). + +Legacy code (service locator): + +```php +global $DIC; +$privacy = $DIC[\ILIAS\Data\Privacy\Services::class]; +``` + +Never construct concrete privacy types with `new` in production code — +only the factory binds the audit logger. (Tests may construct directly.) + +### Wrapping values (repositories) + +Wrap at the point where the value enters PHP, typically when reading +from the database: + +```php +$address = $this->privacy->factory()->postalAddress( + new PostalAddressValue( + $row->street ?? '', + $row->city ?? '', + $row->zipcode ?? '', + $row->country ?? '' + ), + $this->privacy->sources()->user()->postalAddress() +); +``` + +Values entering from a form or an external system use the corresponding +source: + +```php +$address = $privacy->factory()->postalAddress( + new PostalAddressValue(street: $input), + $privacy->sources()->userInput('profile_form') +); +``` + +### Resolving values (consumers) + +Passing the wrapper around needs no purpose. Only release the raw value +where it is actually used: + +```php +// Persisting — the target column(s) become part of the audit trail: +$value = $data->getPostalAddress()->resolve( + $privacy->purposes()->storeInTable($privacy->sources()->user()->postalAddress()) +); +$fields['street'] = [\ilDBConstants::T_TEXT, $value->street]; + +// Rendering: +$street = $address->resolve($privacy->purposes()->displayToUser('public_profile'))->street; + +// Technical processing, no output: +$hash = hash('sha256', serialize( + $address->resolve($privacy->purposes()->technicalProcessing('pseudonymisation')) +)); +``` + +### Transforming without resolving + +Changing a value is not disclosure. The withers derive a new instance +without logging; the given source replaces the previous one: + +```php +$changed = $address->withStreet('Sidestreet 7', $sources->userInput('profile_form')); +``` + +### Testing + +`components/ILIAS/Data/tests/Privacy/Fixtures` provides an +`InMemoryPrivacyLogger` with assertion helpers, the +`PrivacyDataTypeAssertions` trait, and `UnitTestSource` / +`UnitTestPurpose` for constructing and resolving instances in tests +(never use these in production code — and never use `LegacySource` / +`LegacyAccess` in tests, those mark unmigrated production paths): + +```php +$logger = new InMemoryPrivacyLogger(); +$factory = new Factory($logger); + +$address = $factory->postalAddress($value, new UserInput('test')); +$address->resolve(new DisplayToUser('test_context')); + +$logger->assertLoggedOnce(); +$logger->assertLastPurposeIs('display_to_user:test_context'); +``` + +## Contributing a logger backend + +The audit trail backend is deliberately pluggable and **no backend is +shipped yet** (storage and retention are open community decisions). The +Data component collects all contributed backends into a composite; with +none contributed, logging is a no-op. + +A component provides a backend via its bootstrap: + +```php +// MyComponent.php +$contribute[\ILIAS\Data\Privacy\Logger\PrivacyLogger::class] = static fn() => + new MyPrivacyLoggerBackend(/* lazy dependencies only! */); +``` + +Rules for backends: + +- record **metadata only** (type, source, purpose, timestamp, acting + user) — never the raw value; +- the constructor is executed at bootstrap build time: no DB, no DIC, no + HTTP access there — inject closures and evaluate them at `log()` time. + +## Static analysis and generated documentation + +The PHPStan extension lives in +`components/ILIAS/Data/PHPStan/Privacy` and provides: + +- `NoRawValueAccessRule` — a `PrivacyDataType` must not be passed to + `var_dump`, `print_r`, `var_export`, `json_encode`, `serialize`; +- `StoreInTableTargetRule` — `new StoreInTable(...)` requires a + `DbTarget` object; +- `PreferKnownSourcesRule` — `new DbTableColumn('lit', 'lit')` outside + the `Known` catalogue is flagged (`@privacy-undocumented` escape + hatch); +- `PrivacyResolveCollector` — records every `resolve()` call site. + +`resolve()` is typed as the wrapped `T` by PHPStan's native generics +resolution — no extension needed. + +The generator turns the collector output into per-component +documentation: + +``` +php scripts/Privacy/generate-privacy-docs.php --run-phpstan [--component=User] [--dry-run] +``` + +By default it writes `PRIVACY_DATA.md` next to each component's +handwritten `PRIVACY.md`; `--target=privacy-md` overwrites `PRIVACY.md` +itself (to be used once the community decides to replace the handwritten +files). Resolves through `LegacyAccess`/`LegacySource` are listed in a +separate "Unmigrated (legacy access)" section, making migration progress +visible per component. + +## Migrating a component + +1. Wrap the value in the component's repository/DTO where it is read + (source: `KnownSources` catalogue) and written (`StoreInTable`). +2. Keep existing getters/setters working as `@deprecated` delegates that + resolve with `LegacyAccess` (getters) and write with `LegacySource` + (setters) — nothing breaks, every unmigrated access shows up in the + audit trail and the generated report. +3. Migrate consumers call site by call site to real purposes — one + commit per consuming component. +4. When no `LegacyAccess` entries remain for a field, remove the + deprecated accessors. diff --git a/components/ILIAS/Data/src/Privacy/Services.php b/components/ILIAS/Data/src/Privacy/Services.php new file mode 100644 index 000000000000..42a6ab039b16 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Services.php @@ -0,0 +1,50 @@ +factory ??= new Factory($this->logger); + } + + public function sources(): Sources + { + return $this->sources; + } + + public function purposes(): Purposes + { + return $this->purposes; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/DbTableColumn.php b/components/ILIAS/Data/src/Privacy/Source/DbTableColumn.php new file mode 100644 index 000000000000..7301646f0b33 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/DbTableColumn.php @@ -0,0 +1,52 @@ +table; + } + + public function getColumn(): string + { + return $this->column; + } + + public function describe(): string + { + return "{$this->table}.{$this->column}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/DbTableColumns.php b/components/ILIAS/Data/src/Privacy/Source/DbTableColumns.php new file mode 100644 index 000000000000..1a439f16e8d8 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/DbTableColumns.php @@ -0,0 +1,61 @@ + + */ + private array $columns; + + public function __construct( + private string $table, + string ...$columns, + ) { + if ($columns === []) { + throw new \InvalidArgumentException('At least one column is required.'); + } + $this->columns = array_values($columns); + } + + public function getTable(): string + { + return $this->table; + } + + /** + * @return non-empty-list + */ + public function getColumns(): array + { + return $this->columns; + } + + public function describe(): string + { + return $this->table . '.(' . implode(',', $this->columns) . ')'; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/DbTarget.php b/components/ILIAS/Data/src/Privacy/Source/DbTarget.php new file mode 100644 index 000000000000..e78613cea1d2 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/DbTarget.php @@ -0,0 +1,31 @@ +service; + } + + public function getField(): string + { + return $this->field; + } + + public function describe(): string + { + return "api:{$this->service}.{$this->field}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/Known/KnownSources.php b/components/ILIAS/Data/src/Privacy/Source/Known/KnownSources.php new file mode 100644 index 000000000000..238939d3a85a --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/Known/KnownSources.php @@ -0,0 +1,38 @@ +user ??= new UserSources(); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/Known/UserSources.php b/components/ILIAS/Data/src/Privacy/Source/Known/UserSources.php new file mode 100644 index 000000000000..56ee0ef96f60 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/Known/UserSources.php @@ -0,0 +1,170 @@ +hint; + } + + public function describe(): string + { + return "legacy:{$this->hint}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/SessionData.php b/components/ILIAS/Data/src/Privacy/Source/SessionData.php new file mode 100644 index 000000000000..94a9c62965cd --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/SessionData.php @@ -0,0 +1,42 @@ +key; + } + + public function describe(): string + { + return "session:{$this->key}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/Source.php b/components/ILIAS/Data/src/Privacy/Source/Source.php new file mode 100644 index 000000000000..bda1fc10f43f --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/Source.php @@ -0,0 +1,32 @@ +user()->postalAddress()`). + * + * Obtain it via {@see \ILIAS\Data\Privacy\Services::sources()}, via + * `$pull[Sources::class]` in a component bootstrap, or via + * `$DIC[Sources::class]` in legacy code. + * + * Deliberately not offered here: ad-hoc DbTableColumn(s) construction — + * database columns must be registered in the KnownSources catalogue. + */ +class Sources extends KnownSources +{ + /** + * @param string $context e.g. "profile_form", "registration_form" + */ + public function userInput(string $context): UserInput + { + return new UserInput($context); + } + + /** + * @param string $service e.g. "shibboleth", "ldap", "xml_import" + * @param string $field the attribute/field name in that system + */ + public function externalApi(string $service, string $field): ExternalApi + { + return new ExternalApi($service, $field); + } + + public function sessionData(string $key): SessionData + { + return new SessionData($key); + } + + /** + * Never use this in new code — state the real source instead. + */ + public function legacy(string $hint = 'unclassified'): LegacySource + { + return new LegacySource($hint); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Source/UserInput.php b/components/ILIAS/Data/src/Privacy/Source/UserInput.php new file mode 100644 index 000000000000..57426f08c831 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Source/UserInput.php @@ -0,0 +1,45 @@ +context; + } + + public function describe(): string + { + return "user_input:{$this->context}"; + } +} diff --git a/components/ILIAS/Data/src/Privacy/Types/PostalAddress.php b/components/ILIAS/Data/src/Privacy/Types/PostalAddress.php new file mode 100644 index 000000000000..0d65fd12cc85 --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Types/PostalAddress.php @@ -0,0 +1,88 @@ + + */ +final readonly class PostalAddress extends AbstractPrivacyDataType +{ + public function withStreet(string $street, Source $source): self + { + return $this->withValue( + new PostalAddressValue( + $street, + $this->value->city, + $this->value->zipcode, + $this->value->country + ), + $source + ); + } + + public function withCity(string $city, Source $source): self + { + return $this->withValue( + new PostalAddressValue( + $this->value->street, + $city, + $this->value->zipcode, + $this->value->country + ), + $source + ); + } + + public function withZipcode(string $zipcode, Source $source): self + { + return $this->withValue( + new PostalAddressValue( + $this->value->street, + $this->value->city, + $zipcode, + $this->value->country + ), + $source + ); + } + + public function withCountry(string $country, Source $source): self + { + return $this->withValue( + new PostalAddressValue( + $this->value->street, + $this->value->city, + $this->value->zipcode, + $country + ), + $source + ); + } +} diff --git a/components/ILIAS/Data/src/Privacy/Types/PostalAddressValue.php b/components/ILIAS/Data/src/Privacy/Types/PostalAddressValue.php new file mode 100644 index 000000000000..09538378dc7f --- /dev/null +++ b/components/ILIAS/Data/src/Privacy/Types/PostalAddressValue.php @@ -0,0 +1,38 @@ +logger = new InMemoryPrivacyLogger(); + $this->type = new ConcretePrivacyDataType( + self::RAW_VALUE, + new UserInput('some_form'), + $this->logger + ); + } + + public function testResolveReturnsTheRawValue(): void + { + $this->assertResolvesTo( + $this->type, + self::RAW_VALUE, + new TechnicalProcessing('comparison') + ); + } + + public function testResolveLogsTheCall(): void + { + $this->type->resolve(new TechnicalProcessing('comparison')); + + $this->logger->assertLoggedOnce(); + $this->logger->assertLastPurposeIs('technical:comparison'); + $this->logger->assertLastSourceIs('user_input:some_form'); + $this->logger->assertLastDataTypeIs(ConcretePrivacyDataType::class); + } + + public function testResolveWithoutLoggerDoesNotThrow(): void + { + $type = new ConcretePrivacyDataType( + self::RAW_VALUE, + new UserInput('some_form') + ); + + $this->assertSame( + self::RAW_VALUE, + $type->resolve(new TechnicalProcessing('comparison')) + ); + } + + public function testResolveLogsEachCallSeparately(): void + { + $this->type->resolve(new TechnicalProcessing('first')); + $this->type->resolve(new TechnicalProcessing('second')); + $this->type->resolve(new TechnicalProcessing('third')); + + $this->logger->assertLoggedTimes(3); + $this->logger->assertContainsPurpose('technical:first'); + $this->logger->assertContainsPurpose('technical:second'); + $this->logger->assertContainsPurpose('technical:third'); + } + + public function testToStringDoesNotExposeValue(): void + { + $this->assertToStringDoesNotExposeValue($this->type, self::RAW_VALUE); + } + + public function testToStringContainsClassName(): void + { + $this->assertStringContainsString( + ConcretePrivacyDataType::class, + (string) $this->type + ); + } + + public function testToStringContainsSourceDescription(): void + { + $this->assertStringContainsString( + 'user_input:some_form', + (string) $this->type + ); + } + + public function testGetSourceReturnsCorrectSource(): void + { + $this->assertSourceDescribes($this->type, 'user_input:some_form'); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/FactoryTest.php b/components/ILIAS/Data/tests/Privacy/FactoryTest.php new file mode 100644 index 000000000000..1f88f3c2829d --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/FactoryTest.php @@ -0,0 +1,76 @@ +postalAddress( + new PostalAddressValue('Mainstreet 5', 'Berne', '3011', 'CH'), + new UserInput('profile_form') + ); + + $this->assertInstanceOf(PostalAddress::class, $address); + $this->assertSame('user_input:profile_form', $address->getSource()->describe()); + } + + public function testCreatedTypesLogToTheBoundLogger(): void + { + $logger = new InMemoryPrivacyLogger(); + $factory = new Factory($logger); + + $address = $factory->postalAddress( + new PostalAddressValue('Mainstreet 5', 'Berne', '3011', 'CH'), + new UserInput('profile_form') + ); + $address->resolve(new DisplayToUser('public_profile')); + + $logger->assertLoggedOnce(); + $logger->assertLastPurposeIs('display_to_user:public_profile'); + $logger->assertLastDataTypeIs(PostalAddress::class); + } + + public function testServicesExposeFactorySourcesAndPurposes(): void + { + $services = new ServicesImpl(new CompositeLogger([]), new Sources(), new Purposes()); + + $this->assertSame($services->factory(), $services->factory()); + $this->assertSame($services->sources(), $services->sources()); + $this->assertSame($services->purposes(), $services->purposes()); + $this->assertSame( + 'usr_data.(street,city,zipcode,country)', + $services->sources()->user()->postalAddress()->describe() + ); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Fixtures/ConcretePrivacyDataType.php b/components/ILIAS/Data/tests/Privacy/Fixtures/ConcretePrivacyDataType.php new file mode 100644 index 000000000000..f3811c3df444 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Fixtures/ConcretePrivacyDataType.php @@ -0,0 +1,32 @@ + + */ +final readonly class ConcretePrivacyDataType extends AbstractPrivacyDataType +{ +} diff --git a/components/ILIAS/Data/tests/Privacy/Fixtures/InMemoryPrivacyLogger.php b/components/ILIAS/Data/tests/Privacy/Fixtures/InMemoryPrivacyLogger.php new file mode 100644 index 000000000000..e16168c772ce --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Fixtures/InMemoryPrivacyLogger.php @@ -0,0 +1,102 @@ + + */ + private array $entries = []; + + public function log(PrivacyDataType $data, Purpose $purpose): void + { + $this->entries[] = [ + 'data_type' => $data::class, + 'source' => $data->getSource()->describe(), + 'purpose' => $purpose->describe(), + 'timestamp' => time(), + ]; + } + + /** + * @return list + */ + public function getEntries(): array + { + return $this->entries; + } + + public function reset(): void + { + $this->entries = []; + } + + public function assertLoggedOnce(): void + { + Assert::assertCount(1, $this->entries); + } + + public function assertLoggedTimes(int $times): void + { + Assert::assertCount($times, $this->entries); + } + + public function assertNothingLogged(): void + { + Assert::assertSame([], $this->entries); + } + + public function assertLastPurposeIs(string $expected): void + { + Assert::assertNotEmpty($this->entries); + Assert::assertSame($expected, $this->entries[array_key_last($this->entries)]['purpose']); + } + + public function assertLastSourceIs(string $expected): void + { + Assert::assertNotEmpty($this->entries); + Assert::assertSame($expected, $this->entries[array_key_last($this->entries)]['source']); + } + + public function assertLastDataTypeIs(string $expected): void + { + Assert::assertNotEmpty($this->entries); + Assert::assertSame($expected, $this->entries[array_key_last($this->entries)]['data_type']); + } + + public function assertContainsPurpose(string $purpose_describe): void + { + Assert::assertContains( + $purpose_describe, + array_column($this->entries, 'purpose') + ); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Fixtures/PrivacyDataTypeAssertions.php b/components/ILIAS/Data/tests/Privacy/Fixtures/PrivacyDataTypeAssertions.php new file mode 100644 index 000000000000..4b5055568a62 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Fixtures/PrivacyDataTypeAssertions.php @@ -0,0 +1,64 @@ +resolve($purpose)); + } + + protected function assertSourceDescribes( + PrivacyDataType $type, + string $expected + ): void { + Assert::assertSame($expected, $type->getSource()->describe()); + } + + protected function assertResolveTriggersLog( + PrivacyDataType $type, + Purpose $purpose, + InMemoryPrivacyLogger $logger + ): void { + $count_before = count($logger->getEntries()); + $type->resolve($purpose); + Assert::assertCount($count_before + 1, $logger->getEntries()); + $logger->assertLastPurposeIs($purpose->describe()); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestPurpose.php b/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestPurpose.php new file mode 100644 index 000000000000..f748c56ebae7 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestPurpose.php @@ -0,0 +1,42 @@ +context}"; + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestSource.php b/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestSource.php new file mode 100644 index 000000000000..57b6e416b14c --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestSource.php @@ -0,0 +1,42 @@ +context}"; + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Logger/CompositeLoggerTest.php b/components/ILIAS/Data/tests/Privacy/Logger/CompositeLoggerTest.php new file mode 100644 index 000000000000..55f81a9988ff --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Logger/CompositeLoggerTest.php @@ -0,0 +1,76 @@ +assertSame('raw', $type->resolve(new TechnicalProcessing('noop'))); + } + + public function testFansOutToAllBackends(): void + { + $first = new InMemoryPrivacyLogger(); + $second = new InMemoryPrivacyLogger(); + + $type = new ConcretePrivacyDataType( + 'raw', + new SessionData('key'), + new CompositeLogger([$first, $second]) + ); + $type->resolve(new TechnicalProcessing('comparison')); + + $first->assertLoggedOnce(); + $second->assertLoggedOnce(); + $first->assertLastPurposeIs('technical:comparison'); + $second->assertLastPurposeIs('technical:comparison'); + } + + public function testAcceptsGenerators(): void + { + $backend = new InMemoryPrivacyLogger(); + $generator = (static function () use ($backend): \Generator { + yield $backend; + })(); + + $type = new ConcretePrivacyDataType( + 'raw', + new SessionData('key'), + new CompositeLogger($generator) + ); + $type->resolve(new TechnicalProcessing('comparison')); + + $backend->assertLoggedOnce(); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/Fixtures/resolve-calls.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/Fixtures/resolve-calls.php new file mode 100644 index 000000000000..1153b30b549f --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/Fixtures/resolve-calls.php @@ -0,0 +1,64 @@ + $dynamic_purpose_class + */ +function resolveCalls( + PostalAddress $address, + Purposes $purposes, + Sources $sources, + Purpose $purpose, + mixed $untyped, + NotAPrivacyType $other, + string $dynamic_purpose_class, + string $context_variable +): void { + $address->resolve(new DisplayToUser('fixture_context')); + $address->resolve($purposes->displayToUser('factory_context')); + $address->resolve($untyped->passToComponent('Mail', 'fixture_reason')); + $address->resolve($purposes->storeInTable($sources->user()->postalAddress())); + $address->resolve(new StoreInTable(new DbTableColumn('tmp_table', 'tmp_column'))); + $address->resolve($purpose); + $other->resolve($purpose); + $address->getSource(); + $address->{'resolve'}($purpose); + $address->resolve(new $dynamic_purpose_class('dynamic_class_context')); + $address->resolve(new DisplayToUser($context_variable)); + $address->resolve(new DisplayToUser($purposes->technicalProcessing('x')->describe())); +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/PrivacyResolveCollectorTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/PrivacyResolveCollectorTest.php new file mode 100644 index 000000000000..6ddf821c9a9a --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Collector/PrivacyResolveCollectorTest.php @@ -0,0 +1,68 @@ + + */ +class PrivacyResolveCollectorTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new PrivacyResolveReportRule(); + } + + protected function getCollectors(): array + { + return [new PrivacyResolveCollector()]; + } + + public function testCollectsEveryResolveCallSite(): void + { + $message = static fn(string $purpose_class, array $purpose_args, int $line): string => + PrivacyResolveReportRule::MARKER . json_encode([ + 'privacy_type' => 'ILIAS\\Data\\Privacy\\Types\\PostalAddress', + 'purpose_class' => $purpose_class, + 'purpose_args' => $purpose_args, + 'component' => 'Data', + 'line' => $line, + ], JSON_THROW_ON_ERROR); + + $this->analyse([__DIR__ . '/Fixtures/resolve-calls.php'], [ + [$message('DisplayToUser', ['fixture_context'], 52), 52], + [$message('DisplayToUser', ['factory_context'], 53), 53], + [$message('PassToComponent', ['Mail', 'fixture_reason'], 54), 54], + [$message('StoreInTable', ['usr_data.(street,city,zipcode,country)'], 55), 55], + [$message('StoreInTable', ['DbTableColumn(tmp_table, tmp_column)'], 56), 56], + [$message('dynamic', ['ILIAS\\Data\\Privacy\\Purpose\\Purpose'], 57), 57], + [$message('unknown', [], 61), 61], + [$message('DisplayToUser', ['string'], 62), 62], + [$message('DisplayToUser', ['string'], 63), 63], + ]); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/CollectorResultParserTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/CollectorResultParserTest.php new file mode 100644 index 000000000000..18ef605f6e8f --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/CollectorResultParserTest.php @@ -0,0 +1,129 @@ +withContent(json_encode($data, JSON_THROW_ON_ERROR)) + ->at($root)->url(); + } + + public function testExtractsMarkedMessages(): void + { + $payload = [ + 'privacy_type' => 'ILIAS\\Data\\Privacy\\Types\\PostalAddress', + 'purpose_class' => 'DisplayToUser', + 'purpose_args' => ['public_profile'], + 'component' => 'User', + 'line' => 12, + ]; + $file = $this->outputFile([ + 'files' => [ + '/repo/components/ILIAS/User/SomeClass.php' => [ + 'messages' => [ + ['message' => 'Some unrelated PHPStan error', 'line' => 3], + ['message' => PrivacyResolveReportRule::MARKER . json_encode($payload, JSON_THROW_ON_ERROR), 'line' => 12], + ], + ], + ], + ]); + + $entries = new CollectorResultParser()->parse($file); + + $this->assertCount(1, $entries); + $this->assertSame('DisplayToUser', $entries[0]['purpose_class']); + $this->assertSame(['public_profile'], $entries[0]['purpose_args']); + $this->assertSame('User', $entries[0]['component']); + $this->assertSame('/repo/components/ILIAS/User/SomeClass.php', $entries[0]['file']); + $this->assertSame(12, $entries[0]['line']); + } + + public function testMissingPayloadKeysFallBackToDefaults(): void + { + $file = $this->outputFile([ + 'files' => [ + 'f.php' => [ + 'messages' => [ + ['message' => PrivacyResolveReportRule::MARKER . '{}', 'line' => 1], + ], + ], + ], + ]); + + $entries = new CollectorResultParser()->parse($file); + + $this->assertSame( + [ + 'privacy_type' => 'unknown', + 'purpose_class' => 'unknown', + 'purpose_args' => [], + 'component' => 'Unknown', + 'file' => 'f.php', + 'line' => 0, + ], + $entries[0] + ); + } + + public function testNonArrayPayloadIsIgnored(): void + { + $file = $this->outputFile([ + 'files' => [ + 'f.php' => [ + 'messages' => [ + ['message' => PrivacyResolveReportRule::MARKER . '"scalar-payload"', 'line' => 1], + ], + ], + ], + ]); + + $this->assertSame([], new CollectorResultParser()->parse($file)); + } + + public function testEmptyOutput(): void + { + $this->assertSame([], new CollectorResultParser()->parse($this->outputFile(['files' => []]))); + $this->assertSame([], new CollectorResultParser()->parse($this->outputFile(['totals' => []]))); + } + + public function testUnreadableFileThrows(): void + { + $this->expectException(\RuntimeException::class); + @new CollectorResultParser()->parse(vfsStream::setup('empty')->url() . '/missing.json'); + } + + public function testInvalidJsonThrows(): void + { + $root = vfsStream::setup('phpstan'); + $file = vfsStream::newFile('broken.json')->withContent('{not json')->at($root)->url(); + + $this->expectException(\JsonException::class); + new CollectorResultParser()->parse($file); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/ComponentReportTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/ComponentReportTest.php new file mode 100644 index 000000000000..047c594a8aa9 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/ComponentReportTest.php @@ -0,0 +1,62 @@ +assertTrue($report->isEmpty()); + $this->assertSame([], $report->get(EntryCategory::Store)); + $this->assertSame([], $report->all()); + } + + public function testGroupsByCategory(): void + { + $report = new ComponentReport(); + $store = $this->entry(EntryCategory::Store); + $display_one = $this->entry(EntryCategory::Display); + $display_two = $this->entry(EntryCategory::Display, 'OtherType'); + $legacy = $this->entry(EntryCategory::Legacy); + + $report->add($store); + $report->add($display_one); + $report->add($display_two); + $report->add($legacy); + + $this->assertFalse($report->isEmpty()); + $this->assertSame([$store], $report->get(EntryCategory::Store)); + $this->assertSame([$display_one, $display_two], $report->get(EntryCategory::Display)); + $this->assertSame([$legacy], $report->get(EntryCategory::Legacy)); + $this->assertSame([], $report->get(EntryCategory::Pass)); + $this->assertSame([], $report->get(EntryCategory::Technical)); + $this->assertCount(4, $report->all()); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/MarkdownRendererTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/MarkdownRendererTest.php new file mode 100644 index 000000000000..e952f9bdb252 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/MarkdownRendererTest.php @@ -0,0 +1,127 @@ +render('Mail', new ComponentReport()); + + $this->assertStringContainsString('# Privacy Data Usage – Mail', $md); + $this->assertStringContainsString('does not resolve any privacy-protected data', $md); + $this->assertStringNotContainsString('## Summary', $md); + } + + public function testFullReport(): void + { + $report = new ComponentReport(); + $report->add(new ResolveEntry( + 'PostalAddress', + 'StoreInTable', + ['usr_data.(street,city,zipcode,country)'], + EntryCategory::Store, + '/repo/components/ILIAS/User/src/Profile/DatabaseDataRepository.php', + 123 + )); + $report->add(new ResolveEntry( + 'PostalAddress', + 'DisplayToUser', + ['public_profile'], + EntryCategory::Display, + '/repo/components/ILIAS/User/src/Profile/class.PublicProfileGUI.php', + 371 + )); + $report->add(new ResolveEntry( + 'PostalAddress', + 'PassToComponent', + ['Mail', 'profile_mail_body'], + EntryCategory::Pass, + '/repo/components/ILIAS/User/classes/class.ilObjUser.php', + 1580 + )); + $report->add(new ResolveEntry( + 'PostalAddress', + 'TechnicalProcessing', + ['comparison'], + EntryCategory::Technical, + '/repo/components/ILIAS/User/classes/class.ilSomething.php', + 10 + )); + $report->add(new ResolveEntry( + 'PostalAddress', + 'LegacyAccess', + ['profile_data_getter'], + EntryCategory::Legacy, + '/repo/components/ILIAS/User/src/Profile/Data.php', + 266 + )); + + $md = new MarkdownRenderer()->render('User', $report); + + $this->assertStringContainsString('# Privacy Data Usage – User', $md); + // summary counts + $this->assertStringContainsString('| Stored in DB | 1 |', $md); + $this->assertStringContainsString('| Displayed to user | 1 |', $md); + $this->assertStringContainsString('| Passed to component | 1 |', $md); + $this->assertStringContainsString('| Technical processing | 1 |', $md); + $this->assertStringContainsString('| Unmigrated (legacy access) | 1 |', $md); + // sections + $this->assertStringContainsString('## Stored data', $md); + $this->assertStringContainsString('`usr_data.(street,city,zipcode,country)`', $md); + $this->assertStringContainsString('## Displayed to user', $md); + $this->assertStringContainsString('`public_profile`', $md); + $this->assertStringContainsString('## Passed to other components', $md); + $this->assertStringContainsString('| `PostalAddress` | `Mail` | profile_mail_body |', $md); + $this->assertStringContainsString('## Technical processing', $md); + $this->assertStringContainsString('## Unmigrated (legacy access)', $md); + // file links are shortened to component-relative paths + $this->assertStringContainsString('`User/src/Profile/DatabaseDataRepository.php:123`', $md); + $this->assertStringNotContainsString('/repo/components', $md); + // GDPR block + $this->assertStringContainsString('| Personal data categories | `PostalAddress` |', $md); + $this->assertStringContainsString('| Storage locations | `usr_data.(street,city,zipcode,country)` |', $md); + $this->assertStringContainsString('| Data recipients | `Mail` |', $md); + $this->assertStringContainsString('| Legal basis | _To be filled in manually_ |', $md); + $this->assertStringContainsString('| Retention period | _To be filled in manually_ |', $md); + } + + public function testGdprBlockOmitsEmptyStorageAndRecipients(): void + { + $report = new ComponentReport(); + $report->add(new ResolveEntry( + 'PostalAddress', + 'DisplayToUser', + ['map_user_info'], + EntryCategory::Display, + '/repo/components/ILIAS/Maps/classes/class.ilGoogleMapGUI.php', + 97 + )); + + $md = new MarkdownRenderer()->render('Maps', $report); + + $this->assertStringNotContainsString('Storage locations', $md); + $this->assertStringNotContainsString('Data recipients', $md); + $this->assertStringNotContainsString('## Stored data', $md); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PrivacyDocGeneratorTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PrivacyDocGeneratorTest.php new file mode 100644 index 000000000000..03ae6f56d376 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PrivacyDocGeneratorTest.php @@ -0,0 +1,118 @@ +root = vfsStream::setup('repo', null, [ + 'components' => ['User' => [], 'Mail' => []], + ]); + } + + private function phpstanOutput(): string + { + $message = static fn(string $component, string $purpose, array $args, int $line): array => [ + 'message' => PrivacyResolveReportRule::MARKER . json_encode([ + 'privacy_type' => 'PostalAddress', + 'purpose_class' => $purpose, + 'purpose_args' => $args, + 'component' => $component, + 'line' => $line, + ], JSON_THROW_ON_ERROR), + 'line' => $line, + ]; + + return vfsStream::newFile('output.json')->withContent(json_encode([ + 'files' => [ + '/repo/components/ILIAS/User/A.php' => [ + 'messages' => [$message('User', 'DisplayToUser', ['public_profile'], 10)], + ], + '/repo/components/ILIAS/Mail/B.php' => [ + 'messages' => [$message('Mail', 'PassToComponent', ['Notifications', 'x'], 20)], + ], + ], + ], JSON_THROW_ON_ERROR))->at($this->root)->url(); + } + + public function testWritesOneReportPerComponent(): void + { + $written = new PrivacyDocGenerator( + $this->phpstanOutput(), + $this->root->url() . '/components' + )->run(); + + $this->assertSame(['Mail', 'User'], array_keys($written)); + $this->assertFileExists($this->root->url() . '/components/User/PRIVACY_DATA.md'); + $this->assertFileExists($this->root->url() . '/components/Mail/PRIVACY_DATA.md'); + $this->assertStringContainsString( + '# Privacy Data Usage – User', + (string) file_get_contents($written['User']) + ); + } + + public function testDryRunWritesNothing(): void + { + $written = new PrivacyDocGenerator( + $this->phpstanOutput(), + $this->root->url() . '/components', + dry_run: true + )->run(); + + $this->assertCount(2, $written); + $this->assertFileDoesNotExist($this->root->url() . '/components/User/PRIVACY_DATA.md'); + $this->assertFileDoesNotExist($this->root->url() . '/components/Mail/PRIVACY_DATA.md'); + } + + public function testComponentFilter(): void + { + $written = new PrivacyDocGenerator( + $this->phpstanOutput(), + $this->root->url() . '/components', + filter_component: 'User' + )->run(); + + $this->assertSame(['User'], array_keys($written)); + $this->assertFileDoesNotExist($this->root->url() . '/components/Mail/PRIVACY_DATA.md'); + } + + public function testTargetFilenameOverride(): void + { + $written = new PrivacyDocGenerator( + $this->phpstanOutput(), + $this->root->url() . '/components', + filter_component: 'User', + target_filename: 'PRIVACY.md' + )->run(); + + $this->assertSame($this->root->url() . '/components/User/PRIVACY.md', $written['User']); + $this->assertFileExists($this->root->url() . '/components/User/PRIVACY.md'); + $this->assertFileDoesNotExist($this->root->url() . '/components/User/PRIVACY_DATA.md'); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PurposeClassifierTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PurposeClassifierTest.php new file mode 100644 index 000000000000..0104cb31f011 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PurposeClassifierTest.php @@ -0,0 +1,75 @@ + + */ + public static function purposeProvider(): array + { + return [ + 'store' => ['StoreInTable', EntryCategory::Store], + 'display' => ['DisplayToUser', EntryCategory::Display], + 'pass' => ['PassToComponent', EntryCategory::Pass], + 'technical' => ['TechnicalProcessing', EntryCategory::Technical], + 'legacy' => ['LegacyAccess', EntryCategory::Legacy], + 'fqcn is shortened' => ['ILIAS\\Data\\Privacy\\Purpose\\DisplayToUser', EntryCategory::Display], + 'unknown falls back to technical' => ['SomethingElse', EntryCategory::Technical], + 'dynamic falls back to technical' => ['dynamic', EntryCategory::Technical], + ]; + } + + #[DataProvider('purposeProvider')] + public function testClassification(string $purpose_class, EntryCategory $expected): void + { + $entry = new PurposeClassifier()->classify([ + 'privacy_type' => 'ILIAS\\Data\\Privacy\\Types\\PostalAddress', + 'purpose_class' => $purpose_class, + 'purpose_args' => ['arg'], + 'file' => '/some/file.php', + 'line' => 42, + ]); + + $this->assertSame($expected, $entry->category); + $this->assertSame('PostalAddress', $entry->privacy_type); + $this->assertSame(['arg'], $entry->purpose_args); + $this->assertSame('/some/file.php', $entry->file); + $this->assertSame(42, $entry->line); + } + + public function testGenericSuffixIsStripped(): void + { + $entry = new PurposeClassifier()->classify([ + 'privacy_type' => 'ILIAS\\Data\\Privacy\\Types\\PostalAddress', + 'purpose_class' => 'DisplayToUser', + 'purpose_args' => [], + 'file' => 'f.php', + 'line' => 1, + ]); + + $this->assertSame('PostalAddress', $entry->privacy_type); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/no-raw-value-access.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/no-raw-value-access.php new file mode 100644 index 000000000000..9e0977a7e961 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/no-raw-value-access.php @@ -0,0 +1,38 @@ + $dynamic_class + */ +function undocumentedSources(string $table, string $column, string $dynamic_class): array +{ + return [ + new DbTableColumn('usr_data', 'street'), + new DbTableColumns('usr_data', 'street', 'city'), + /* @privacy-undocumented this one is deliberately not catalogued */ + new DbTableColumn('tmp_table', 'tmp_column'), + new DbTableColumn('tmp_table', 'tmp_column'), // @privacy-undocumented + new UserInput('some_form'), + new DbTableColumn($table, $column), + new $dynamic_class('usr_data', 'street'), + ]; +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/store-in-table-target.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/store-in-table-target.php new file mode 100644 index 000000000000..e6b5f42a7218 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/store-in-table-target.php @@ -0,0 +1,39 @@ + $dynamic_class + */ +function storeInTableTargets(UserSources $user_sources, string $dynamic_class): array +{ + return [ + new StoreInTable($user_sources->postalAddress()), + new StoreInTable($user_sources->street()), + new StoreInTable(), + new StoreInTable('usr_data.street'), + new $dynamic_class($user_sources->street()), + new UserSources(), + ]; +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/NoRawValueAccessRuleTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/NoRawValueAccessRuleTest.php new file mode 100644 index 000000000000..16bf877aad11 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/NoRawValueAccessRuleTest.php @@ -0,0 +1,52 @@ + + */ +class NoRawValueAccessRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new NoRawValueAccessRule(); + } + + public function testRule(): void + { + $message = static fn(string $function): string => sprintf( + 'PrivacyDataType passed directly to %s(). Call ->resolve() with a Purpose first.', + $function + ); + + $this->analyse([__DIR__ . '/Fixtures/no-raw-value-access.php'], [ + [$message('var_dump'), 27], + [$message('print_r'), 28], + [$message('var_export'), 29], + [$message('json_encode'), 30], + [$message('serialize'), 31], + [$message('var_dump'), 34], + ]); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/PreferKnownSourcesRuleTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/PreferKnownSourcesRuleTest.php new file mode 100644 index 000000000000..74c1d0dcfea6 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/PreferKnownSourcesRuleTest.php @@ -0,0 +1,63 @@ + + */ +class PreferKnownSourcesRuleTest extends RuleTestCase +{ + private const string TIP = 'Add a getter to components/ILIAS/Data/src/Privacy/Source/Known.'; + + protected function getRule(): Rule + { + return new PreferKnownSourcesRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/Fixtures/prefer-known-sources.php'], [ + [ + 'Direct DbTableColumn("usr_data", "street") — add this column to the KnownSources' + . ' catalogue or annotate with @privacy-undocumented.', + 33, + self::TIP, + ], + [ + 'Direct DbTableColumns("usr_data", "street", "city") — add this column to the KnownSources' + . ' catalogue or annotate with @privacy-undocumented.', + 34, + self::TIP, + ], + ]); + } + + public function testCatalogueItselfIsExempt(): void + { + $this->analyse( + [__DIR__ . '/../../../../src/Privacy/Source/Known/UserSources.php'], + [] + ); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/StoreInTableTargetRuleTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/StoreInTableTargetRuleTest.php new file mode 100644 index 000000000000..c1cc55edb1f0 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Rules/StoreInTableTargetRuleTest.php @@ -0,0 +1,50 @@ + + */ +class StoreInTableTargetRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new StoreInTableTargetRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/Fixtures/store-in-table-target.php'], [ + [ + 'StoreInTable requires a DbTarget argument.', + 34, + ], + [ + 'StoreInTable expects a DbTarget (DbTableColumn/DbTableColumns), got string.' + . ' Use the KnownSources catalogue.', + 35, + ], + ]); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Type/Fixtures/return-types.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Type/Fixtures/return-types.php new file mode 100644 index 000000000000..c09c8d608062 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Type/Fixtures/return-types.php @@ -0,0 +1,40 @@ +resolve($purpose)); +} + +/** + * @param PrivacyDataType $wrapped_int + */ +function genericInterfaceTypeResolvesToTypeArgument(PrivacyDataType $wrapped_int, Purpose $purpose): void +{ + assertType('int', $wrapped_int->resolve($purpose)); +} diff --git a/components/ILIAS/Data/tests/Privacy/PHPStan/Type/ResolveReturnTypeInferenceTest.php b/components/ILIAS/Data/tests/Privacy/PHPStan/Type/ResolveReturnTypeInferenceTest.php new file mode 100644 index 000000000000..1bfeed79ad74 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/PHPStan/Type/ResolveReturnTypeInferenceTest.php @@ -0,0 +1,43 @@ +::resolve() to T + * natively through the generics annotations — no dedicated return type + * extension is required. + */ +class ResolveReturnTypeInferenceTest extends TypeInferenceTestCase +{ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/Fixtures/return-types.php'); + } + + #[DataProvider('dataFileAsserts')] + public function testFileAsserts(string $assert_type, string $file, mixed ...$args): void + { + $this->assertFileAsserts($assert_type, $file, ...$args); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Purpose/PurposesTest.php b/components/ILIAS/Data/tests/Privacy/Purpose/PurposesTest.php new file mode 100644 index 000000000000..64d163bdc39f --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Purpose/PurposesTest.php @@ -0,0 +1,99 @@ +assertSame($target, $purpose->getTarget()); + $this->assertSame('store_in:il_mail.sender_id', $purpose->describe()); + } + + public function testStoreInTableWithCompoundColumns(): void + { + $purpose = new StoreInTable( + new DbTableColumns('usr_data', 'street', 'city') + ); + + $this->assertSame('store_in:usr_data.(street,city)', $purpose->describe()); + } + + public function testDisplayToUser(): void + { + $purpose = new DisplayToUser('public_profile'); + + $this->assertSame('public_profile', $purpose->getUiContext()); + $this->assertSame('display_to_user:public_profile', $purpose->describe()); + } + + public function testPassToComponent(): void + { + $purpose = new PassToComponent('Mail', 'signature'); + + $this->assertSame('Mail', $purpose->getComponent()); + $this->assertSame('signature', $purpose->getReason()); + $this->assertSame('pass_to:Mail (signature)', $purpose->describe()); + } + + public function testTechnicalProcessing(): void + { + $purpose = new TechnicalProcessing('pseudonymisation'); + + $this->assertSame('pseudonymisation', $purpose->getOperation()); + $this->assertSame('technical:pseudonymisation', $purpose->describe()); + } + + public function testLegacyAccess(): void + { + $purpose = new LegacyAccess('profile_data_getter'); + + $this->assertSame('profile_data_getter', $purpose->getHint()); + $this->assertSame('legacy:profile_data_getter', $purpose->describe()); + } + + public function testLegacyAccessDefaultsToUnclassified(): void + { + $this->assertSame('legacy:unclassified', new LegacyAccess()->describe()); + } + + public function testFactoryBuildsAllPurposes(): void + { + $purposes = new Purposes(); + + $this->assertSame( + 'store_in:usr_data.street', + $purposes->storeInTable(new DbTableColumn('usr_data', 'street'))->describe() + ); + $this->assertSame('display_to_user:public_profile', $purposes->displayToUser('public_profile')->describe()); + $this->assertSame('pass_to:Mail (signature)', $purposes->passToComponent('Mail', 'signature')->describe()); + $this->assertSame('technical:comparison', $purposes->technicalProcessing('comparison')->describe()); + $this->assertSame('legacy:some_getter', $purposes->legacyAccess('some_getter')->describe()); + $this->assertSame('legacy:unclassified', $purposes->legacyAccess()->describe()); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Source/KnownSourcesTest.php b/components/ILIAS/Data/tests/Privacy/Source/KnownSourcesTest.php new file mode 100644 index 000000000000..15fa1eda4572 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Source/KnownSourcesTest.php @@ -0,0 +1,85 @@ +assertSame($sources->user(), $sources->user()); + } + + public function testPostalAddressIsCompound(): void + { + $source = new KnownSources()->user()->postalAddress(); + + $this->assertSame('usr_data', $source->getTable()); + $this->assertSame(['street', 'city', 'zipcode', 'country'], $source->getColumns()); + } + + /** + * @return array + */ + public static function userColumnProvider(): array + { + return [ + 'userId' => ['userId', 'usr_data.usr_id'], + 'login' => ['login', 'usr_data.login'], + 'externalAccount' => ['externalAccount', 'usr_data.ext_account'], + 'email' => ['email', 'usr_data.email'], + 'secondEmail' => ['secondEmail', 'usr_data.second_email'], + 'phoneOffice' => ['phoneOffice', 'usr_data.phone_office'], + 'phoneHome' => ['phoneHome', 'usr_data.phone_home'], + 'phoneMobile' => ['phoneMobile', 'usr_data.phone_mobile'], + 'fax' => ['fax', 'usr_data.fax'], + 'firstname' => ['firstname', 'usr_data.firstname'], + 'lastname' => ['lastname', 'usr_data.lastname'], + 'title' => ['title', 'usr_data.title'], + 'gender' => ['gender', 'usr_data.gender'], + 'institution' => ['institution', 'usr_data.institution'], + 'department' => ['department', 'usr_data.department'], + 'street' => ['street', 'usr_data.street'], + 'city' => ['city', 'usr_data.city'], + 'zipcode' => ['zipcode', 'usr_data.zipcode'], + 'country' => ['country', 'usr_data.country'], + 'birthday' => ['birthday', 'usr_data.birthday'], + 'hobby' => ['hobby', 'usr_data.hobby'], + 'matriculation' => ['matriculation', 'usr_data.matriculation'], + 'clientIp' => ['clientIp', 'usr_data.client_ip'], + 'lastLogin' => ['lastLogin', 'usr_data.last_login'], + 'lastPasswordChange' => ['lastPasswordChange', 'usr_data.last_password_change'], + ]; + } + + #[DataProvider('userColumnProvider')] + public function testUserColumns(string $getter, string $expected_description): void + { + $this->assertSame( + $expected_description, + new KnownSources()->user()->$getter()->describe() + ); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Source/SourcesTest.php b/components/ILIAS/Data/tests/Privacy/Source/SourcesTest.php new file mode 100644 index 000000000000..99b87c156353 --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Source/SourcesTest.php @@ -0,0 +1,102 @@ +assertSame('usr_data', $source->getTable()); + $this->assertSame('street', $source->getColumn()); + $this->assertSame('usr_data.street', $source->describe()); + } + + public function testDbTableColumns(): void + { + $source = new DbTableColumns('usr_data', 'street', 'city', 'zipcode', 'country'); + + $this->assertSame('usr_data', $source->getTable()); + $this->assertSame(['street', 'city', 'zipcode', 'country'], $source->getColumns()); + $this->assertSame('usr_data.(street,city,zipcode,country)', $source->describe()); + } + + public function testDbTableColumnsRequiresAtLeastOneColumn(): void + { + $this->expectException(\InvalidArgumentException::class); + new DbTableColumns('usr_data'); + } + + public function testUserInput(): void + { + $source = new UserInput('registration_form'); + + $this->assertSame('registration_form', $source->getContext()); + $this->assertSame('user_input:registration_form', $source->describe()); + } + + public function testExternalApi(): void + { + $source = new ExternalApi('shibboleth', 'homePostalAddress'); + + $this->assertSame('shibboleth', $source->getService()); + $this->assertSame('homePostalAddress', $source->getField()); + $this->assertSame('api:shibboleth.homePostalAddress', $source->describe()); + } + + public function testSessionData(): void + { + $source = new SessionData('user_id'); + + $this->assertSame('user_id', $source->getKey()); + $this->assertSame('session:user_id', $source->describe()); + } + + public function testLegacySource(): void + { + $this->assertSame('legacy:unclassified', new LegacySource()->describe()); + $source = new LegacySource('ilObjUser::setStreet'); + $this->assertSame('ilObjUser::setStreet', $source->getHint()); + $this->assertSame('legacy:ilObjUser::setStreet', $source->describe()); + } + + public function testFactoryBuildsAllSources(): void + { + $sources = new Sources(); + + $this->assertSame('user_input:profile_form', $sources->userInput('profile_form')->describe()); + $this->assertSame('api:shibboleth.street', $sources->externalApi('shibboleth', 'street')->describe()); + $this->assertSame('session:user_id', $sources->sessionData('user_id')->describe()); + $this->assertSame('legacy:some_setter', $sources->legacy('some_setter')->describe()); + $this->assertSame('legacy:unclassified', $sources->legacy()->describe()); + } + + public function testFactoryInheritsKnownSourcesCatalogue(): void + { + $this->assertSame( + 'usr_data.(street,city,zipcode,country)', + new Sources()->user()->postalAddress()->describe() + ); + } +} diff --git a/components/ILIAS/Data/tests/Privacy/Types/PostalAddressTest.php b/components/ILIAS/Data/tests/Privacy/Types/PostalAddressTest.php new file mode 100644 index 000000000000..401d39fa1a7c --- /dev/null +++ b/components/ILIAS/Data/tests/Privacy/Types/PostalAddressTest.php @@ -0,0 +1,118 @@ +logger = new InMemoryPrivacyLogger(); + $this->address = new PostalAddress( + new PostalAddressValue('Mainstreet 5', 'Berne', '3011', 'CH'), + new DbTableColumns('usr_data', 'street', 'city', 'zipcode', 'country'), + $this->logger + ); + } + + public function testResolveReturnsValueAndLogs(): void + { + $value = $this->address->resolve(new DisplayToUser('public_profile')); + + $this->assertSame('Mainstreet 5', $value->street); + $this->assertSame('Berne', $value->city); + $this->assertSame('3011', $value->zipcode); + $this->assertSame('CH', $value->country); + + $this->logger->assertLoggedOnce(); + $this->logger->assertLastSourceIs('usr_data.(street,city,zipcode,country)'); + } + + public function testResolveWithStoreInTableLogsTarget(): void + { + $this->address->resolve( + new StoreInTable(new DbTableColumns('usr_data', 'street', 'city', 'zipcode', 'country')) + ); + + $this->logger->assertLastPurposeIs('store_in:usr_data.(street,city,zipcode,country)'); + } + + public function testWithersReplaceSingleFieldsWithoutLogging(): void + { + $changed = $this->address + ->withStreet('Sidestreet 7', new UserInput('profile_form')) + ->withCity('Zurich', new UserInput('profile_form')) + ->withZipcode('8001', new UserInput('profile_form')) + ->withCountry('DE', new UserInput('profile_form')); + + $this->logger->assertNothingLogged(); + + $value = $changed->resolve(new DisplayToUser('test')); + $this->assertSame('Sidestreet 7', $value->street); + $this->assertSame('Zurich', $value->city); + $this->assertSame('8001', $value->zipcode); + $this->assertSame('DE', $value->country); + } + + public function testWitherReplacesSourceAndKeepsLogger(): void + { + $changed = $this->address->withStreet('Sidestreet 7', new UserInput('profile_form')); + + $this->assertSame('user_input:profile_form', $changed->getSource()->describe()); + + $changed->resolve(new DisplayToUser('test')); + $this->logger->assertLoggedOnce(); + $this->logger->assertLastSourceIs('user_input:profile_form'); + } + + public function testWitherDoesNotMutateOriginal(): void + { + $this->address->withStreet('Sidestreet 7', new UserInput('profile_form')); + + $value = $this->address->resolve(new DisplayToUser('test')); + $this->assertSame('Mainstreet 5', $value->street); + $this->assertSame( + 'usr_data.(street,city,zipcode,country)', + $this->address->getSource()->describe() + ); + } + + public function testToStringMasksAllFields(): void + { + $this->assertToStringDoesNotExposeValue($this->address, 'Mainstreet 5'); + $this->assertToStringDoesNotExposeValue($this->address, 'Berne'); + $this->assertToStringDoesNotExposeValue($this->address, '3011'); + $this->assertToStringDoesNotExposeValue($this->address, 'CH'); + $this->assertStringContainsString(PostalAddress::class, (string) $this->address); + } +} diff --git a/components/ILIAS/DataProtection/tests/ConsumerTest.php b/components/ILIAS/DataProtection/tests/ConsumerTest.php index db4e9fe450da..00dc27937380 100755 --- a/components/ILIAS/DataProtection/tests/ConsumerTest.php +++ b/components/ILIAS/DataProtection/tests/ConsumerTest.php @@ -24,6 +24,7 @@ use ILIAS\Refinery\ByTrying; use ilSetting; use ilObjectDataCache; +use ILIAS\Data\Privacy\Purpose\Purposes; use ILIAS\DI\Container; use ILIAS\LegalDocuments\test\ContainerMock; use PHPUnit\Framework\TestCase; @@ -56,7 +57,10 @@ public function testDisabledUses(): void 'settings' => $settings, 'refinery' => ['byTrying' => $by_trying], ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + Purposes::class => new Purposes(), + }); $slot = $this->mock(UseSlot::class); $slot->expects($this->once())->method('hasDocuments')->willReturn($slot); @@ -88,7 +92,10 @@ public function testUsesWithoutAcceptance(): void 'refinery' => ['byTrying' => $by_trying], 'ctrl' => $this->mock(ilCtrl::class), ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + Purposes::class => new Purposes(), + }); $slot = $this->mock(UseSlot::class); $slot->expects($this->once())->method('hasDocuments')->willReturn($slot); @@ -123,7 +130,10 @@ public function testUses(): void 'refinery' => ['byTrying' => $by_trying], 'ctrl' => $this->mock(ilCtrl::class), ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + Purposes::class => new Purposes(), + }); $slot = $this->mock(UseSlot::class); $slot->expects($this->once())->method('hasDocuments')->willReturn($slot); diff --git a/components/ILIAS/Init/Init.php b/components/ILIAS/Init/Init.php index b58094043b9a..5773f0678c66 100644 --- a/components/ILIAS/Init/Init.php +++ b/components/ILIAS/Init/Init.php @@ -127,6 +127,9 @@ public function init( $pull[\ILIAS\UI\Implementation\Render\JavaScriptBinding::class], $pull[\ILIAS\UI\Implementation\Component\SignalGeneratorInterface::class], $pull[\ILIAS\UI\Implementation\Render\TemplateFactory::class], + $use[\ILIAS\Data\Privacy\Services::class], + $pull[\ILIAS\Data\Privacy\Source\Sources::class], + $pull[\ILIAS\Data\Privacy\Purpose\Purposes::class], ); } } diff --git a/components/ILIAS/Init/src/AllModernComponents.php b/components/ILIAS/Init/src/AllModernComponents.php index 16871a138682..faa0d76bbcf0 100644 --- a/components/ILIAS/Init/src/AllModernComponents.php +++ b/components/ILIAS/Init/src/AllModernComponents.php @@ -101,6 +101,9 @@ public function __construct( protected \ILIAS\UI\Implementation\Render\JavaScriptBinding $ui_java_script_binding, protected \ILIAS\UI\Implementation\Component\SignalGeneratorInterface $ui_signal_generator, protected \ILIAS\UI\Implementation\Render\TemplateFactory $ui_template_factory, + protected \ILIAS\Data\Privacy\Services $privacy_services, + protected \ILIAS\Data\Privacy\Source\Sources $privacy_sources, + protected \ILIAS\Data\Privacy\Purpose\Purposes $privacy_purposes, ) { } @@ -182,6 +185,9 @@ protected function populateComponentsInLegacyEnvironment(\Pimple\Container $DIC) $DIC['ui.javascript_binding'] = fn() => $this->ui_java_script_binding; $DIC['ui.signal_generator'] = fn() => $this->ui_signal_generator; $DIC['ui.template_factory'] = fn() => $this->ui_template_factory; + $DIC[\ILIAS\Data\Privacy\Services::class] = fn() => $this->privacy_services; + $DIC[\ILIAS\Data\Privacy\Source\Sources::class] = fn() => $this->privacy_sources; + $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class] = fn() => $this->privacy_purposes; } public function getName(): string diff --git a/components/ILIAS/LegalDocuments/PRIVACY_DATA.md b/components/ILIAS/LegalDocuments/PRIVACY_DATA.md new file mode 100644 index 000000000000..187a59b21414 --- /dev/null +++ b/components/ILIAS/LegalDocuments/PRIVACY_DATA.md @@ -0,0 +1,30 @@ +# Privacy Data Usage – LegalDocuments + +> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on 2026-07-23. +> Do not edit manually – re-run the generator after code changes. + +## Summary + +| Category | Count | +|---|---| +| Stored in DB | 0 | +| Displayed to user | 0 | +| Passed to component | 0 | +| Technical processing | 1 | +| Unmigrated (legacy access) | 0 | + +## Technical processing + +_Data resolved for internal computation. Not persisted or displayed._ + +| Type | Operation | File | +|---|---|---| +| `PostalAddress` | `legal_documents_country_condition` | `LegalDocuments/classes/Condition/UserCountry.php:53` | + +## GDPR relevance + +| GDPR aspect | Details | +|---|---| +| Personal data categories | `PostalAddress` | +| Legal basis | _To be filled in manually_ | +| Retention period | _To be filled in manually_ | diff --git a/components/ILIAS/LegalDocuments/classes/Condition/Definitions/UserCountryDefinition.php b/components/ILIAS/LegalDocuments/classes/Condition/Definitions/UserCountryDefinition.php index e6f8090709ea..9d4526989421 100755 --- a/components/ILIAS/LegalDocuments/classes/Condition/Definitions/UserCountryDefinition.php +++ b/components/ILIAS/LegalDocuments/classes/Condition/Definitions/UserCountryDefinition.php @@ -27,6 +27,7 @@ use ILIAS\LegalDocuments\Value\CriterionContent; use ILIAS\LegalDocuments\ConsumerToolbox\UI; use ILIAS\UI\Component\Input\Field\Group; +use ILIAS\Data\Privacy\Purpose\Purposes; use ilCountry; use Closure; @@ -35,8 +36,11 @@ class UserCountryDefinition implements ConditionDefinition /** * @param Closure(array): Constraint $required */ - public function __construct(private readonly UI $ui, private readonly Closure $required) - { + public function __construct( + private readonly UI $ui, + private readonly Closure $required, + private readonly Purposes $purposes + ) { } public function formGroup(array $arguments = []): Group @@ -54,7 +58,7 @@ public function formGroup(array $arguments = []): Group public function withCriterion(CriterionContent $criterion): Condition { - return new UserCountry($criterion, $this, $this->ui->create()); + return new UserCountry($criterion, $this, $this->ui->create(), $this->purposes); } public function translatedType(): string diff --git a/components/ILIAS/LegalDocuments/classes/Condition/UserCountry.php b/components/ILIAS/LegalDocuments/classes/Condition/UserCountry.php index fdf2779520f5..4dee965bb552 100755 --- a/components/ILIAS/LegalDocuments/classes/Condition/UserCountry.php +++ b/components/ILIAS/LegalDocuments/classes/Condition/UserCountry.php @@ -26,6 +26,7 @@ use ILIAS\LegalDocuments\Value\CriterionContent; use ILIAS\UI\Component\Component; use ILIAS\UI\Factory as UIFactory; +use ILIAS\Data\Privacy\Purpose\Purposes; use ilObjUser; class UserCountry implements Condition @@ -33,7 +34,8 @@ class UserCountry implements Condition public function __construct( private readonly CriterionContent $criterion, private readonly UserCountryDefinition $definition, - private readonly UIFactory $create + private readonly UIFactory $create, + private readonly Purposes $purposes ) { } @@ -48,7 +50,10 @@ public function asComponent(): Component public function eval(ilObjUser $user): bool { - return strtoupper($user->getCountry()) === strtoupper($this->criterion->arguments()['country']); + $country = $user->getProfileData()->getPostalAddress()?->resolve( + $this->purposes->technicalProcessing('legal_documents_country_condition') + )->country ?? ''; + return strtoupper($country) === strtoupper($this->criterion->arguments()['country']); } public function definition(): ConditionDefinition diff --git a/components/ILIAS/LegalDocuments/classes/DefaultMappings.php b/components/ILIAS/LegalDocuments/classes/DefaultMappings.php index eee3be2836f6..1c8895ce67ef 100755 --- a/components/ILIAS/LegalDocuments/classes/DefaultMappings.php +++ b/components/ILIAS/LegalDocuments/classes/DefaultMappings.php @@ -59,7 +59,7 @@ public function conditionDefinitions(): SelectionMap return new SelectionMap([ 'usr_global_role' => new RoleDefinition($ui, $this->container['ilObjDataCache'], $this->container->rbac()->review(), $required), 'usr_language' => new UserLanguageDefinition($ui, $this->container->language()->getInstalledLanguages(), $required), - 'usr_country' => new UserCountryDefinition($ui, $required), + 'usr_country' => new UserCountryDefinition($ui, $required, $this->container[\ILIAS\Data\Privacy\Purpose\Purposes::class]), ], 'usr_country'); } diff --git a/components/ILIAS/LegalDocuments/tests/Condition/Definitions/UserCountryDefinitionTest.php b/components/ILIAS/LegalDocuments/tests/Condition/Definitions/UserCountryDefinitionTest.php index 1db61175cf9d..849a741e4824 100755 --- a/components/ILIAS/LegalDocuments/tests/Condition/Definitions/UserCountryDefinitionTest.php +++ b/components/ILIAS/LegalDocuments/tests/Condition/Definitions/UserCountryDefinitionTest.php @@ -39,7 +39,8 @@ public function testConstruct(): void { $this->assertInstanceOf(UserCountryDefinition::class, new UserCountryDefinition( $this->mock(UI::class), - $this->fail(...) + $this->fail(...), + new \ILIAS\Data\Privacy\Purpose\Purposes() )); } @@ -47,7 +48,8 @@ public function testFormGroup(): void { $instance = new UserCountryDefinition( $this->mock(UI::class), - fn() => $this->mock(Constraint::class) + fn() => $this->mock(Constraint::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); $this->assertInstanceOf(Group::class, $instance->formGroup()); @@ -55,19 +57,19 @@ public function testFormGroup(): void public function testWithCriterion(): void { - $instance = new UserCountryDefinition($this->mock(UI::class), $this->fail(...)); + $instance = new UserCountryDefinition($this->mock(UI::class), $this->fail(...), new \ILIAS\Data\Privacy\Purpose\Purposes()); $this->assertInstanceOf(UserCountry::class, $instance->withCriterion($this->mock(CriterionContent::class))); } public function testTranslatedType(): void { - $instance = new UserCountryDefinition($this->mockMethod(UI::class, 'txt', ['crit_type_usr_country'], 'foo'), $this->fail(...)); + $instance = new UserCountryDefinition($this->mockMethod(UI::class, 'txt', ['crit_type_usr_country'], 'foo'), $this->fail(...), new \ILIAS\Data\Privacy\Purpose\Purposes()); $this->assertSame('foo', $instance->translatedType()); } public function testTranslatedCountry(): void { - $instance = new UserCountryDefinition($this->mockMethod(UI::class, 'txt', ['meta_c_FOO'], 'foo'), $this->fail(...)); + $instance = new UserCountryDefinition($this->mockMethod(UI::class, 'txt', ['meta_c_FOO'], 'foo'), $this->fail(...), new \ILIAS\Data\Privacy\Purpose\Purposes()); $this->assertSame('foo', $instance->translatedCountry('foo')); } } diff --git a/components/ILIAS/LegalDocuments/tests/Condition/UserCountryTest.php b/components/ILIAS/LegalDocuments/tests/Condition/UserCountryTest.php index 0b6b40b6b3a0..7e0093139996 100755 --- a/components/ILIAS/LegalDocuments/tests/Condition/UserCountryTest.php +++ b/components/ILIAS/LegalDocuments/tests/Condition/UserCountryTest.php @@ -41,7 +41,8 @@ public function testConstruct(): void $this->assertInstanceOf(UserCountry::class, new UserCountry( $this->mock(CriterionContent::class), $this->mock(UserCountryDefinition::class), - $this->mock(UIFactory::class) + $this->mock(UIFactory::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() )); } @@ -57,7 +58,8 @@ public function testAsComponent(): void $instance = new UserCountry( $this->mockTree(CriterionContent::class, ['arguments' => ['country' => 'foo']]), $this->mock(UserCountryDefinition::class), - $this->mockTree(UIFactory::class, ['legacy' => $legacy_factory]) + $this->mockTree(UIFactory::class, ['legacy' => $legacy_factory]), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); $this->assertSame($legacy, $instance->asComponent()); @@ -68,10 +70,18 @@ public function testEval(): void $instance = new UserCountry( $this->mockTree(CriterionContent::class, ['arguments' => ['country' => 'foo']]), $this->mock(UserCountryDefinition::class), - $this->mock(UIFactory::class) + $this->mock(UIFactory::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); - $this->assertTrue($instance->eval($this->mockTree(ilObjUser::class, ['getCountry' => 'foo']))); + $this->assertTrue($instance->eval($this->mockTree(ilObjUser::class, [ + 'getProfileData' => new \ILIAS\User\Profile\Data( + postal_address: new \ILIAS\Data\Privacy\Types\PostalAddress( + new \ILIAS\Data\Privacy\Types\PostalAddressValue(country: 'foo'), + new \ILIAS\Data\Privacy\Fixtures\UnitTestSource('user_country_condition') + ) + ) + ]))); } public function testDefinition(): void @@ -80,7 +90,8 @@ public function testDefinition(): void $instance = new UserCountry( $this->mock(CriterionContent::class), $definition, - $this->mock(UIFactory::class) + $this->mock(UIFactory::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); $this->assertSame($definition, $instance->definition()); @@ -91,13 +102,15 @@ public function testKnownToNeverMatchWith(): void $instance = new UserCountry( $this->mock(CriterionContent::class), $this->mock(UserCountryDefinition::class), - $this->mock(UIFactory::class) + $this->mock(UIFactory::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); $second = new UserCountry( $this->mock(CriterionContent::class), $this->mock(UserCountryDefinition::class), - $this->mock(UIFactory::class) + $this->mock(UIFactory::class), + new \ILIAS\Data\Privacy\Purpose\Purposes() ); $this->assertTrue($instance->knownToNeverMatchWith($second)); diff --git a/components/ILIAS/LegalDocuments/tests/DefaultMappingsTest.php b/components/ILIAS/LegalDocuments/tests/DefaultMappingsTest.php index 46e7e184146b..8dc341ce082c 100755 --- a/components/ILIAS/LegalDocuments/tests/DefaultMappingsTest.php +++ b/components/ILIAS/LegalDocuments/tests/DefaultMappingsTest.php @@ -57,7 +57,10 @@ public function testConditionDefinitions(): void 'language' => $this->mockMethod(ilLanguage::class, 'getInstalledLanguages', [], []), 'rbac' => $this->mockMethod(RBACServices::class, 'review', [], $this->mock(ilRbacReview::class)), ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + \ILIAS\Data\Privacy\Purpose\Purposes::class => new \ILIAS\Data\Privacy\Purpose\Purposes(), + }); $instance = new DefaultMappings('foo', $container); $result = $instance->conditionDefinitions(); diff --git a/components/ILIAS/Maps/PRIVACY_DATA.md b/components/ILIAS/Maps/PRIVACY_DATA.md new file mode 100644 index 000000000000..03ce86dfb99e --- /dev/null +++ b/components/ILIAS/Maps/PRIVACY_DATA.md @@ -0,0 +1,31 @@ +# Privacy Data Usage – Maps + +> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on 2026-07-23. +> Do not edit manually – re-run the generator after code changes. + +## Summary + +| Category | Count | +|---|---| +| Stored in DB | 0 | +| Displayed to user | 2 | +| Passed to component | 0 | +| Technical processing | 0 | +| Unmigrated (legacy access) | 0 | + +## Displayed to user + +_Data resolved for rendering in the UI._ + +| Type | UI context | File | +|---|---|---| +| `PostalAddress` | `map_user_info` | `Maps/classes/class.ilGoogleMapGUI.php:97` | +| `PostalAddress` | `map_user_info` | `Maps/classes/class.ilOpenLayersMapGUI.php:115` | + +## GDPR relevance + +| GDPR aspect | Details | +|---|---| +| Personal data categories | `PostalAddress` | +| Legal basis | _To be filled in manually_ | +| Retention period | _To be filled in manually_ | diff --git a/components/ILIAS/Maps/classes/class.ilGoogleMapGUI.php b/components/ILIAS/Maps/classes/class.ilGoogleMapGUI.php index 3cb833948145..e294be9e40a8 100755 --- a/components/ILIAS/Maps/classes/class.ilGoogleMapGUI.php +++ b/components/ILIAS/Maps/classes/class.ilGoogleMapGUI.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Data\Privacy\Purpose\Purposes; + /** * User interface class for Google Maps * @@ -86,19 +88,28 @@ public function getHtml(): string $info .= $delim . htmlspecialchars($user->getDepartment()); } $delim = "
"; + $postal_address = null; + if ($user->getPref("public_street") == "y" + || $user->getPref("public_zip") == "y" + || $user->getPref("public_city") == "y" + || $user->getPref("public_country") == "y") { + global $DIC; + $postal_address = $user->getProfileData()->getPostalAddress() + ?->resolve($DIC[Purposes::class]->displayToUser('map_user_info')); + } if ($user->getPref("public_street") == "y") { - $info .= $delim . htmlspecialchars($user->getStreet()); + $info .= $delim . htmlspecialchars($postal_address?->street ?? ''); } if ($user->getPref("public_zip") == "y") { - $info .= $delim . htmlspecialchars($user->getZipcode()); + $info .= $delim . htmlspecialchars($postal_address?->zipcode ?? ''); $delim = " "; } if ($user->getPref("public_city") == "y") { - $info .= $delim . htmlspecialchars($user->getCity()); + $info .= $delim . htmlspecialchars($postal_address?->city ?? ''); } $delim = "
"; if ($user->getPref("public_country") == "y") { - $info .= $delim . htmlspecialchars($user->getCountry()); + $info .= $delim . htmlspecialchars($postal_address?->country ?? ''); } $js_tpl->setVariable( "USER_INFO", diff --git a/components/ILIAS/Maps/classes/class.ilOpenLayersMapGUI.php b/components/ILIAS/Maps/classes/class.ilOpenLayersMapGUI.php index aa924fb12d6f..4f5d2892affc 100755 --- a/components/ILIAS/Maps/classes/class.ilOpenLayersMapGUI.php +++ b/components/ILIAS/Maps/classes/class.ilOpenLayersMapGUI.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\Data\Privacy\Purpose\Purposes; + /** * User interface class for OpenLayers maps */ @@ -104,19 +106,28 @@ public function getHtml(): string $info .= $delim . htmlspecialchars($user->getDepartment()); } $delim = "
"; + $postal_address = null; + if ($user->getPref("public_street") == "y" + || $user->getPref("public_zip") == "y" + || $user->getPref("public_city") == "y" + || $user->getPref("public_country") == "y") { + global $DIC; + $postal_address = $user->getProfileData()->getPostalAddress() + ?->resolve($DIC[Purposes::class]->displayToUser('map_user_info')); + } if ($user->getPref("public_street") == "y") { - $info .= $delim . htmlspecialchars($user->getStreet()); + $info .= $delim . htmlspecialchars($postal_address?->street ?? ''); } if ($user->getPref("public_zip") == "y") { - $info .= $delim . htmlspecialchars($user->getZipcode()); + $info .= $delim . htmlspecialchars($postal_address?->zipcode ?? ''); $delim = " "; } if ($user->getPref("public_city") == "y") { - $info .= $delim . htmlspecialchars($user->getCity()); + $info .= $delim . htmlspecialchars($postal_address?->city ?? ''); } $delim = "
"; if ($user->getPref("public_country") == "y") { - $info .= $delim . htmlspecialchars($user->getCountry()); + $info .= $delim . htmlspecialchars($postal_address?->country ?? ''); } $js_tpl->setVariable( "USER_INFO", diff --git a/components/ILIAS/MyStaff/PRIVACY_DATA.md b/components/ILIAS/MyStaff/PRIVACY_DATA.md new file mode 100644 index 000000000000..64890bed289b --- /dev/null +++ b/components/ILIAS/MyStaff/PRIVACY_DATA.md @@ -0,0 +1,39 @@ +# Privacy Data Usage – MyStaff + +> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on 2026-07-23. +> Do not edit manually – re-run the generator after code changes. + +## Summary + +| Category | Count | +|---|---| +| Stored in DB | 0 | +| Displayed to user | 2 | +| Passed to component | 0 | +| Technical processing | 0 | +| Unmigrated (legacy access) | 1 | + +## Displayed to user + +_Data resolved for rendering in the UI._ + +| Type | UI context | File | +|---|---|---| +| `PostalAddress` | `staff_list` | `MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php:237` | +| `PostalAddress` | `staff_list_export` | `MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php:399` | + +## Unmigrated (legacy access) + +_Resolves through deprecated code paths without a classified purpose. Each entry is a migration TODO._ + +| Type | Hint | File | +|---|---|---| +| `PostalAddress` | `mst_list_user_getter` | `MyStaff/classes/ListUsers/class.ilMStListUser.php:250` | + +## GDPR relevance + +| GDPR aspect | Details | +|---|---| +| Personal data categories | `PostalAddress` | +| Legal basis | _To be filled in manually_ | +| Retention period | _To be filled in manually_ | diff --git a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUser.php b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUser.php index 65505241962c..9dc87245cb9a 100755 --- a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUser.php +++ b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUser.php @@ -21,6 +21,10 @@ namespace ILIAS\MyStaff\ListUsers; use ilObjUser; +use ILIAS\Data\Privacy\Purpose\LegacyAccess; +use ILIAS\Data\Privacy\Source\LegacySource; +use ILIAS\Data\Privacy\Types\PostalAddress; +use ILIAS\Data\Privacy\Types\PostalAddressValue; /** * Class ilMStListUser @@ -36,10 +40,7 @@ final class ilMStListUser private string $hobby; private string $institution; private string $department; - private string $street; - private string $zipcode; - private string $city; - private string $country; + private ?PostalAddress $postal_address = null; private string $matriculation; private string $firstname; private string $lastname; @@ -166,44 +167,94 @@ public function setDepartment(string $department): void $this->department = $department; } + public function getPostalAddress(): ?PostalAddress + { + return $this->postal_address; + } + + public function setPostalAddress(?PostalAddress $postal_address): void + { + $this->postal_address = $postal_address; + } + + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getStreet(): string { - return $this->street; + return $this->resolveAddressForLegacyGetter()->street; } + /** + * @deprecated Use {@see setPostalAddress()} with a real source instead. + */ public function setStreet(string $street): void { - $this->street = $street; + $this->postal_address = $this->postalAddressForLegacySetter() + ->withStreet($street, new LegacySource('mst_list_user_setter')); } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getZipcode(): string { - return $this->zipcode; + return $this->resolveAddressForLegacyGetter()->zipcode; } + /** + * @deprecated Use {@see setPostalAddress()} with a real source instead. + */ public function setZipcode(string $zipcode): void { - $this->zipcode = $zipcode; + $this->postal_address = $this->postalAddressForLegacySetter() + ->withZipcode($zipcode, new LegacySource('mst_list_user_setter')); } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getCity(): string { - return $this->city; + return $this->resolveAddressForLegacyGetter()->city; } + /** + * @deprecated Use {@see setPostalAddress()} with a real source instead. + */ public function setCity(string $city): void { - $this->city = $city; + $this->postal_address = $this->postalAddressForLegacySetter() + ->withCity($city, new LegacySource('mst_list_user_setter')); } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getCountry(): string { - return $this->country; + return $this->resolveAddressForLegacyGetter()->country; } + /** + * @deprecated Use {@see setPostalAddress()} with a real source instead. + */ public function setCountry(string $country): void { - $this->country = $country; + $this->postal_address = $this->postalAddressForLegacySetter() + ->withCountry($country, new LegacySource('mst_list_user_setter')); + } + + private function resolveAddressForLegacyGetter(): PostalAddressValue + { + return $this->postal_address?->resolve(new LegacyAccess('mst_list_user_getter')) + ?? new PostalAddressValue(); + } + + private function postalAddressForLegacySetter(): PostalAddress + { + return $this->postal_address + ?? new PostalAddress(new PostalAddressValue(), new LegacySource('mst_list_user_setter')); } public function getMatriculation(): string diff --git a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsers.php b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsers.php index a550377d24a8..ed8c6cd98f26 100755 --- a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsers.php +++ b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsers.php @@ -22,6 +22,7 @@ use ILIAS\DI\Container; use ILIAS\components\MyStaff\Utils\ListFetcherResult; +use ILIAS\Data\Privacy\Types\PostalAddressValue; /** * Class ilListUser @@ -96,6 +97,7 @@ final public function getData(array $arr_usr_ids = array(), array $options = arr $result = $this->dic->database()->query($select); $user_data = array(); + $privacy = $this->dic[\ILIAS\Data\Privacy\Services::class]; while ($user = $this->dic->database()->fetchAssoc($result)) { $list_user = new ilMStListUser(); @@ -104,10 +106,17 @@ final public function getData(array $arr_usr_ids = array(), array $options = arr $list_user->setTitle($user['title'] ?? ""); $list_user->setInstitution($user['institution'] ?? ""); $list_user->setDepartment($user['department'] ?? ""); - $list_user->setStreet($user['street'] ?? ""); - $list_user->setZipcode($user['zipcode'] ?? ""); - $list_user->setCity($user['city'] ?? ""); - $list_user->setCountry($user['country'] ?? ""); + $list_user->setPostalAddress( + $privacy->factory()->postalAddress( + new PostalAddressValue( + $user['street'] ?? "", + $user['city'] ?? "", + $user['zipcode'] ?? "", + $user['country'] ?? "" + ), + $privacy->sources()->user()->postalAddress() + ) + ); $list_user->setHobby($user['hobby'] ?? ""); $list_user->setMatriculation($user['matriculation'] ?? ""); $list_user->setActive(intval($user['active'])); diff --git a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php index a517b2073e44..45fac4a078bb 100755 --- a/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php +++ b/components/ILIAS/MyStaff/classes/ListUsers/class.ilMStListUsersTableGUI.php @@ -38,6 +38,7 @@ class ilMStListUsersTableGUI extends \ilTable2GUI private \ILIAS\UI\Renderer $uiRenderer; private \ilLanguage $language; private Profile $profile; + private \ILIAS\Data\Privacy\Purpose\Purposes $purposes; /** * @param \ilMStListUsersGUI $parent_obj @@ -49,6 +50,7 @@ public function __construct(\ilMStListUsersGUI $parent_obj, $parent_cmd = \ilMSt $this->access = ilMyStaffAccess::getInstance(); $this->profile = $DIC['user']->getProfile(); + $this->purposes = $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class]; $this->setPrefix('myst_lu'); $this->setFormName('myst_lu'); @@ -230,6 +232,13 @@ final protected function fillRow(array $a_set): void $this->tpl->setVariable('user_profile_picture', $this->uiRenderer->render($avatar)); $this->tpl->parseCurrentBlock(); + $postal_address = null; + if (array_intersect(['street', 'zipcode', 'city', 'country'], array_keys($this->getSelectedColumns())) !== []) { + $postal_address = $set->getPostalAddress()?->resolve( + $this->purposes->displayToUser('staff_list') + ); + } + foreach ($this->getSelectedColumns() as $k => $v) { switch ($k) { case 'org_units': @@ -240,6 +249,14 @@ final protected function fillRow(array $a_set): void ); $this->tpl->parseCurrentBlock(); break; + case 'street': + case 'zipcode': + case 'city': + case 'country': + $this->tpl->setCurrentBlock('td'); + $this->tpl->setVariable('VALUE', ($postal_address->{$k} ?? '') !== '' ? $postal_address->{$k} : ' '); + $this->tpl->parseCurrentBlock(); + break; case 'gender': $this->tpl->setCurrentBlock('td'); $this->tpl->setVariable('VALUE', $this->language->txt('gender_' . $set->getGender())); @@ -377,11 +394,24 @@ protected function getFieldValuesForExport(ilMStListUser $my_staff_user): array $field_values = array(); + $postal_address = null; + if (array_intersect(['street', 'zipcode', 'city', 'country'], array_keys($this->getSelectedColumns())) !== []) { + $postal_address = $my_staff_user->getPostalAddress()?->resolve( + $this->purposes->displayToUser('staff_list_export') + ); + } + foreach ($this->getSelectedColumns() as $k => $v) { switch ($k) { case 'org_units': $field_values[$k] = $this->getTextRepresentationOfUsersOrgUnits($my_staff_user->getUsrId()); break; + case 'street': + case 'zipcode': + case 'city': + case 'country': + $field_values[$k] = $postal_address->{$k} ?? ''; + break; case 'gender': $field_values[$k] = $DIC->language()->txt('gender_' . $my_staff_user->getGender()); break; diff --git a/components/ILIAS/TermsOfService/tests/ConsumerTest.php b/components/ILIAS/TermsOfService/tests/ConsumerTest.php index d1234b29a7c0..81fcff31ab83 100755 --- a/components/ILIAS/TermsOfService/tests/ConsumerTest.php +++ b/components/ILIAS/TermsOfService/tests/ConsumerTest.php @@ -22,6 +22,7 @@ use ilCtrl; use ilObjectDataCache; +use ILIAS\Data\Privacy\Purpose\Purposes; use ILIAS\LegalDocuments\LazyProvide; use ILIAS\LegalDocuments\UseSlot; use ilSetting; @@ -56,7 +57,10 @@ public function testDisabledUses(): void 'settings' => $settings, 'refinery' => ['byTrying' => $by_trying], ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + Purposes::class => new Purposes(), + }); $slot = $this->mock(UseSlot::class); $slot->expects($this->once())->method('hasDocuments')->willReturn($slot); @@ -80,7 +84,10 @@ public function testUses(): void 'refinery' => ['byTrying' => $by_trying], 'ctrl' => $this->mock(ilCtrl::class), ]); - $container->expects($this->once())->method('offsetGet')->with('ilObjDataCache')->willReturn($this->mock(ilObjectDataCache::class)); + $container->expects($this->exactly(2))->method('offsetGet')->willReturnCallback(fn(string $id) => match ($id) { + 'ilObjDataCache' => $this->mock(ilObjectDataCache::class), + Purposes::class => new Purposes(), + }); $slot = $this->mock(UseSlot::class); $slot->expects($this->once())->method('hasDocuments')->willReturn($slot); diff --git a/components/ILIAS/User/PRIVACY_DATA.md b/components/ILIAS/User/PRIVACY_DATA.md new file mode 100644 index 000000000000..1f8eaf412b17 --- /dev/null +++ b/components/ILIAS/User/PRIVACY_DATA.md @@ -0,0 +1,70 @@ +# Privacy Data Usage – User + +> Auto-generated by `scripts/Privacy/generate-privacy-docs.php` on 2026-07-23. +> Do not edit manually – re-run the generator after code changes. + +## Summary + +| Category | Count | +|---|---| +| Stored in DB | 1 | +| Displayed to user | 7 | +| Passed to component | 1 | +| Technical processing | 1 | +| Unmigrated (legacy access) | 1 | + +## Stored data + +_Data resolved for persistence in a database table._ + +| Type | Target table · column | File | +|---|---|---| +| `PostalAddress` | `usr_data.(street,city,zipcode,country)` | `User/src/Profile/DatabaseDataRepository.php:121` | + +## Displayed to user + +_Data resolved for rendering in the UI._ + +| Type | UI context | File | +|---|---|---| +| `PostalAddress` | `profile_form` | `User/src/Profile/Fields/Standard/City.php:72` | +| `PostalAddress` | `profile_form` | `User/src/Profile/Fields/Standard/Country.php:71` | +| `PostalAddress` | `profile_form_location` | `User/src/Profile/Fields/Standard/Location.php:109` | +| `PostalAddress` | `profile_form` | `User/src/Profile/Fields/Standard/Street.php:72` | +| `PostalAddress` | `profile_form` | `User/src/Profile/Fields/Standard/ZipCode.php:72` | +| `PostalAddress` | `public_profile` | `User/src/Profile/class.PublicProfileGUI.php:373` | +| `PostalAddress` | `vcard` | `User/src/Profile/class.PublicProfileGUI.php:624` | + +## Passed to other components + +_Data resolved and forwarded to another component._ + +| Type | Target component | Reason | File | +|---|---|---|---| +| `PostalAddress` | `Mail` | profile_mail_body | `User/classes/class.ilObjUser.php:1580` | + +## Technical processing + +_Data resolved for internal computation. Not persisted or displayed._ + +| Type | Operation | File | +|---|---|---| +| `PostalAddress` | `user_import_update` | `User/classes/class.ilUserImportParser.php:2253` | + +## Unmigrated (legacy access) + +_Resolves through deprecated code paths without a classified purpose. Each entry is a migration TODO._ + +| Type | Hint | File | +|---|---|---| +| `PostalAddress` | `profile_data_getter` | `User/src/Profile/Data.php:266` | + +## GDPR relevance + +| GDPR aspect | Details | +|---|---| +| Personal data categories | `PostalAddress` | +| Storage locations | `usr_data.(street,city,zipcode,country)` | +| Data recipients | `Mail` | +| Legal basis | _To be filled in manually_ | +| Retention period | _To be filled in manually_ | diff --git a/components/ILIAS/User/classes/class.ilObjUser.php b/components/ILIAS/User/classes/class.ilObjUser.php index f7e6d356f8ea..880af3b56663 100755 --- a/components/ILIAS/User/classes/class.ilObjUser.php +++ b/components/ILIAS/User/classes/class.ilObjUser.php @@ -27,6 +27,7 @@ use ILIAS\User\Profile\Fields\Standard\HelpOffered; use ILIAS\User\Profile\Fields\Standard\HelpLookedFor; use ILIAS\User\Settings\DataRepository as SettingsDataRepository; +use ILIAS\Data\Privacy\Purpose\Purposes; use ILIAS\Language\Language; use ILIAS\ResourceStorage\Services; use ILIAS\ResourceStorage\Identification\ResourceIdentification; @@ -542,6 +543,16 @@ public function getProfileData(): Data return $this->profile_data; } + /** + * In-place replacement for subclasses that mutate profile data with + * a proper privacy source (e.g. authentication providers). Prefer + * {@see withProfileData()} where the caller controls the instance. + */ + protected function replaceProfileData(Data $profile_data): void + { + $this->profile_data = $profile_data; + } + public function setLogin(string $login): void { $this->profile_data = $this->profile_data->withAlias($login); @@ -635,41 +646,65 @@ public function getDepartment(): string return $this->profile_data->getDepartment(); } + /** + * @deprecated Derive the postal address of {@see getProfileData()} with a real privacy source instead. + */ public function setStreet(string $street): void { $this->profile_data = $this->profile_data->withStreet($street); } + /** + * @deprecated Resolve the postal address of {@see getProfileData()} with a real privacy purpose instead. + */ public function getStreet(): string { return $this->profile_data->getStreet(); } + /** + * @deprecated Derive the postal address of {@see getProfileData()} with a real privacy source instead. + */ public function setCity(string $city): void { $this->profile_data = $this->profile_data->withCity($city); } + /** + * @deprecated Resolve the postal address of {@see getProfileData()} with a real privacy purpose instead. + */ public function getCity(): string { return $this->profile_data->getCity(); } + /** + * @deprecated Derive the postal address of {@see getProfileData()} with a real privacy source instead. + */ public function setZipcode(string $zipcode): void { $this->profile_data = $this->profile_data->withZipcode($zipcode); } + /** + * @deprecated Resolve the postal address of {@see getProfileData()} with a real privacy purpose instead. + */ public function getZipcode(): string { return $this->profile_data->getZipcode(); } + /** + * @deprecated Derive the postal address of {@see getProfileData()} with a real privacy source instead. + */ public function setCountry(string $country): void { $this->profile_data = $this->profile_data->withCountry($country); } + /** + * @deprecated Resolve the postal address of {@see getProfileData()} with a real privacy purpose instead. + */ public function getCountry(): string { return $this->profile_data->getCountry(); @@ -1542,17 +1577,22 @@ public function getProfileAsString(Language $language): string if ($this->getDepartment() !== '') { $body .= ($language->txt('department') . ': ' . $this->getDepartment() . "\n"); } - if ($this->getStreet() !== '') { - $body .= ($language->txt('street') . ': ' . $this->getStreet() . "\n"); - } - if ($this->getCity() !== '') { - $body .= ($language->txt('city') . ': ' . $this->getCity() . "\n"); - } - if ($this->getZipcode() !== '') { - $body .= ($language->txt('zipcode') . ': ' . $this->getZipcode() . "\n"); - } - if ($this->getCountry() !== '') { - $body .= ($language->txt('country') . ': ' . $this->getCountry() . "\n"); + $postal_address = $this->profile_data->getPostalAddress()?->resolve( + $DIC[Purposes::class]->passToComponent('Mail', 'profile_mail_body') + ); + if ($postal_address !== null) { + if ($postal_address->street !== '') { + $body .= ($language->txt('street') . ': ' . $postal_address->street . "\n"); + } + if ($postal_address->city !== '') { + $body .= ($language->txt('city') . ': ' . $postal_address->city . "\n"); + } + if ($postal_address->zipcode !== '') { + $body .= ($language->txt('zipcode') . ': ' . $postal_address->zipcode . "\n"); + } + if ($postal_address->country !== '') { + $body .= ($language->txt('country') . ': ' . $postal_address->country . "\n"); + } } if ($this->getPhoneOffice() !== '') { $body .= ($language->txt('phone_office') . ': ' . $this->getPhoneOffice() . "\n"); diff --git a/components/ILIAS/User/classes/class.ilUserImportParser.php b/components/ILIAS/User/classes/class.ilUserImportParser.php index 947024f59eec..944bed9cce3b 100755 --- a/components/ILIAS/User/classes/class.ilUserImportParser.php +++ b/components/ILIAS/User/classes/class.ilUserImportParser.php @@ -23,6 +23,8 @@ use ILIAS\User\UserGUIRequest; use ILIAS\User\Profile\Profile; use ILIAS\User\Profile\Data as ProfileData; +use ILIAS\Data\Privacy\Purpose\Purposes; +use ILIAS\Data\Privacy\Source\Sources; use ILIAS\Refinery\Factory as Refinery; class ilUserImportParser extends ilSaxParser @@ -1109,18 +1111,15 @@ public function importEndTag( if ($this->tagContained('Institution')) { $update_user->setInstitution($this->user_obj->getInstitution()); } - if ($this->tagContained('Street')) { - $update_user->setStreet($this->user_obj->getStreet()); - } - if ($this->tagContained('City')) { - $update_user->setCity($this->user_obj->getCity()); - } - if ($this->tagContained('PostalCode')) { - $update_user->setZipcode($this->user_obj->getZipcode()); - } - if ($this->tagContained('SelCountry') && mb_strlen($this->cdata) === 2) { - $update_user->setCountry($this->user_obj->getCountry()); - } + $update_user = $this->copyImportedAddressFields( + $update_user, + array_keys(array_filter([ + 'street' => $this->tagContained('Street'), + 'city' => $this->tagContained('City'), + 'zipcode' => $this->tagContained('PostalCode'), + 'country' => $this->tagContained('SelCountry') && mb_strlen($this->cdata) === 2, + ])) + ); if ($this->tagContained('PhoneOffice')) { $update_user->setPhoneOffice($this->user_obj->getPhoneOffice()); } @@ -1327,15 +1326,15 @@ public function importEndTag( break; case 'Street': - $this->user_obj->setStreet($this->getCDataWithoutTags($this->cdata)); + $this->setImportedAddressField('street', $this->getCDataWithoutTags($this->cdata)); break; case 'City': - $this->user_obj->setCity($this->getCDataWithoutTags($this->cdata)); + $this->setImportedAddressField('city', $this->getCDataWithoutTags($this->cdata)); break; case 'PostalCode': - $this->user_obj->setZipcode($this->getCDataWithoutTags($this->cdata)); + $this->setImportedAddressField('zipcode', $this->getCDataWithoutTags($this->cdata)); break; case 'Country': @@ -1343,7 +1342,7 @@ public function importEndTag( if (mb_strlen($this->cdata) !== 2) { break; } - $this->user_obj->setCountry($this->getCDataWithoutTags($this->cdata)); + $this->setImportedAddressField('country', $this->getCDataWithoutTags($this->cdata)); break; case 'PhoneOffice': @@ -1695,15 +1694,15 @@ public function verifyEndTag( break; case 'Street': - $this->user_obj->setStreet($this->cdata); + $this->setImportedAddressField('street', $this->cdata); break; case 'City': - $this->user_obj->setCity($this->cdata); + $this->setImportedAddressField('city', $this->cdata); break; case 'PostalCode': - $this->user_obj->setZipcode($this->cdata); + $this->setImportedAddressField('zipcode', $this->cdata); break; case 'Country': @@ -1711,7 +1710,7 @@ public function verifyEndTag( if (mb_strlen($this->cdata) !== 2) { break; } - $this->user_obj->setCountry($this->cdata); + $this->setImportedAddressField('country', $this->cdata); break; case 'PhoneOffice': @@ -2221,6 +2220,90 @@ private function getCDataWithoutTags(): string return $this->stripTags($this->cdata); } + /** + * Carries the address fields contained in the import over to the + * user being updated, keeping the ExternalApi source set by the + * staging user and without routing through the deprecated string + * accessors. + * + * @param list $fields + */ + private function copyImportedAddressFields(ilObjUser $update_user, array $fields): ilObjUser + { + if ($fields === []) { + return $update_user; + } + + $source_address = $this->user_obj->getProfileData()->getPostalAddress(); + $target_data = $update_user->getProfileData(); + $target_address = $target_data->getPostalAddress(); + if ($source_address === null || $target_address === null) { + // bare instances without a bound postal address — deprecated setter path + foreach ($fields as $field) { + match ($field) { + 'street' => $update_user->setStreet($this->user_obj->getStreet()), + 'city' => $update_user->setCity($this->user_obj->getCity()), + 'zipcode' => $update_user->setZipcode($this->user_obj->getZipcode()), + 'country' => $update_user->setCountry($this->user_obj->getCountry()), + }; + } + return $update_user; + } + + $imported = $source_address->resolve($this->privacyPurposes()->technicalProcessing('user_import_update')); + foreach ($fields as $field) { + $source = $this->privacySources()->externalApi('xml_import', $field); + $target_address = match ($field) { + 'street' => $target_address->withStreet($imported->street, $source), + 'city' => $target_address->withCity($imported->city, $source), + 'zipcode' => $target_address->withZipcode($imported->zipcode, $source), + 'country' => $target_address->withCountry($imported->country, $source), + }; + } + + return $update_user->withProfileData($target_data->withPostalAddress($target_address)); + } + + private function privacyPurposes(): Purposes + { + global $DIC; + return $DIC[Purposes::class]; + } + + private function privacySources(): Sources + { + global $DIC; + return $DIC[Sources::class]; + } + + private function setImportedAddressField(string $field, string $value): void + { + $profile_data = $this->user_obj->getProfileData(); + $address = $profile_data->getPostalAddress(); + if ($address === null) { + // bare instance without a bound postal address — deprecated setter path + match ($field) { + 'street' => $this->user_obj->setStreet($value), + 'city' => $this->user_obj->setCity($value), + 'zipcode' => $this->user_obj->setZipcode($value), + 'country' => $this->user_obj->setCountry($value), + }; + return; + } + + $source = $this->privacySources()->externalApi('xml_import', $field); + $this->user_obj = $this->user_obj->withProfileData( + $profile_data->withPostalAddress( + match ($field) { + 'street' => $address->withStreet($value, $source), + 'city' => $address->withCity($value, $source), + 'zipcode' => $address->withZipcode($value, $source), + 'country' => $address->withCountry($value, $source), + } + ) + ); + } + private function stripTags(string $string): string { return $this->refinery->string()->stripTags()->transform($string); diff --git a/components/ILIAS/User/src/LocalDIC.php b/components/ILIAS/User/src/LocalDIC.php index a13d3a082b7e..0d73a176581d 100755 --- a/components/ILIAS/User/src/LocalDIC.php +++ b/components/ILIAS/User/src/LocalDIC.php @@ -97,6 +97,7 @@ private function init(ILIASContainer $DIC): void new DatabaseProfileDataRepository( $DIC['ilDB'], $DIC['resource_storage'], + $DIC[\ILIAS\Data\Privacy\Services::class], $c[ProfileFieldsConfigurationRepository::class] ); $this[ProfileFieldsConfigurationRepository::class] = fn($c): ProfileFieldsConfigurationRepository => @@ -135,10 +136,22 @@ private function init(ILIASContainer $DIC): void ), new Standard\Institution(), new Standard\Department(), - new Standard\Street(), - new Standard\ZipCode(), - new Standard\City(), - new Standard\Country(), + new Standard\Street( + $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class], + $DIC[\ILIAS\Data\Privacy\Source\Sources::class] + ), + new Standard\ZipCode( + $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class], + $DIC[\ILIAS\Data\Privacy\Source\Sources::class] + ), + new Standard\City( + $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class], + $DIC[\ILIAS\Data\Privacy\Source\Sources::class] + ), + new Standard\Country( + $DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class], + $DIC[\ILIAS\Data\Privacy\Source\Sources::class] + ), new Standard\PhoneOffice(), new Standard\PhoneHome(), new Standard\PhoneMobile(), @@ -149,7 +162,9 @@ private function init(ILIASContainer $DIC): void new Standard\ReferralComment(), new Standard\Matriculation(), new Standard\ClientIP(), - \ilMapUtil::isActivated() ? new Standard\Location() : null + \ilMapUtil::isActivated() + ? new Standard\Location($DIC[\ILIAS\Data\Privacy\Purpose\Purposes::class]) + : null ]) ); $this['profile.fields.changelisteners'] = fn($c): array => diff --git a/components/ILIAS/User/src/Profile/Data.php b/components/ILIAS/User/src/Profile/Data.php index 4bd76890b3df..fcf65dea6a2d 100644 --- a/components/ILIAS/User/src/Profile/Data.php +++ b/components/ILIAS/User/src/Profile/Data.php @@ -22,6 +22,10 @@ use ILIAS\User\Profile\Fields\Standard\Genders; use ILIAS\ResourceStorage\Identification\ResourceIdentification; +use ILIAS\Data\Privacy\Purpose\LegacyAccess; +use ILIAS\Data\Privacy\Source\LegacySource; +use ILIAS\Data\Privacy\Types\PostalAddress; +use ILIAS\Data\Privacy\Types\PostalAddressValue; class Data { @@ -38,10 +42,7 @@ public function __construct( private ?\DateTimeImmutable $birthday = null, private string $institution = '', private string $department = '', - private string $street = '', - private string $city = '', - private string $zipcode = '', - private string $country = '', + private ?PostalAddress $postal_address = null, private string $email = '', private ?string $second_email = null, private string $phone_office = '', @@ -176,52 +177,105 @@ public function withDepartment(string $department): self return $clone; } - public function getStreet(): string + public function getPostalAddress(): ?PostalAddress { - return $this->street; + return $this->postal_address; } - public function withStreet(string $street): self + public function withPostalAddress(PostalAddress $postal_address): self { $clone = clone $this; - $clone->street = $street; + $clone->postal_address = $postal_address; return $clone; } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ + public function getStreet(): string + { + return $this->resolveAddressForLegacyGetter()->street; + } + + /** + * @deprecated Derive {@see getPostalAddress()} with a real source and use {@see withPostalAddress()} instead. + */ + public function withStreet(string $street): self + { + return $this->withPostalAddress( + $this->postalAddressForLegacyWither()->withStreet($street, new LegacySource('profile_data_wither')) + ); + } + + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getCity(): string { - return $this->city; + return $this->resolveAddressForLegacyGetter()->city; } + /** + * @deprecated Derive {@see getPostalAddress()} with a real source and use {@see withPostalAddress()} instead. + */ public function withCity(string $city): self { - $clone = clone $this; - $clone->city = $city; - return $clone; + return $this->withPostalAddress( + $this->postalAddressForLegacyWither()->withCity($city, new LegacySource('profile_data_wither')) + ); } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getZipcode(): string { - return $this->zipcode; + return $this->resolveAddressForLegacyGetter()->zipcode; } + /** + * @deprecated Derive {@see getPostalAddress()} with a real source and use {@see withPostalAddress()} instead. + */ public function withZipcode(string $zipcode): self { - $clone = clone $this; - $clone->zipcode = $zipcode; - return $clone; + return $this->withPostalAddress( + $this->postalAddressForLegacyWither()->withZipcode($zipcode, new LegacySource('profile_data_wither')) + ); } + /** + * @deprecated Resolve {@see getPostalAddress()} with a real purpose instead. + */ public function getCountry(): string { - return $this->country; + return $this->resolveAddressForLegacyGetter()->country; } + /** + * @deprecated Derive {@see getPostalAddress()} with a real source and use {@see withPostalAddress()} instead. + */ public function withCountry(string $country): self { - $clone = clone $this; - $clone->country = $country; - return $clone; + return $this->withPostalAddress( + $this->postalAddressForLegacyWither()->withCountry($country, new LegacySource('profile_data_wither')) + ); + } + + private function resolveAddressForLegacyGetter(): PostalAddressValue + { + return $this->postal_address?->resolve(new LegacyAccess('profile_data_getter')) + ?? new PostalAddressValue(); + } + + /** + * Transitional: instances built through the repository always carry a + * postal address bound to the audit logger. Only bare `new Data()` + * (e.g. in tests) falls back to an unlogged instance here. + */ + private function postalAddressForLegacyWither(): PostalAddress + { + return $this->postal_address + ?? new PostalAddress(new PostalAddressValue(), new LegacySource('profile_data_wither')); } public function getEmail(): string diff --git a/components/ILIAS/User/src/Profile/DatabaseDataRepository.php b/components/ILIAS/User/src/Profile/DatabaseDataRepository.php index 847d24d87583..49cbeed060ea 100644 --- a/components/ILIAS/User/src/Profile/DatabaseDataRepository.php +++ b/components/ILIAS/User/src/Profile/DatabaseDataRepository.php @@ -30,6 +30,8 @@ use ILIAS\User\Search\DefaultAutocompleteItem; use ILIAS\User\Settings\DataRepository as SettingsDataRepository; use ILIAS\ResourceStorage\Services as ResourceStorage; +use ILIAS\Data\Privacy\Services as PrivacyServices; +use ILIAS\Data\Privacy\Types\PostalAddressValue; class DatabaseDataRepository implements DataRepository { @@ -48,13 +50,19 @@ class DatabaseDataRepository implements DataRepository public function __construct( private readonly \ilDBInterface $db, - private readonly ResourceStorage $irss + private readonly ResourceStorage $irss, + private readonly PrivacyServices $privacy ) { } public function getDefault(): Data { - return new Data(); + return new Data( + postal_address: $this->privacy->factory()->postalAddress( + new PostalAddressValue(), + $this->privacy->sources()->legacy('default_profile_data') + ) + ); } public function getSingle(int $id): Data @@ -110,6 +118,9 @@ public function getMultiple(array $user_ids): \Generator public function store(Data $user_data): void { $system_information = $user_data->getSystemInformation(); + $postal_address = $user_data->getPostalAddress()?->resolve( + $this->privacy->purposes()->storeInTable($this->privacy->sources()->user()->postalAddress()) + ) ?? new PostalAddressValue(); $this->db->replace( self::USER_BASE_TABLE, [ @@ -130,10 +141,10 @@ public function store(Data $user_data): void 'hobby' => [\ilDBConstants::T_TEXT, $user_data->getHobby()], 'institution' => [\ilDBConstants::T_TEXT, $user_data->getInstitution()], 'department' => [\ilDBConstants::T_TEXT, $user_data->getDepartment()], - 'street' => [\ilDBConstants::T_TEXT, $user_data->getStreet()], - 'city' => [\ilDBConstants::T_TEXT, $user_data->getCity()], - 'zipcode' => [\ilDBConstants::T_TEXT, $user_data->getZipcode()], - 'country' => [\ilDBConstants::T_TEXT, $user_data->getCountry()], + 'street' => [\ilDBConstants::T_TEXT, $postal_address->street], + 'city' => [\ilDBConstants::T_TEXT, $postal_address->city], + 'zipcode' => [\ilDBConstants::T_TEXT, $postal_address->zipcode], + 'country' => [\ilDBConstants::T_TEXT, $postal_address->country], 'phone_office' => [\ilDBConstants::T_TEXT, $user_data->getPhoneOffice()], 'phone_home' => [\ilDBConstants::T_TEXT, $user_data->getPhoneHome()], 'phone_mobile' => [\ilDBConstants::T_TEXT, $user_data->getPhoneMobile()], @@ -331,10 +342,15 @@ private function buildFromData( : null, $base_data->institution ?? '', $base_data->department ?? '', - $base_data->street ?? '', - $base_data->city ?? '', - $base_data->zipcode ?? '', - $base_data->country ?? '', + $this->privacy->factory()->postalAddress( + new PostalAddressValue( + $base_data->street ?? '', + $base_data->city ?? '', + $base_data->zipcode ?? '', + $base_data->country ?? '' + ), + $this->privacy->sources()->user()->postalAddress() + ), $base_data->email ?? '', $base_data->second_email, $base_data->phone_office ?? '', diff --git a/components/ILIAS/User/src/Profile/Fields/Standard/City.php b/components/ILIAS/User/src/Profile/Fields/Standard/City.php index 4ae66f7aae6d..215ee64a8157 100644 --- a/components/ILIAS/User/src/Profile/Fields/Standard/City.php +++ b/components/ILIAS/User/src/Profile/Fields/Standard/City.php @@ -25,11 +25,19 @@ use ILIAS\User\Profile\Fields\FieldDefinition; use ILIAS\User\Profile\Fields\AvailableSections; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; +use ILIAS\Data\Privacy\Source\Sources; class City implements FieldDefinition { use NoOverrides; + public function __construct( + private readonly Purposes $purposes, + private readonly Sources $sources, + ) { + } + public function getIdentifier(): string { return 'city'; @@ -61,7 +69,8 @@ public function getLegacyInput( return $input; } $input->setValue( - $this->retrieveValueFromUser($user) + $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('profile_form'))->city ?? '' ); return $input; } @@ -71,8 +80,15 @@ public function addValueToUserObject( mixed $input, ?\ilPropertyFormGUI $form = null ): \ilObjUser { - $user->setCity($input); - return $user; + $address = $user->getProfileData()->getPostalAddress() + ?->withCity((string) $input, $this->sources->userInput('profile_form')); + if ($address === null) { + $user->setCity($input); + return $user; + } + return $user->withProfileData( + $user->getProfileData()->withPostalAddress($address) + ); } public function retrieveValueFromUser(\ilObjUser $user): string diff --git a/components/ILIAS/User/src/Profile/Fields/Standard/Country.php b/components/ILIAS/User/src/Profile/Fields/Standard/Country.php index 3bb572ecff09..407e3734b68e 100644 --- a/components/ILIAS/User/src/Profile/Fields/Standard/Country.php +++ b/components/ILIAS/User/src/Profile/Fields/Standard/Country.php @@ -25,11 +25,19 @@ use ILIAS\User\Profile\Fields\FieldDefinition; use ILIAS\User\Profile\Fields\AvailableSections; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; +use ILIAS\Data\Privacy\Source\Sources; class Country implements FieldDefinition { use NoOverrides; + public function __construct( + private readonly Purposes $purposes, + private readonly Sources $sources, + ) { + } + public function getIdentifier(): string { return 'country'; @@ -60,7 +68,8 @@ public function getLegacyInput( return $input; } $input->setValue( - $this->retrieveValueFromUser($user) + $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('profile_form'))->country ?? '' ); return $input; } @@ -70,8 +79,15 @@ public function addValueToUserObject( mixed $input, ?\ilPropertyFormGUI $form = null ): \ilObjUser { - $user->setCountry($input); - return $user; + $address = $user->getProfileData()->getPostalAddress() + ?->withCountry((string) $input, $this->sources->userInput('profile_form')); + if ($address === null) { + $user->setCountry($input); + return $user; + } + return $user->withProfileData( + $user->getProfileData()->withPostalAddress($address) + ); } public function retrieveValueFromUser(\ilObjUser $user): string diff --git a/components/ILIAS/User/src/Profile/Fields/Standard/Location.php b/components/ILIAS/User/src/Profile/Fields/Standard/Location.php index 85da4fb4c5af..efc94aff5099 100644 --- a/components/ILIAS/User/src/Profile/Fields/Standard/Location.php +++ b/components/ILIAS/User/src/Profile/Fields/Standard/Location.php @@ -25,11 +25,17 @@ use ILIAS\User\Profile\Fields\FieldDefinition; use ILIAS\User\Profile\Fields\AvailableSections; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; class Location implements FieldDefinition { use NoOverrides; + public function __construct( + private readonly Purposes $purposes, + ) { + } + public function getIdentifier(): string { return 'location'; @@ -100,15 +106,17 @@ public function getLegacyInput( $zoom = (int) $def['zoom']; } - $street = $user?->getStreet() ?? ''; + $address = $user?->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('profile_form_location')); + $street = $address?->street ?? ''; if ($street === '') { $street = $lng->txt('street'); } - $city = $user?->getCity() ?? ''; + $city = $address?->city ?? ''; if ($city === '') { $city = $lng->txt('city'); } - $country = $user?->getCountry() ?? ''; + $country = $address?->country ?? ''; if ($country === '') { $country = $lng->txt('country'); } diff --git a/components/ILIAS/User/src/Profile/Fields/Standard/Street.php b/components/ILIAS/User/src/Profile/Fields/Standard/Street.php index a8b2fd891b56..baa4ff54175c 100644 --- a/components/ILIAS/User/src/Profile/Fields/Standard/Street.php +++ b/components/ILIAS/User/src/Profile/Fields/Standard/Street.php @@ -25,11 +25,19 @@ use ILIAS\User\Profile\Fields\FieldDefinition; use ILIAS\User\Profile\Fields\AvailableSections; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; +use ILIAS\Data\Privacy\Source\Sources; class Street implements FieldDefinition { use NoOverrides; + public function __construct( + private readonly Purposes $purposes, + private readonly Sources $sources, + ) { + } + public function getIdentifier(): string { return 'street'; @@ -61,7 +69,8 @@ public function getLegacyInput( return $input; } $input->setValue( - $this->retrieveValueFromUser($user) + $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('profile_form'))->street ?? '' ); return $input; } @@ -71,8 +80,15 @@ public function addValueToUserObject( mixed $input, ?\ilPropertyFormGUI $form = null ): \ilObjUser { - $user->setStreet($input); - return $user; + $address = $user->getProfileData()->getPostalAddress() + ?->withStreet((string) $input, $this->sources->userInput('profile_form')); + if ($address === null) { + $user->setStreet($input); + return $user; + } + return $user->withProfileData( + $user->getProfileData()->withPostalAddress($address) + ); } public function retrieveValueFromUser(\ilObjUser $user): string diff --git a/components/ILIAS/User/src/Profile/Fields/Standard/ZipCode.php b/components/ILIAS/User/src/Profile/Fields/Standard/ZipCode.php index 1dfde89f8d8d..e81110cb459f 100644 --- a/components/ILIAS/User/src/Profile/Fields/Standard/ZipCode.php +++ b/components/ILIAS/User/src/Profile/Fields/Standard/ZipCode.php @@ -25,11 +25,19 @@ use ILIAS\User\Profile\Fields\FieldDefinition; use ILIAS\User\Profile\Fields\AvailableSections; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; +use ILIAS\Data\Privacy\Source\Sources; class ZipCode implements FieldDefinition { use NoOverrides; + public function __construct( + private readonly Purposes $purposes, + private readonly Sources $sources, + ) { + } + public function getIdentifier(): string { return 'zipcode'; @@ -61,7 +69,8 @@ public function getLegacyInput( return $input; } $input->setValue( - $this->retrieveValueFromUser($user) + $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('profile_form'))->zipcode ?? '' ); return $input; } @@ -71,8 +80,15 @@ public function addValueToUserObject( mixed $input, ?\ilPropertyFormGUI $form = null ): \ilObjUser { - $user->setZipcode($input); - return $user; + $address = $user->getProfileData()->getPostalAddress() + ?->withZipcode((string) $input, $this->sources->userInput('profile_form')); + if ($address === null) { + $user->setZipcode($input); + return $user; + } + return $user->withProfileData( + $user->getProfileData()->withPostalAddress($address) + ); } public function retrieveValueFromUser(\ilObjUser $user): string diff --git a/components/ILIAS/User/src/Profile/class.PublicProfileGUI.php b/components/ILIAS/User/src/Profile/class.PublicProfileGUI.php index 74c95b8d1f62..d28d1aadccd8 100755 --- a/components/ILIAS/User/src/Profile/class.PublicProfileGUI.php +++ b/components/ILIAS/User/src/Profile/class.PublicProfileGUI.php @@ -24,6 +24,7 @@ use ILIAS\User\Context; use ILIAS\Badge\PublicUserProfileBadgesRenderer; use ILIAS\Language\Language; +use ILIAS\Data\Privacy\Purpose\Purposes; /** * GUI class for public user profile presentation. @@ -50,6 +51,7 @@ class PublicProfileGUI private \ilRbacSystem $rbac_system; private Language $lng; private PublicUserProfileBadgesRenderer $badges_renderer; + private Purposes $purposes; public function __construct(int $a_user_id = 0) { @@ -66,6 +68,7 @@ public function __construct(int $a_user_id = 0) $this->lng = $DIC['lng']; $this->profile = LocalDIC::dic()[Profile::class]; + $this->purposes = $DIC[Purposes::class]; $this->profile_request = new GUIRequest( $DIC->http(), @@ -367,16 +370,17 @@ public function getEmbeddable(bool $a_add_goto = false): string $this->getPublicPref($user, 'public_city') == 'y' || $this->getPublicPref($user, 'public_country') == 'y') { $address = []; + $postal_address = $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('public_profile')); $val_arr = [ - 'getStreet' => 'street', - 'getZipcode' => 'zipcode', - 'getCity' => 'city', - 'getCountry' => 'country' + 'street' => $postal_address?->street ?? '', + 'zipcode' => $postal_address?->zipcode ?? '', + 'city' => $postal_address?->city ?? '', + 'country' => $postal_address?->country ?? '' ]; - foreach ($val_arr as $key => $value) { + foreach ($val_arr as $value => $address_value) { // if value 'y' show information if ($this->getPublicPref($user, 'public_' . $value) == 'y') { - $address_value = $user->$key(); // only if set if (trim($address_value) != '') { @@ -612,6 +616,14 @@ public function deliverVCard(): void $org = []; $adr = []; + $postal_address = null; + if ($user->getPref('public_street') == 'y' + || $user->getPref('public_zipcode') == 'y' + || $user->getPref('public_city') == 'y' + || $user->getPref('public_country') == 'y') { + $postal_address = $user->getProfileData()->getPostalAddress() + ?->resolve($this->purposes->displayToUser('vcard')); + } foreach ($val_arr as $key => $value) { // if value 'y' show information if ($user->getPref('public_' . $value) == 'y') { @@ -623,16 +635,16 @@ public function deliverVCard(): void $org[1] = $user->$key(); break; case 'street': - $adr[2] = $user->$key(); + $adr[2] = $postal_address?->street ?? ''; break; case 'zipcode': - $adr[5] = $user->$key(); + $adr[5] = $postal_address?->zipcode ?? ''; break; case 'city': - $adr[3] = $user->$key(); + $adr[3] = $postal_address?->city ?? ''; break; case 'country': - $adr[6] = $user->$key(); + $adr[6] = $postal_address?->country ?? ''; break; case 'phone_office': $vcard->setPhone($user->$key(), VCard::TEL_TYPE_WORK); diff --git a/scripts/Privacy/generate-privacy-docs.php b/scripts/Privacy/generate-privacy-docs.php new file mode 100644 index 000000000000..4edb65a35e9f --- /dev/null +++ b/scripts/Privacy/generate-privacy-docs.php @@ -0,0 +1,85 @@ +#!/usr/bin/env php + %s 2>/dev/null', + escapeshellarg($root . '/vendor/composer/vendor/bin/phpstan'), + escapeshellarg($root . '/components/ILIAS/Data/PHPStan/Privacy/privacy-analysis.neon'), + escapeshellarg($root . '/vendor/composer/vendor/autoload.php'), + escapeshellarg($paths), + escapeshellarg($phpstan_output) + )); +} + +if (!file_exists($phpstan_output)) { + fwrite(STDERR, "PHPStan output not found at {$phpstan_output}.\nRun with --run-phpstan.\n"); + exit(1); +} + +$results = new PrivacyDocGenerator( + $phpstan_output, + $components_dir, + $dry_run, + $filter_component, + $target_filename +)->run(); + +echo str_repeat('-', 60) . "\n"; +foreach ($results as $component => $path) { + echo sprintf(" %s %-30s %s\n", $dry_run ? '[DRY RUN]' : '[written]', $component, $path); +} +echo str_repeat('-', 60) . "\n"; +echo sprintf(" %d component(s) processed.%s\n", count($results), $dry_run ? ' (dry run)' : '');