From d86175da39fcefdc6c4b0d9557db0949edc29876 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:02:53 +0200 Subject: [PATCH 01/11] Data: introduce privacy data types base infrastructure Personal data values are wrapped in privacy data types that carry their origin (Source) and release the raw value only through resolve() with an explicitly stated Purpose. Every resolve is reported to a PrivacyLogger, forming the GDPR audit trail. - PrivacyDataType interface + abstract base, first concrete type PostalAddress (compound over usr_data street/city/zipcode/country) - Sources: DbTableColumn(s), UserInput, ExternalApi, SessionData, LegacySource; KnownSources catalogue with named usr_data getters - Purposes: StoreInTable, DisplayToUser, PassToComponent, TechnicalProcessing, LegacyAccess (marks unmigrated call sites) - PrivacyLogger as contribute/seek collection with CompositeLogger; no persistent backend yet (storage/retention decision pending) - Services defined/implemented in the Data component bootstrap; types are created via an injectable Factory that binds the audit logger (no static named constructors) --- components/ILIAS/Data/Data.php | 26 ++ components/ILIAS/Data/README.md | 17 + .../src/Privacy/AbstractPrivacyDataType.php | 78 +++++ components/ILIAS/Data/src/Privacy/Factory.php | 47 +++ .../src/Privacy/Logger/CompositeLogger.php | 53 +++ .../Data/src/Privacy/Logger/PrivacyLogger.php | 40 +++ .../Data/src/Privacy/PrivacyDataType.php | 56 ++++ .../src/Privacy/Purpose/DisplayToUser.php | 45 +++ .../Data/src/Privacy/Purpose/LegacyAccess.php | 51 +++ .../src/Privacy/Purpose/PassToComponent.php | 55 ++++ .../Data/src/Privacy/Purpose/Purpose.php | 34 ++ .../Data/src/Privacy/Purpose/Purposes.php | 70 ++++ .../Data/src/Privacy/Purpose/StoreInTable.php | 45 +++ .../Privacy/Purpose/TechnicalProcessing.php | 46 +++ components/ILIAS/Data/src/Privacy/README.md | 305 ++++++++++++++++++ .../ILIAS/Data/src/Privacy/Services.php | 50 +++ .../ILIAS/Data/src/Privacy/ServicesImpl.php | 57 ++++ .../Data/src/Privacy/Source/DbTableColumn.php | 52 +++ .../src/Privacy/Source/DbTableColumns.php | 61 ++++ .../Data/src/Privacy/Source/DbTarget.php | 31 ++ .../Data/src/Privacy/Source/ExternalApi.php | 53 +++ .../src/Privacy/Source/Known/KnownSources.php | 38 +++ .../src/Privacy/Source/Known/UserSources.php | 170 ++++++++++ .../Data/src/Privacy/Source/LegacySource.php | 51 +++ .../Data/src/Privacy/Source/SessionData.php | 42 +++ .../ILIAS/Data/src/Privacy/Source/Source.php | 32 ++ .../ILIAS/Data/src/Privacy/Source/Sources.php | 68 ++++ .../Data/src/Privacy/Source/UserInput.php | 45 +++ .../Data/src/Privacy/Types/PostalAddress.php | 88 +++++ .../src/Privacy/Types/PostalAddressValue.php | 38 +++ .../Privacy/AbstractPrivacyDataTypeTest.php | 118 +++++++ .../ILIAS/Data/tests/Privacy/FactoryTest.php | 76 +++++ .../Fixtures/ConcretePrivacyDataType.php | 32 ++ .../Fixtures/InMemoryPrivacyLogger.php | 102 ++++++ .../Fixtures/PrivacyDataTypeAssertions.php | 64 ++++ .../Privacy/Fixtures/UnitTestPurpose.php | 42 +++ .../tests/Privacy/Fixtures/UnitTestSource.php | 42 +++ .../Privacy/Logger/CompositeLoggerTest.php | 76 +++++ .../tests/Privacy/Purpose/PurposesTest.php | 99 ++++++ .../tests/Privacy/Source/KnownSourcesTest.php | 85 +++++ .../Data/tests/Privacy/Source/SourcesTest.php | 102 ++++++ .../tests/Privacy/Types/PostalAddressTest.php | 118 +++++++ 42 files changed, 2800 insertions(+) create mode 100644 components/ILIAS/Data/src/Privacy/AbstractPrivacyDataType.php create mode 100644 components/ILIAS/Data/src/Privacy/Factory.php create mode 100644 components/ILIAS/Data/src/Privacy/Logger/CompositeLogger.php create mode 100644 components/ILIAS/Data/src/Privacy/Logger/PrivacyLogger.php create mode 100644 components/ILIAS/Data/src/Privacy/PrivacyDataType.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/DisplayToUser.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/LegacyAccess.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/PassToComponent.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/Purpose.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/Purposes.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/StoreInTable.php create mode 100644 components/ILIAS/Data/src/Privacy/Purpose/TechnicalProcessing.php create mode 100644 components/ILIAS/Data/src/Privacy/README.md create mode 100644 components/ILIAS/Data/src/Privacy/Services.php create mode 100644 components/ILIAS/Data/src/Privacy/ServicesImpl.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/DbTableColumn.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/DbTableColumns.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/DbTarget.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/ExternalApi.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/Known/KnownSources.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/Known/UserSources.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/LegacySource.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/SessionData.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/Source.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/Sources.php create mode 100644 components/ILIAS/Data/src/Privacy/Source/UserInput.php create mode 100644 components/ILIAS/Data/src/Privacy/Types/PostalAddress.php create mode 100644 components/ILIAS/Data/src/Privacy/Types/PostalAddressValue.php create mode 100644 components/ILIAS/Data/tests/Privacy/AbstractPrivacyDataTypeTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/FactoryTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/Fixtures/ConcretePrivacyDataType.php create mode 100644 components/ILIAS/Data/tests/Privacy/Fixtures/InMemoryPrivacyLogger.php create mode 100644 components/ILIAS/Data/tests/Privacy/Fixtures/PrivacyDataTypeAssertions.php create mode 100644 components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestPurpose.php create mode 100644 components/ILIAS/Data/tests/Privacy/Fixtures/UnitTestSource.php create mode 100644 components/ILIAS/Data/tests/Privacy/Logger/CompositeLoggerTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/Purpose/PurposesTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/Source/KnownSourcesTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/Source/SourcesTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/Types/PostalAddressTest.php 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/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/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); + } +} From 08a2d423b585b5b559721015e50684d078165e80 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:05:28 +0200 Subject: [PATCH 02/11] Init: expose privacy services in legacy container Legacy code can access the privacy data type services via $DIC[\ILIAS\Data\Privacy\Services::class] until all consumers are migrated to the component bootstrap. --- components/ILIAS/Init/Init.php | 3 +++ components/ILIAS/Init/src/AllModernComponents.php | 6 ++++++ 2 files changed, 9 insertions(+) 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 From 6ce5c094001773e2962d44fe0320120ab6b7066b Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:15:44 +0200 Subject: [PATCH 03/11] Data: PHPStan extension and privacy doc generator - Rules: NoRawValueAccessRule (no PrivacyDataType into var_dump/ json_encode/serialize etc.), StoreInTableTargetRule (StoreInTable requires a DbTarget), PreferKnownSourcesRule (no DbTableColumn(s) literals outside the KnownSources catalogue; escape hatch @privacy-undocumented) - Return type extension: PrivacyDataType::resolve() is typed as T - PrivacyResolveCollector + report rule: every resolve() call site is collected with purpose and component and emitted into PHPStan's JSON output; KnownSources getters are evaluated so reports show the actual table/columns - Generator renders per-component privacy documentation (stored / displayed / passed / technical / unmigrated legacy access + GDPR summary) to PRIVACY_DATA.md; --overwrite-privacy-md switches to PRIVACY.md once the community decides to replace the handwritten files - CLI: scripts/Privacy/generate-privacy-docs.php [--run-phpstan] [--component=X] [--dry-run] --- .../Collector/PrivacyResolveCollector.php | 202 ++++++++++++++++++ .../Collector/PrivacyResolveReportRule.php | 64 ++++++ .../Generator/CollectorResultParser.php | 70 ++++++ .../Privacy/Generator/ComponentReport.php | 58 +++++ .../Privacy/Generator/EntryCategory.php | 30 +++ .../Privacy/Generator/MarkdownRenderer.php | 159 ++++++++++++++ .../Privacy/Generator/PrivacyDocGenerator.php | 71 ++++++ .../Privacy/Generator/PurposeClassifier.php | 59 +++++ .../Privacy/Generator/ResolveEntry.php | 37 ++++ .../Privacy/Rules/NoRawValueAccessRule.php | 79 +++++++ .../Privacy/Rules/PreferKnownSourcesRule.php | 126 +++++++++++ .../Privacy/Rules/StoreInTableTargetRule.php | 83 +++++++ .../PHPStan/Privacy/privacy-analysis.neon | 22 ++ .../ILIAS/Data/PHPStan/Privacy/privacy.neon | 24 +++ .../Collector/Fixtures/resolve-calls.php | 64 ++++++ .../Collector/PrivacyResolveCollectorTest.php | 68 ++++++ .../Generator/CollectorResultParserTest.php | 129 +++++++++++ .../PHPStan/Generator/ComponentReportTest.php | 62 ++++++ .../Generator/MarkdownRendererTest.php | 127 +++++++++++ .../Generator/PrivacyDocGeneratorTest.php | 118 ++++++++++ .../Generator/PurposeClassifierTest.php | 75 +++++++ .../Rules/Fixtures/no-raw-value-access.php | 38 ++++ .../Rules/Fixtures/prefer-known-sources.php | 42 ++++ .../Rules/Fixtures/store-in-table-target.php | 39 ++++ .../Rules/NoRawValueAccessRuleTest.php | 52 +++++ .../Rules/PreferKnownSourcesRuleTest.php | 63 ++++++ .../Rules/StoreInTableTargetRuleTest.php | 50 +++++ .../PHPStan/Type/Fixtures/return-types.php | 40 ++++ .../Type/ResolveReturnTypeInferenceTest.php | 43 ++++ scripts/Privacy/generate-privacy-docs.php | 85 ++++++++ 30 files changed, 2179 insertions(+) create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveCollector.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Collector/PrivacyResolveReportRule.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/CollectorResultParser.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/ComponentReport.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/EntryCategory.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/MarkdownRenderer.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/PrivacyDocGenerator.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/PurposeClassifier.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Generator/ResolveEntry.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Rules/NoRawValueAccessRule.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Rules/PreferKnownSourcesRule.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/Rules/StoreInTableTargetRule.php create mode 100644 components/ILIAS/Data/PHPStan/Privacy/privacy-analysis.neon create mode 100644 components/ILIAS/Data/PHPStan/Privacy/privacy.neon create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Collector/Fixtures/resolve-calls.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Collector/PrivacyResolveCollectorTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Generator/CollectorResultParserTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Generator/ComponentReportTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Generator/MarkdownRendererTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PrivacyDocGeneratorTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Generator/PurposeClassifierTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/no-raw-value-access.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/prefer-known-sources.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/Fixtures/store-in-table-target.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/NoRawValueAccessRuleTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/PreferKnownSourcesRuleTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Rules/StoreInTableTargetRuleTest.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Type/Fixtures/return-types.php create mode 100644 components/ILIAS/Data/tests/Privacy/PHPStan/Type/ResolveReturnTypeInferenceTest.php create mode 100644 scripts/Privacy/generate-privacy-docs.php 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/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/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)' : ''); From 56a058c412461c43d43b0e6ddb9f7e517edeb891 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:39:15 +0200 Subject: [PATCH 04/11] User: use PostalAddress privacy type for residential address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile\Data now holds the residential address as a single PostalAddress privacy value instead of four bare strings. The repository wraps the value on read (source usr_data street/city/ zipcode/country) and resolves it on store with a StoreInTable purpose, so every persistence of the address lands in the audit trail. Migrated consumers state their real purpose: - profile form fields (Street/City/ZipCode/Country/Location) resolve with DisplayToUser and write back with a UserInput source - PublicProfileGUI resolves with DisplayToUser for the profile page and the vCard export - the profile mail body resolves with PassToComponent(Mail) - the XML import parser writes with an ExternalApi(xml_import) source The string-based accessors on ilObjUser and Profile\Data remain as deprecated delegates that resolve with a LegacyAccess purpose (reads) or a LegacySource (writes) — nothing breaks, and every unmigrated access shows up in the audit trail and the generated report. Raw SQL reads of the address columns (user lists, search) bypass the wrapper and are left for later migration steps. --- .../ILIAS/User/classes/class.ilObjUser.php | 62 +++++++-- .../User/classes/class.ilUserImportParser.php | 123 +++++++++++++++--- components/ILIAS/User/src/LocalDIC.php | 25 +++- components/ILIAS/User/src/Profile/Data.php | 94 ++++++++++--- .../src/Profile/DatabaseDataRepository.php | 36 +++-- .../User/src/Profile/Fields/Standard/City.php | 22 +++- .../src/Profile/Fields/Standard/Country.php | 22 +++- .../src/Profile/Fields/Standard/Location.php | 14 +- .../src/Profile/Fields/Standard/Street.php | 22 +++- .../src/Profile/Fields/Standard/ZipCode.php | 22 +++- .../src/Profile/class.PublicProfileGUI.php | 32 +++-- 11 files changed, 383 insertions(+), 91 deletions(-) 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); From 3aa4211952ef5a7eca90574be89776b611f45a51 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:39:47 +0200 Subject: [PATCH 05/11] Maps: resolve user address with DisplayToUser purpose The user info overlays of the map GUIs resolve the postal address privacy type with an explicit DisplayToUser purpose instead of the deprecated string accessors. --- .../Maps/classes/class.ilGoogleMapGUI.php | 19 +++++++++++++++---- .../Maps/classes/class.ilOpenLayersMapGUI.php | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) 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", From 63c8eff4a6fb3ea70ed7168bf85795f1274baf48 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:41:16 +0200 Subject: [PATCH 06/11] Certificate: resolve address placeholders with purpose The USER_STREET/CITY/ZIPCODE/COUNTRY placeholder values resolve the postal address privacy type with a PassToComponent(Certificate) purpose instead of the deprecated string accessors. --- .../class.ilDefaultPlaceholderValues.php | 16 +++++++---- .../tests/ilDefaultPlaceholderValuesTest.php | 27 +++++-------------- 2 files changed, 18 insertions(+), 25 deletions(-) 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( From 14921828b8faee0fd8b129946a106707bc19cf99 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:47:16 +0200 Subject: [PATCH 07/11] MyStaff: use PostalAddress privacy type in staff list ilMStListUser holds the address as a PostalAddress privacy value; the list fetcher wraps the raw usr_data columns with the proper source. Table rendering and Excel/CSV export resolve with DisplayToUser purposes; the string accessors remain as deprecated legacy delegates. --- .../classes/ListUsers/class.ilMStListUser.php | 75 ++++++++++++++++--- .../ListUsers/class.ilMStListUsers.php | 17 ++++- .../class.ilMStListUsersTableGUI.php | 30 ++++++++ 3 files changed, 106 insertions(+), 16 deletions(-) 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; From aa934e50c149d23c34164323014f414f6312f7c6 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 20:49:37 +0200 Subject: [PATCH 08/11] LegalDocuments: resolve country condition with purpose The user country criterion resolves the postal address privacy type with a TechnicalProcessing purpose instead of the deprecated string accessor. --- .../Definitions/UserCountryDefinition.php | 10 ++++--- .../classes/Condition/UserCountry.php | 9 +++++-- .../classes/DefaultMappings.php | 2 +- .../Definitions/UserCountryDefinitionTest.php | 12 +++++---- .../tests/Condition/UserCountryTest.php | 27 ++++++++++++++----- .../tests/DefaultMappingsTest.php | 5 +++- 6 files changed, 46 insertions(+), 19 deletions(-) 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(); From c5c679805c56afb4d7fed737f92dee72b3a40859 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Tue, 21 Jul 2026 21:04:27 +0200 Subject: [PATCH 09/11] AuthShibboleth: write address with ExternalApi source shibUser writes street/zipcode/country received from the identity provider into the postal address privacy type with an ExternalApi(shibboleth) source instead of the deprecated setters. --- .../classes/User/class.shibUser.php | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) 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), + } + ) + ); + } } From 0cd06a7f741cf8f007dc3b5552868354b0029d7d Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Wed, 22 Jul 2026 08:11:01 +0200 Subject: [PATCH 10/11] Add generated PRIVACY_DATA.md reports Output of scripts/Privacy/generate-privacy-docs.php for the components migrated to the postal address privacy type (User, Maps, Certificate, MyStaff, LegalDocuments). --- components/ILIAS/Certificate/PRIVACY_DATA.md | 31 ++++++++ .../ILIAS/LegalDocuments/PRIVACY_DATA.md | 30 ++++++++ components/ILIAS/Maps/PRIVACY_DATA.md | 31 ++++++++ components/ILIAS/MyStaff/PRIVACY_DATA.md | 39 +++++++++++ components/ILIAS/User/PRIVACY_DATA.md | 70 +++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 components/ILIAS/Certificate/PRIVACY_DATA.md create mode 100644 components/ILIAS/LegalDocuments/PRIVACY_DATA.md create mode 100644 components/ILIAS/Maps/PRIVACY_DATA.md create mode 100644 components/ILIAS/MyStaff/PRIVACY_DATA.md create mode 100644 components/ILIAS/User/PRIVACY_DATA.md 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/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/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/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/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_ | From 39fabc6ea1af559b4ed9ac804cc108e092840d65 Mon Sep 17 00:00:00 2001 From: Fabian Schmid Date: Fri, 24 Jul 2026 07:33:33 +0200 Subject: [PATCH 11/11] LegalDocuments: fix consumer tests for second container lookup DefaultMappings::conditionDefinitions() now pulls Purposes from the container in addition to ilObjDataCache. The TermsOfService and DataProtection consumer tests still expected a single offsetGet call. --- .../ILIAS/DataProtection/tests/ConsumerTest.php | 16 +++++++++++++--- .../ILIAS/TermsOfService/tests/ConsumerTest.php | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) 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/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);