-
Notifications
You must be signed in to change notification settings - Fork 53
feat: support storing, retrieving and refreshing encrypted secrets at account or asset level #2236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
f25e718
feat: support storing, retrieving and refreshing encrypted secrets at…
nhoening e664222
add changelog entry and missing documentation section
nhoening 6779b5f
move plugin author docs on secrets to their own page, add my section …
nhoening f65b8c0
only use one key config setting, add CLI command to set secrets, impr…
nhoening 0e37f47
more poignant documentation
nhoening b559555
show which secrets are stored (no values) in UI
nhoening c509ac8
add CLI command to delete secrets
nhoening 4625f83
Merge branch 'main' into feat/secrets-column
nhoening 76e14b1
merge db migrations
nhoening 6ebfa60
small robustness improvement in migration
nhoening 0e7bf00
also show expiration time in UI tables
nhoening 1c2d4a2
Update documentation/cli/change_log.rst
nhoening 1cb6b1b
Update documentation/cli/commands.rst
nhoening d6ea4b7
Update documentation/cli/commands.rst
nhoening ed0922c
Update documentation/plugin/storing-platform-secrets.rst
nhoening 068865b
Update documentation/plugin/storing-platform-secrets.rst
nhoening 3bfefec
Update documentation/plugin/storing-platform-secrets.rst
nhoening a4f4be7
Update documentation/plugin/storing-platform-secrets.rst
nhoening 4167dab
Update flexmeasures/cli/data_delete.py
nhoening 0d7cb74
Update flexmeasures/cli/data_edit.py
nhoening cf2dd85
Apply suggestions from code review
nhoening 7ca065a
Update documentation/plugin/storing-platform-secrets.rst
nhoening 2927bfa
add an example how to add/edit metadata, example: expires_at
nhoening 8f799d5
more robust CLI tests
nhoening 19676ed
some refactoring (fewer methods, docstrings) and use the word keyring
nhoening 0c92f3e
add short example of stored secrets
nhoening 20c4bb9
more robust handling of the layers in secrets: only allow up to two l…
nhoening 7da8cb8
improve CLI to supprt dots in secret paths, raise if expires_at is no…
nhoening ed8d876
clarify where encryption is being done
nhoening File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| .. _connection_secrets_dev: | ||
|
|
||
| Connection secrets | ||
| ================== | ||
|
|
||
| If you make a secure connection to an external platform, FlexMeasures can store credentials, | ||
| API keys and tokens in the ``secrets`` JSON field of the account or asset that owns the connection. | ||
| Each secret value is encrypted separately and stored together with metadata such as its encryption | ||
| key ID and timestamps. Developers normally do not need to read or modify this | ||
| JSON structure directly. | ||
|
|
||
| For implementation examples, token lifecycle strategies and manually seeding a | ||
| credential through the CLI, see :ref:`storing_connection_secrets`. | ||
|
|
||
| The encrypted values are protected by | ||
| ``FLEXMEASURES_SECRETS_ENCRYPTION_KEYS``, see :ref:`flexmeasures_secrets_encryption_keys` | ||
| This setting accepts arbitrary non-empty strings, which FlexMeasures derives into Fernet-compatible keys. | ||
| Hosts must configure this keyring before secrets can be stored | ||
| - FlexMeasures will print a warning if it is not set and hints how to initialize it. | ||
|
|
||
|
|
||
| Recommended practices | ||
| --------------------- | ||
|
|
||
| Write-only API and UI | ||
| ^^^^^^^^^^^^^^^^^^^^^ | ||
|
|
||
| Treat secrets as write-only in API and UI flows. Accept new or replacement | ||
| values, but never return plaintext secrets. Responses should contain only | ||
| redacted information such as whether a value is set and when it expires. | ||
|
|
||
| For administrator-level maintenance, use ``flexmeasures edit secret`` to store | ||
| or replace one account or asset secret and ``flexmeasures delete secret`` to | ||
| remove one. Use ``--secret`` for dot-separated paths or ``--secret-path`` once | ||
| or twice. Prefer the edit command's ``--prompt`` option so secret values do not | ||
| enter shell history. | ||
|
|
||
|
|
||
| Use the secret utilities | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^ | ||
|
|
||
| FlexMeasures provides a complete utility API in :mod:`flexmeasures.utils.secrets_utils`. | ||
|
|
||
| Use :func:`flexmeasures.utils.secrets_utils.set_secret` for individual | ||
| credentials. For token refresh flows, prefer | ||
| :class:`flexmeasures.utils.secrets_utils.TokenRefreshResult` together with | ||
| :func:`flexmeasures.utils.secrets_utils.apply_token_refresh_result`. They safely | ||
| handle replacement tokens, rotated refresh tokens and providers that only | ||
| extend the expiry of an existing token. | ||
|
|
||
| Use :func:`flexmeasures.utils.secrets_utils.get_secret_overview` to build safe | ||
| secret listings with their paths and optional expiration times, without | ||
| exposing encrypted values or unrelated metadata. | ||
| When ``expires_at`` is stored without a timezone offset, FlexMeasures treats it | ||
| as UTC when rendering the overview. | ||
|
|
||
| Store secrets at one or two path levels only: a top-level namespace such as a | ||
| provider name, optionally followed by a secret name within that namespace. | ||
| String paths therefore look like ``provider`` or ``provider.refresh_token``. | ||
| If a secret name itself contains a dot, pass the path as a tuple or list of | ||
| parts, or use ``--secret-path`` in the CLI. | ||
|
|
||
|
|
||
| Refresh early in multi-worker deployments | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||
|
|
||
| Store reusable access tokens and their expiry metadata in ``secrets`` so all | ||
| workers share the same token state. Refresh with time to spare before expiry to | ||
| allow for clock differences, retries and concurrent workers. In high-traffic | ||
| integrations, use database locking so only one worker performs a refresh. See | ||
| the refresh-leeway example in :ref:`initiating_connection_tokens`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| .. _storing_connection_secrets: | ||
|
|
||
| Storing connection secrets | ||
| ========================== | ||
|
|
||
| Plugins that connect FlexMeasures accounts or assets to external platforms often | ||
| need to store credentials, refresh tokens, access tokens or connection-specific | ||
| passwords. Store such values in the ``secrets`` JSON field of the relevant | ||
| account or asset, rather than in ``attributes`` or plugin configuration files. | ||
|
|
||
| Secrets live in a dictionary and support one or two path levels. For example: | ||
|
|
||
| .. code-block:: json | ||
|
|
||
| { | ||
| "3rdparty-platform": { | ||
| "refresh_token": "encrypted-value", | ||
| "access_token": "encrypted-value" | ||
| } | ||
| } | ||
|
|
||
| (where "encrypted-value" is not the actual string you pass in, FlexMeasures handles the encryption seamlessly) | ||
|
|
||
| The ``secrets`` field is intended to be write-only from API and UI flows: users | ||
| can provide or replace secret values, but normal responses should return only | ||
| redacted metadata such as whether a value is set and when it expires. Trusted | ||
| server-side plugin code can decrypt and use the value when it performs work for | ||
| the account or asset. | ||
|
|
||
| Use ``flexmeasures.utils.secrets_utils`` for secret handling: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| from flexmeasures.utils.secrets_utils import ( | ||
| SecretsEncryptor, | ||
| get_secret, | ||
| redact_secrets, | ||
| set_secret, | ||
| ) | ||
|
|
||
| encryptor = SecretsEncryptor.from_current_app() | ||
|
|
||
| my_account.secrets = set_secret( | ||
| my_account.secrets, | ||
| "3rdparty-platform.refresh_token", | ||
| refresh_token, | ||
| encryptor=encryptor, | ||
| metadata={"expires_at": refresh_token_expires_at.isoformat()}, | ||
|
nhoening marked this conversation as resolved.
|
||
| ) | ||
|
|
||
| refresh_token = get_secret( | ||
| my_account.secrets, | ||
| "3rdparty-platform.refresh_token", | ||
| encryptor=encryptor, | ||
| ) | ||
|
|
||
| response_payload = redact_secrets(my_account.secrets) | ||
|
|
||
| The metadata string ``expires_at`` (see example above) is useful to add, as it will be used by the token handling and also shown in the UI. | ||
| FlexMeasures will also handle ``created-at`` and ``updated-at`` metadata automatically. | ||
|
|
||
| More details and best practices for storing connection secrets are in the :ref:`connection_secrets_dev` section. | ||
|
|
||
|
|
||
| Token lifecycle strategies | ||
| ----------------------------- | ||
|
|
||
| External platforms do not all require the same interactions to maintain a connection. | ||
| Initial login, token refresh and token expiry can all work rather differently. | ||
| You could say that they implement different "token lifecycle strategies". | ||
|
|
||
| That's why our advice is to keep the provider-specific HTTP calls in your plugin, | ||
| and use FlexMeasures utilities only for encryption, redaction and updating stored token state. | ||
|
|
||
| The following token lifecycle strategies are supported by | ||
| ``TokenRefreshResult`` and ``apply_token_refresh_result``: | ||
|
|
||
| * A refresh operation returns a new access token: set | ||
| ``TokenRefreshResult.access_token`` and ``access_token_expires_at``. | ||
| * A refresh operation rotates the refresh token: set | ||
| ``TokenRefreshResult.refresh_token`` and ``refresh_token_expires_at``. | ||
| * A refresh operation extends an existing access token without returning a new token: | ||
| leave ``access_token`` as ``None`` and set ``access_token_expires_at``. | ||
| * Access tokens are minted separately from refresh-token rotation: call | ||
| ``apply_token_refresh_result`` once for the refreshed long-lived credential | ||
| and again when a short-lived access token is minted. | ||
|
|
||
| Your task is to translate the HTTP response from the platform provider into a | ||
| ``TokenRefreshResult`` and let FlexMeasures update the encrypted JSON state. | ||
| Let's look at an example: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| from flexmeasures.utils.secrets_utils import ( | ||
| SecretsEncryptor, | ||
| TokenRefreshResult, | ||
| apply_token_refresh_result, | ||
| get_secret, | ||
| ) | ||
|
|
||
| encryptor = SecretsEncryptor.from_current_app() | ||
|
|
||
| refresh_token = get_secret( | ||
| my_account.secrets, | ||
| "3rdparty-platform.refresh_token", | ||
| encryptor=encryptor, | ||
| ) | ||
|
|
||
| # this function would be written by you | ||
| token_response = refresh_with_external_platform(refresh_token) | ||
|
|
||
| # here you translate the response - consult the platform docs | ||
| my_account.secrets = apply_token_refresh_result( | ||
| my_account.secrets, | ||
| "3rdparty-platform", | ||
| TokenRefreshResult( | ||
| access_token=token_response.get("access_token"), | ||
| refresh_token=token_response.get("refresh_token"), | ||
| access_token_expires_at=token_response.get("access_token_expires_at"), | ||
| refresh_token_expires_at=token_response.get("refresh_token_expires_at"), | ||
| token_type=token_response.get("token_type"), | ||
| metadata={"scope": token_response.get("scope")}, | ||
| ), | ||
| encryptor=encryptor, | ||
| ) | ||
|
|
||
| This varies by platform - for instance, if a platform only extends the lifetime | ||
| of the existing access token (instead of returning a new one), keep | ||
| ``access_token`` set to ``None`` and provide the new ``access_token_expires_at``. | ||
| The existing encrypted token is kept, and only its metadata is updated. | ||
|
|
||
| Your plugin should also decide when to request a refreshed access token, e.g.: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| 3RDPARTY_PLATFORM_TOKEN_LEEWAY = timedelta(seconds=120) | ||
| # you might get this info with `secret_utils.get_secret()` | ||
| if current_access_token_expires_at > now + 3RDPARTY_PLATFORM_TOKEN_LEEWAY: | ||
| return access_token | ||
| response = send_request_to_3rdparty_platform() | ||
| access_token = response.text | ||
| refresh_account.secrets = apply_token_refresh_result(...) | ||
|
|
||
|
|
||
|
|
||
| .. _initiating_connection_tokens: | ||
|
|
||
| Initiating tokens (before app startup) | ||
| ----------------------------------------- | ||
|
|
||
| Most providers will require the true credentials only in the first interaction: | ||
| For example: username & password to get the access & refresh token. | ||
| From then on, the refresh token helps to get by (as long as it does not expire). | ||
|
|
||
| In your plugin, you can write a CLI command to perform this login, get your first | ||
| refresh token and save it as a secret (see `set_secret()` and `apply_token_refresh_result()` | ||
| in utils/secrets_utils.py). | ||
| The token lifecycle strategy (see above) will depend on the platform you connect to. | ||
|
|
||
| Also advisable: if no current token is in the database, let your plugin code fail | ||
| explicitly and advise the user to call your login CLI command. | ||
|
|
||
|
|
||
| Alternatively, you can manually store a known credential: Use ``flexmeasures edit secret`` with | ||
| an account or asset ID, plus either ``--secret`` or one or two ``--secret-path`` | ||
| options, and either ``--value`` or ``--prompt`` (to paste the secret instead of typing it). | ||
| Use ``--metadata`` for non-secret JSON metadata such as expiry | ||
| timestamps. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.