Files
Smart-Tablet-Screen/docs/research/smart-tablet-screen-config-secrets.md
haylanandClaude-Bot a6fd372123 docs(research): add YAML account config and secrets vault research
Cover Symfony Config component typed-list patterns (Doctrine/Messenger
precedents) and the native secrets vault vs. plaintext YAML/SOPS for
encrypting account credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:54:07 +02:00

20 KiB

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:

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

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):

$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

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

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/<extension_name>.yaml, e.g.:

# 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

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:

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

Messenger — multiple named transports, each keyed by name with its own dsn/options:

# 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

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

2.2 Symfony's native secrets vault

Per Symfony's own secrets docs — Configuring Symfony — Secrets, 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/<env>/<env>.encrypt.public.php (safe to commit — used only to encrypt/add secrets) and config/secrets/<env>/<env>.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:
    # 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.<environment> (committed, per-environment), and .env.<environment>.local (uncommitted, per-environment-and-machine) — Configuring Symfony, 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.<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

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:
    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:
    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

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

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.


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 <NAME>, and reference each one from the YAML with %env(<NAME>)% — 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:

# 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.