# Smart Tablet Screen — Multi-Account Config & Secrets Research Research date: 2026-09-01. Scope: how to configure the project's multiple account "sources" (Microsoft/Exchange OAuth2, N Mail-in-a-Box CalDAV accounts, a Nextcloud CalDAV account, an RSS feed list, a Diun webhook secret) via YAML, and how to keep the secret values out of plaintext. All claims sourced against primary/official docs; each claim links its own source. This report does not redo the research in `smart-tablet-screen-stack.md`, `smart-tablet-screen-additions.md`, or `smart-tablet-screen-nextcloud-todos.md`, and does not modify them. --- ## 1. Modeling "N accounts, each with a type and connection details" in Symfony config ### 1.1 The Config component's `TreeBuilder` + `useAttributeAsKey()` + prototypes Symfony's own Config component docs describe defining a config schema as a `Configuration` class returning a `TreeBuilder`: ```php class DatabaseConfiguration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('database'); return $treeBuilder; } } ``` — [Defining and Processing Configuration Values — Symfony Config component, symfony.com/doc](https://symfony.com/doc/current/components/config/definition.html) The relevant piece for "a list of named entries, each with a shared shape" is the combination of an array node's **prototype** (the shape repeated for every entry) with **`useAttributeAsKey()`** (which preserves the YAML map key — e.g. the account name — as an array key rather than discarding it, which is the array node's default behavior for list-like input): ```php $node ->children() ->arrayNode('connections', 'connection') ->useAttributeAsKey('name') // Preserves keys ->arrayPrototype() ->children() ->scalarNode('table')->end() ->scalarNode('user')->end() ->scalarNode('password')->end() ->end() ->end() ->end() ->end() ; ``` Given ```yaml connections: sf_connection: table: symfony user: root default: table: foo user: root ``` this produces a PHP array keyed by `sf_connection` and `default`, each holding its own validated sub-tree — [Config component — Defining Configuration Trees, symfony.com/doc/current/components/config/definition.html](https://symfony.com/doc/current/components/config/definition.html) This is exactly the shape needed for "N Mail-in-a-Box accounts, each keyed by a name, each with its own `url`/`user`/`password` fields." ### 1.2 How a bundle exposes that schema as YAML Symfony's bundle configuration docs confirm the two supported ways to wire a `Configuration` schema up to real `config/packages/*.yaml` input: - **`AbstractBundle`** (current recommended style): define the tree directly in the bundle class via `configure(DefinitionConfigurator $definition)`, and read the merged/validated `$config` array in `loadExtension()`. - **Traditional Extension + separate `Configuration` class**: a `src/DependencyInjection/Configuration.php` implementing `ConfigurationInterface`, whose `getConfigTreeBuilder()` is passed into `processConfiguration()` inside the bundle's `Extension::load()` — this is what actually validates and merges the YAML. Either way, the **root key is derived from the bundle/extension name** and the user-facing config lives in `config/packages/.yaml`, e.g.: ```yaml # config/packages/acme_social.yaml acme_social: twitter: client_id: 123 client_secret: your_secret ``` — [How to Create Friendly Configuration for a Bundle, symfony.com/doc/current/bundles/configuration.html](https://symfony.com/doc/current/bundles/configuration.html) The `config:dump-reference` console command (documented on the same page) dumps the effective schema for a given config root — useful for verifying a `smart_screen.yaml` schema once written, without hand-checking every node. **Applied to this project**: a `SmartScreenBundle` (or its `Extension`+`Configuration` pair) defining a `smart_screen` root, with sub-trees for `accounts.exchange`, `accounts.caldav` (list, `useAttributeAsKey`), `accounts.nextcloud`, `rss.feeds` (list), and `diun.webhook`, is the idiomatic Symfony way to validate this project's `accounts.yaml`-shaped config rather than reading raw YAML by hand. ### 1.3 Real first-party precedent for "N named entries, each with a type/DSN discriminator" Two of Symfony's own subsystems use exactly this "list of named, typed connections" pattern in their reference YAML, confirming it's an established, idiomatic pattern rather than something bespoke to invent for this project: **Doctrine DBAL — multiple named connections:** ```yaml doctrine: dbal: default_connection: default connections: default: dbname: Symfony2 user: root password: null host: localhost customer: dbname: customer user: root password: null host: localhost ``` Each connection is also exposed as its own service, `doctrine.dbal.[name]_connection`. — [DoctrineBundle Configuration Reference, symfony.com/bundles/DoctrineBundle/current/configuration.html](https://symfony.com/bundles/DoctrineBundle/current/configuration.html) **Messenger — multiple named transports, each keyed by name with its own `dsn`/`options`:** ```yaml # config/packages/messenger.yaml framework: messenger: transports: async_priority_high: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: queue_name: high async_priority_low: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: queue_name: low routing: 'App\Message\SmsNotification': async_priority_low 'App\Message\NewUserWelcomeEmail': async_priority_high ``` A transport can also be written as a bare DSN string (`async: "%env(MESSENGER_TRANSPORT_DSN)%"`) when no extra options are needed, which is the shorthand form of the same prototype. — [Messenger: Sync & Queued Message Handling, symfony.com/doc/current/messenger.html](https://symfony.com/doc/current/messenger.html) **Relevance to this project**: Messenger's transport DSN string (`amqp://...`, `doctrine://...`, `sync://`) is effectively a "type" encoded in the URL scheme, dispatched by Messenger's transport factory chain to the right transport implementation. The same idea maps directly onto this project's account list: each entry can carry an explicit `type: exchange|caldav_miab|caldav_nextcloud` discriminator (clearer for four genuinely different shapes than trying to smuggle type into a URL scheme), with the Symfony `Configuration` tree validating that each type's required fields are present — while `%env(...)%` placeholders (see §2) fill in the actual secret values, exactly as Messenger does with `%env(MESSENGER_TRANSPORT_DSN)%`. ### 1.4 On the "known-good YAML for multiple external accounts with a type field" comparison target The task asked to check a real OSS project (e.g. mbin/friendica) configuring multiple external accounts via YAML with a discriminated type field, as a second data point beyond Symfony's own subsystems. Symfony's own Messenger transports config (§1.3) already **is** that pattern, first-party, documented, and directly reusable by this project without adopting an unrelated project's schema conventions — so no separate third-party project was adopted as a model; Messenger's transports and Doctrine's connections together cover the "list of named, typed entries" precedent this project needs. --- ## 2. Encrypting the secrets ### 2.1 Confirm: no encryption in plain YAML Symfony's Config/bundle-configuration docs contain no mechanism for encrypting values inside a YAML file — YAML itself is a plain serialization format with no such feature, and the fields Symfony's `Configuration` tree validates are read as literal strings unless the value is an `%env(...)%` placeholder (§2.3). The framework's own answer to "how do I keep a config value secret" is the dedicated secrets vault, not YAML: Symfony's configuration docs state plainly: > "Instead of defining a real environment variable or adding it to a `.env` file, if the value of a variable is sensitive (e.g. an API key or a database password), you can encrypt the value using the [secrets management system]." — [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html) ### 2.2 Symfony's native secrets vault Per Symfony's own secrets docs — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html): - The vault requires the **Sodium PHP extension**. Keys are generated per environment: `php bin/console secrets:generate-keys` produces `config/secrets//.encrypt.public.php` (safe to commit — used only to encrypt/add secrets) and `config/secrets//.decrypt.private.php` (must **not** be committed for `prod` — used to decrypt). - **Algorithm: libsodium asymmetric sealed boxes** — public-key encryption where the public key encrypts and only the private key decrypts, giving confidentiality and integrity without needing the decryption key present during container compilation/cache warmup. - Secrets are set via `php bin/console secrets:set SECRET_NAME` (interactively, from a file, from STDIN, or `--random`), listed via `secrets:list` (`--reveal` to show plaintext), and removed via `secrets:remove`. - A secret is referenced in YAML exactly like a normal env var, via the `%env(...)%` syntax: ```yaml # config/packages/doctrine.yaml doctrine: dbal: password: '%env(DATABASE_PASSWORD)%' ``` - Production deployment options: copy `prod.decrypt.private.php` to the server, or set `SYMFONY_DECRYPTION_SECRET` (base64 of the private key) as a real env var, or run `secrets:decrypt-to-local` during deploy so the private key never needs to live on the server long-term. ### 2.3 `.env` vs. the vault — and whether they coexist Symfony's configuration docs lay out the `.env` file hierarchy and its intended scope explicitly: > "This file should be committed to your repository and (due to that fact) should only contain 'default' values that are good for local development. This file should not contain production values." with `.env` (defaults), `.env.local` (uncommitted, machine-specific), `.env.` (committed, per-environment), and `.env..local` (uncommitted, per-environment-and-machine) — [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html). Immediately after that, the same page hands sensitive values off to the secrets system quoted in §2.1. **They coexist by design, not by accident**: the secrets vault docs show the vault has an explicit "local override" escape hatch — `php bin/console secrets:set SECRET_NAME --local` writes the value straight into `.env..local` as plaintext, and that local `.env` value takes precedence over the vault's encrypted value. This is the documented mechanism for "give a developer a working non-production secret locally without touching the committed vault." — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html) **Conclusion for this project** (matching the plan already assumed in `smart-tablet-screen-stack.md`, which put the Graph client id/secret/refresh token in `.env`): keep genuinely non-sensitive, per-environment configuration (feature flags, base URLs that aren't secret, `APP_ENV`) in `.env`/`.env.local` as before, but move the four actual secret values this project has — Graph `client_secret` and refresh token, each Mail-in-a-Box account's app-password, the Nextcloud account's app-password, and the Diun webhook shared token — into the secrets vault, referenced from YAML via `%env(...)%`. This is a refinement of, not a contradiction to, the earlier `.env`-based plan: it only reclassifies which values live in which of the two documented buckets. ### 2.4 Can the vault hold structured/multiple values, or is the answer a YAML+vault hybrid? The vault's own interface (`secrets:set NAME`, `secrets:list`, `%env(NAME)%`) is a **flat, name→string store** — each secret is one named string value, decrypted individually into one env var. There is no first-party "store a nested object/array as one secret" primitive in the commands themselves. However, Symfony's **env var processors** dock directly onto both plain env vars and vault-backed secrets (since a resolved secret becomes an ordinary env var to the container at runtime), and two of them make structured secret values workable: - **`env(json:FOO)`** — decodes a JSON-encoded env var into an array: ```yaml parameters: env(ALLOWED_LANGUAGES): '["en","de","es"]' app_allowed_languages: '%env(json:ALLOWED_LANGUAGES)%' ``` - **`env(resolve:FOO)`** — interpolates `%parameter_name%` container parameters *inside* an env var's value before it's used, useful for composing a value (e.g. a DSN) from one secret plus other non-secret parameters: ```yaml parameters: sentry_host: '10.0.0.1' env(SENTRY_DSN): 'http://%sentry_host%/project' sentry: dsn: '%env(resolve:SENTRY_DSN)%' ``` - Processors chain (e.g. `%env(json:file:resolve:AUTH_FILE)%` — resolve a path, read the file, JSON-decode it), showing the mechanism is designed to compose. — [Environment Variable Processors, symfony.com/doc/current/configuration/env_var_processors.html](https://symfony.com/doc/current/configuration/env_var_processors.html) **Practical answer for this project**: don't try to cram the whole `accounts.yaml` account list into one vault secret (JSON-blob-in-a-vault-entry). The clean split, directly matching what Messenger/Doctrine already do (§1.3), is a **hybrid**: - Keep the *structure* — the list of accounts, each account's `type`, non-secret connection fields (server URLs, usernames, feed URLs, account display names) — in plain committed YAML (`config/packages/smart_screen.yaml`), validated by a `Configuration` tree (§1). - Put only the *actual secret strings* (app-passwords, client secret, refresh token, webhook token) into individually-named vault secrets, and reference each one from the YAML via a `%env(SECRET_NAME)%` placeholder in the relevant field — exactly the pattern Doctrine's own reference config uses for `password: '%env(DATABASE_PASSWORD)%'` (§2.2) and Messenger uses for `dsn: '%env(MESSENGER_TRANSPORT_DSN)%'` (§1.3). - For N Mail-in-a-Box accounts each needing their own app-password, this means N vault secrets (e.g. `MIAB_HOME_APP_PASSWORD`, `MIAB_WORK_APP_PASSWORD`), one per account entry, each referenced by name from that account's YAML block — no processor trickery needed since each field is just one string. --- ## 3. SOPS as a comparison point Per SOPS's own README: > "SOPS is an editor of encrypted files that supports YAML, JSON, ENV, INI and BINARY formats." SOPS encrypts values **inline, in place, inside the committed file itself** (rather than moving them to a separate vault/store), supporting age and PGP as well as cloud KMS providers (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault). Using it requires: the `sops` binary itself, and key management — either a cloud KMS the deploy environment has IAM access to, or a locally-managed age/PGP keypair whose private half must reach every machine/CI job that needs to decrypt. — [getsops/sops README, github.com/getsops/sops](https://github.com/getsops/sops) **Trade-off vs. Symfony's native vault**: SOPS is format-agnostic and works outside any one framework, but it is an **external dependency** — a separate binary to install everywhere the file is decrypted (dev machines, CI, prod), plus a key (age/PGP/KMS) to provision and rotate outside of Symfony's own tooling, and no native `%env(...)%`-style resolution — some glue (`sops -d` piped into `.env` or an env-loading step) is needed to get decrypted values into the app at all. Symfony's vault, by contrast, needs **zero extra dependencies beyond the already-required PHP `sodium` extension**, ships its own CLI (`secrets:set`/`secrets:list`/`secrets:generate-keys`), and resolves straight into `%env(...)%` — the same mechanism this project already uses for ordinary env vars — with no extra decode step. For a single-team, self-hosted Symfony project (this one), the native vault is the lower-friction choice; SOPS would only earn its keep if the project already had multi-repo/multi-language secret sharing or an existing cloud-KMS setup to plug into, neither of which applies here. --- ## Recommended approach Use Symfony's **native secrets vault + `.env`, referenced from a `Configuration`-validated `config/packages/smart_screen.yaml`** — no third-party secret-encryption tool, no bespoke YAML-parsing code. 1. Define a `Configuration` tree (per §1.1–§1.2) under a `smart_screen` root with a discriminated, `useAttributeAsKey()`-keyed `accounts` list (per §1.3's Messenger/Doctrine precedent) plus a plain `rss.feeds` list and a `diun` block. 2. Put every actual secret (OAuth client secret + refresh token, each CalDAV account's app-password, the Diun webhook token) into the vault via `bin/console secrets:set `, and reference each one from the YAML with `%env()%` — never as a literal string in the file. 3. Non-secret connection details (server URLs, usernames, feed URLs, account display names, the `type` discriminator) stay as plain YAML values, committed normally. Illustrative shape only — not real project code: ```yaml # config/packages/smart_screen.yaml smart_screen: accounts: work_outlook: type: exchange tenant: common client_id: '11111111-2222-3333-4444-555555555555' client_secret: '%env(EXCHANGE_WORK_CLIENT_SECRET)%' refresh_token: '%env(EXCHANGE_WORK_REFRESH_TOKEN)%' miab_home: type: caldav_miab url: 'https://mail.example.com/remote.php/dav/calendars/alice/' user: 'alice' app_password: '%env(MIAB_HOME_APP_PASSWORD)%' miab_work: type: caldav_miab url: 'https://mail2.example.com/remote.php/dav/calendars/bob/' user: 'bob' app_password: '%env(MIAB_WORK_APP_PASSWORD)%' nextcloud_tasks: type: caldav_nextcloud url: 'https://cloud.example.com/remote.php/dav/calendars/alice/' user: 'alice' app_password: '%env(NEXTCLOUD_APP_PASSWORD)%' rss: feeds: - 'https://example.com/feed.xml' - 'https://another-example.com/rss' diun: webhook_token: '%env(DIUN_WEBHOOK_TOKEN)%' ``` Why: this is Symfony's own idiomatic pattern end to end — `useAttributeAsKey()`-backed prototypes for the account list mirror Doctrine's `dbal.connections` and Messenger's `messenger.transports` reference config exactly (§1.3), the vault needs no dependency beyond the `sodium` extension PHP already requires, and `%env(SECRET_NAME)%` is the same placeholder mechanism this project's `.env`-based plan (per `smart-tablet-screen-stack.md`) already relies on for ordinary env vars — so adopting the vault only reclassifies which values are secret, it doesn't introduce a second configuration system.