datasette-secrets 0.1a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of datasette-secrets might be problematic. Click here for more details.

@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.1
2
+ Name: datasette-secrets
3
+ Version: 0.1a0
4
+ Summary: Manage secrets such as API keys for use with other Datasette plugins
5
+ Author: Datasette
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/datasette/datasette-secrets
8
+ Project-URL: Changelog, https://github.com/datasette/datasette-secrets/releases
9
+ Project-URL: Issues, https://github.com/datasette/datasette-secrets/issues
10
+ Project-URL: CI, https://github.com/datasette/datasette-secrets/actions
11
+ Classifier: Framework :: Datasette
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: datasette>=1.0a13
17
+ Requires-Dist: cryptography
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == "test"
20
+ Requires-Dist: pytest-asyncio; extra == "test"
21
+
22
+ # datasette-secrets
23
+
24
+ [![PyPI](https://img.shields.io/pypi/v/datasette-secrets.svg)](https://pypi.org/project/datasette-secrets/)
25
+ [![Changelog](https://img.shields.io/github/v/release/datasette/datasette-secrets?include_prereleases&label=changelog)](https://github.com/datasette/datasette-secrets/releases)
26
+ [![Tests](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml/badge.svg)](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml)
27
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/datasette/datasette-secrets/blob/main/LICENSE)
28
+
29
+ Manage secrets such as API keys for use with other Datasette plugins
30
+
31
+ This plugin requires a **Datasette 1.0 alpha** release.
32
+
33
+ Datasette plugins sometimes need access to secrets, such as API keys used to integrate with tools hosted outside of Datasette - things like geocoders or hosted AI language models.
34
+
35
+ This plugin provides ways to configure those secrets:
36
+
37
+ - Secrets can be configured using environment variables, such as `DATASETTE_SECRETS_OPENAI_API_KEY`
38
+ - Secrets can be stored, encrypted, in a SQLite database table which administrator users can then update through the Datasette web interface
39
+
40
+ ## Installation
41
+
42
+ Install this plugin in the same environment as Datasette.
43
+ ```bash
44
+ datasette install datasette-secrets
45
+ ```
46
+ ## Configuration
47
+
48
+ First you will need to generate an encryption key for this plugin to use. Run this command:
49
+
50
+ ```bash
51
+ datasette secrets generate-encryption-key
52
+ ```
53
+ Store this secret somewhere secure. It will be used to both encrypt and decrypt secrets stored by this plugin - if you lose it you will not be able to recover your secrets.
54
+
55
+ Configure the plugin with these these two plugin settings:
56
+
57
+ ```yaml
58
+ plugins:
59
+ datasette-secrets:
60
+ encryption_key:
61
+ $env: DATASETTE_SECRETS_ENCRYPTION_KEY
62
+ database: name_of_database
63
+ ```
64
+ The `encryption_key` setting should be set to the encryption key you generated earlier. You can store it in an environment variable if you prefer.
65
+
66
+ `database` is the name of the database that the encrypted keys should be stored in. Omit this setting to use the internal database.
67
+
68
+ ### Using the internal database
69
+
70
+ While the secrets stored in the `datasette_secrets` table are encrypted, we still recommend hiding that table from view.
71
+
72
+ One way to do that is to keep the table in Datasette's internal database, which is invisible to all users, even users who are logged in.
73
+
74
+ By default, the internal database is an in-memory database that is reset when Datasette restarts. This is no good for persistent secret storage!
75
+
76
+ Instead, you should switch Datasette to using an on-disk internal database. You can do this by starting Datasette with the `--internal` option:
77
+ ```bash
78
+ datasette data.db --internal internal.db
79
+ ```
80
+ Your secrets will be stored in the `datasette_secrets` table in that database file.
81
+
82
+ ### Permissions
83
+
84
+ Only users with the `manage-secrets` permission will have access to manage secrets through the Datasette web interface.
85
+
86
+ You can grant that permission to the `root` user (or the user with an ID of your choice) by including this in your `datasette.yml` file:
87
+
88
+ ```yaml
89
+ permissions:
90
+ manage-secrets:
91
+ id: root
92
+ ```
93
+ Then start Datasette like this (with `--root` to get a URL to login as the root user):
94
+ ```bash
95
+ datasette data.db --internal internal.db -c datasette.yml --root
96
+ ```
97
+ Alternatively, use the `-s` option to set that setting without creating a configuration file:
98
+ ```bash
99
+ datasette data.db --internal internal.db \
100
+ -s permissions.manage-secrets.id root \
101
+ --root
102
+ ```
103
+
104
+ ## Usage
105
+
106
+ users with the `manage-secrets` permission will see a new "Manage secrets" link in the Datasette navigation menu. This interface can also be accessed at `/-/secrets`.
107
+
108
+ ## For plugin authors
109
+
110
+ Plugins can depend on this plugin if they want to implement secrets.
111
+
112
+ `datasette-secrets` to the `dependencies` list in `pyproject.toml`.
113
+
114
+ Then declare the name and description of any secrets you need using the `register_secrets()` plugin hook:
115
+
116
+ ```python
117
+ from datasette import hookimpl
118
+ from datasette_secrets import Secret
119
+
120
+ @hookimpl
121
+ def register_secrets():
122
+ return [
123
+ Secret(
124
+ "OPENAI_API_KEY",
125
+ 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
126
+ ),
127
+ ]
128
+ ```
129
+ The hook can take an optional `datasette` argument. It can return a list or an `async def` function that, when awaited, returns a list.
130
+
131
+ The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
132
+
133
+ To obtain the current value of the secret, use the `await get_secret()` method:
134
+
135
+ ```python
136
+ from datasette_secrets import get_secret
137
+
138
+ secret = await get_secret(datasette, "OPENAI_API_KEY")
139
+ ```
140
+ If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
141
+
142
+ Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
143
+
144
+
145
+ ## Development
146
+
147
+ To set up this plugin locally, first checkout the code. Then create a new virtual environment:
148
+ ```bash
149
+ cd datasette-secrets
150
+ python3 -m venv venv
151
+ source venv/bin/activate
152
+ ```
153
+ Now install the dependencies and test dependencies:
154
+ ```bash
155
+ pip install -e '.[test]'
156
+ ```
157
+ To run the tests:
158
+ ```bash
159
+ pytest
160
+ ```
@@ -0,0 +1,139 @@
1
+ # datasette-secrets
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/datasette-secrets.svg)](https://pypi.org/project/datasette-secrets/)
4
+ [![Changelog](https://img.shields.io/github/v/release/datasette/datasette-secrets?include_prereleases&label=changelog)](https://github.com/datasette/datasette-secrets/releases)
5
+ [![Tests](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml/badge.svg)](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml)
6
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/datasette/datasette-secrets/blob/main/LICENSE)
7
+
8
+ Manage secrets such as API keys for use with other Datasette plugins
9
+
10
+ This plugin requires a **Datasette 1.0 alpha** release.
11
+
12
+ Datasette plugins sometimes need access to secrets, such as API keys used to integrate with tools hosted outside of Datasette - things like geocoders or hosted AI language models.
13
+
14
+ This plugin provides ways to configure those secrets:
15
+
16
+ - Secrets can be configured using environment variables, such as `DATASETTE_SECRETS_OPENAI_API_KEY`
17
+ - Secrets can be stored, encrypted, in a SQLite database table which administrator users can then update through the Datasette web interface
18
+
19
+ ## Installation
20
+
21
+ Install this plugin in the same environment as Datasette.
22
+ ```bash
23
+ datasette install datasette-secrets
24
+ ```
25
+ ## Configuration
26
+
27
+ First you will need to generate an encryption key for this plugin to use. Run this command:
28
+
29
+ ```bash
30
+ datasette secrets generate-encryption-key
31
+ ```
32
+ Store this secret somewhere secure. It will be used to both encrypt and decrypt secrets stored by this plugin - if you lose it you will not be able to recover your secrets.
33
+
34
+ Configure the plugin with these these two plugin settings:
35
+
36
+ ```yaml
37
+ plugins:
38
+ datasette-secrets:
39
+ encryption_key:
40
+ $env: DATASETTE_SECRETS_ENCRYPTION_KEY
41
+ database: name_of_database
42
+ ```
43
+ The `encryption_key` setting should be set to the encryption key you generated earlier. You can store it in an environment variable if you prefer.
44
+
45
+ `database` is the name of the database that the encrypted keys should be stored in. Omit this setting to use the internal database.
46
+
47
+ ### Using the internal database
48
+
49
+ While the secrets stored in the `datasette_secrets` table are encrypted, we still recommend hiding that table from view.
50
+
51
+ One way to do that is to keep the table in Datasette's internal database, which is invisible to all users, even users who are logged in.
52
+
53
+ By default, the internal database is an in-memory database that is reset when Datasette restarts. This is no good for persistent secret storage!
54
+
55
+ Instead, you should switch Datasette to using an on-disk internal database. You can do this by starting Datasette with the `--internal` option:
56
+ ```bash
57
+ datasette data.db --internal internal.db
58
+ ```
59
+ Your secrets will be stored in the `datasette_secrets` table in that database file.
60
+
61
+ ### Permissions
62
+
63
+ Only users with the `manage-secrets` permission will have access to manage secrets through the Datasette web interface.
64
+
65
+ You can grant that permission to the `root` user (or the user with an ID of your choice) by including this in your `datasette.yml` file:
66
+
67
+ ```yaml
68
+ permissions:
69
+ manage-secrets:
70
+ id: root
71
+ ```
72
+ Then start Datasette like this (with `--root` to get a URL to login as the root user):
73
+ ```bash
74
+ datasette data.db --internal internal.db -c datasette.yml --root
75
+ ```
76
+ Alternatively, use the `-s` option to set that setting without creating a configuration file:
77
+ ```bash
78
+ datasette data.db --internal internal.db \
79
+ -s permissions.manage-secrets.id root \
80
+ --root
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ users with the `manage-secrets` permission will see a new "Manage secrets" link in the Datasette navigation menu. This interface can also be accessed at `/-/secrets`.
86
+
87
+ ## For plugin authors
88
+
89
+ Plugins can depend on this plugin if they want to implement secrets.
90
+
91
+ `datasette-secrets` to the `dependencies` list in `pyproject.toml`.
92
+
93
+ Then declare the name and description of any secrets you need using the `register_secrets()` plugin hook:
94
+
95
+ ```python
96
+ from datasette import hookimpl
97
+ from datasette_secrets import Secret
98
+
99
+ @hookimpl
100
+ def register_secrets():
101
+ return [
102
+ Secret(
103
+ "OPENAI_API_KEY",
104
+ 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
105
+ ),
106
+ ]
107
+ ```
108
+ The hook can take an optional `datasette` argument. It can return a list or an `async def` function that, when awaited, returns a list.
109
+
110
+ The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
111
+
112
+ To obtain the current value of the secret, use the `await get_secret()` method:
113
+
114
+ ```python
115
+ from datasette_secrets import get_secret
116
+
117
+ secret = await get_secret(datasette, "OPENAI_API_KEY")
118
+ ```
119
+ If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
120
+
121
+ Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
122
+
123
+
124
+ ## Development
125
+
126
+ To set up this plugin locally, first checkout the code. Then create a new virtual environment:
127
+ ```bash
128
+ cd datasette-secrets
129
+ python3 -m venv venv
130
+ source venv/bin/activate
131
+ ```
132
+ Now install the dependencies and test dependencies:
133
+ ```bash
134
+ pip install -e '.[test]'
135
+ ```
136
+ To run the tests:
137
+ ```bash
138
+ pytest
139
+ ```
@@ -0,0 +1,322 @@
1
+ import click
2
+ from cryptography.fernet import Fernet
3
+ import dataclasses
4
+ from datasette import hookimpl, Forbidden, Permission, Response
5
+ from datasette.plugins import pm
6
+ from datasette.utils import await_me_maybe
7
+ import os
8
+ from typing import Optional
9
+ from . import hookspecs
10
+
11
+ MAX_NOTE_LENGTH = 100
12
+
13
+ pm.add_hookspecs(hookspecs)
14
+
15
+
16
+ async def get_secret(datasette, secret_name):
17
+ secrets_by_name = {secret.name: secret for secret in await get_secrets(datasette)}
18
+ if secret_name not in secrets_by_name:
19
+ return None
20
+ # Is it an environment secret?
21
+ env_var = "DATASETTE_SECRETS_{}".format(secret_name)
22
+ if os.environ.get(env_var):
23
+ return os.environ[env_var]
24
+ # Now look it up in the database
25
+ config = get_config(datasette)
26
+ db = get_database(datasette)
27
+ encrypted = (
28
+ await db.execute(
29
+ "select encrypted from datasette_secrets where name = ? order by version desc limit 1",
30
+ (secret_name,),
31
+ )
32
+ ).first()
33
+ if not encrypted:
34
+ return None
35
+ key = Fernet(config["encryption_key"].encode("utf-8"))
36
+ decrypted = key.decrypt(encrypted["encrypted"])
37
+ return decrypted.decode("utf-8")
38
+
39
+
40
+ @dataclasses.dataclass
41
+ class Secret:
42
+ name: str
43
+ description_html: Optional[str] = None
44
+
45
+
46
+ SCHEMA = """
47
+ create table if not exists datasette_secrets (
48
+ id integer primary key,
49
+ name text not null,
50
+ note text,
51
+ version integer not null default 1,
52
+ encrypted blob,
53
+ encryption_key_name text not null,
54
+ redacted text,
55
+ created_at text,
56
+ created_by text,
57
+ updated_at text,
58
+ updated_by text,
59
+ deleted_at text,
60
+ deleted_by text,
61
+ last_used_at text,
62
+ last_used_by text
63
+ );
64
+ """
65
+
66
+
67
+ def get_database(datasette):
68
+ plugin_config = datasette.plugin_config("datasette-secrets") or {}
69
+ database = plugin_config.get("database") or "_internal"
70
+ if database == "_internal":
71
+ return datasette.get_internal_database()
72
+ return datasette.get_database(database)
73
+
74
+
75
+ def get_config(datasette):
76
+ plugin_config = datasette.plugin_config("datasette-secrets") or {}
77
+ encryption_key = plugin_config.get("encryption-key")
78
+ database = plugin_config.get("database") or "_internal"
79
+ if not encryption_key:
80
+ return None
81
+ return {
82
+ "database": database,
83
+ "encryption_key": encryption_key,
84
+ }
85
+
86
+
87
+ @hookimpl
88
+ def register_permissions(datasette):
89
+ return [
90
+ Permission(
91
+ name="manage-secrets",
92
+ abbr=None,
93
+ description="Manage Datasette secrets",
94
+ takes_database=False,
95
+ takes_resource=False,
96
+ default=False,
97
+ )
98
+ ]
99
+
100
+
101
+ async def get_secrets(datasette):
102
+ secrets = []
103
+ for result in pm.hook.register_secrets(datasette=datasette):
104
+ result = await await_me_maybe(result)
105
+ secrets.extend(result)
106
+ # if not secrets:
107
+ secrets.append(Secret("EXAMPLE_SECRET", "An example secret"))
108
+
109
+ return secrets
110
+
111
+
112
+ @hookimpl
113
+ def register_secrets():
114
+ return [
115
+ Secret(
116
+ "OPENAI_API_KEY",
117
+ 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
118
+ ),
119
+ ]
120
+
121
+
122
+ @hookimpl
123
+ def register_commands(cli):
124
+ @cli.group()
125
+ def secrets():
126
+ "Commands for managing datasette-secrets"
127
+
128
+ @secrets.command()
129
+ def generate_encryption_key():
130
+ "Generate a new encryption key for encrypting and decrypting secrets"
131
+ key = Fernet.generate_key()
132
+ click.echo(key.decode("utf-8"))
133
+
134
+
135
+ @hookimpl
136
+ def startup(datasette):
137
+ plugin_config = get_config(datasette)
138
+ if not plugin_config:
139
+ return
140
+ db = get_database(datasette)
141
+
142
+ async def create_table():
143
+ await db.execute_write(SCHEMA)
144
+
145
+ return create_table
146
+
147
+
148
+ async def secrets_index(datasette, request):
149
+ if not await datasette.permission_allowed(request.actor, "manage-secrets"):
150
+ raise Forbidden("Permission denied")
151
+ all_secrets = await get_secrets(datasette)
152
+
153
+ environment_secrets = []
154
+ for secret in all_secrets:
155
+ if os.environ.get("DATASETTE_SECRETS_{}".format(secret.name)):
156
+ environment_secrets.append(secret)
157
+ environment_secrets_names = {secret.name for secret in environment_secrets}
158
+
159
+ db = get_database(datasette)
160
+ existing_secrets_result = await db.execute(
161
+ """
162
+ select name, max(version) as version, updated_at, updated_by, note
163
+ from datasette_secrets
164
+ where name not in ({})
165
+ group by name
166
+ """.format(
167
+ ", ".join("?" for _ in environment_secrets_names)
168
+ ),
169
+ list(environment_secrets_names),
170
+ )
171
+ existing_secrets = {row["name"]: dict(row) for row in existing_secrets_result.rows}
172
+ unset_secrets = [
173
+ secret
174
+ for secret in all_secrets
175
+ if secret.name not in existing_secrets
176
+ and secret.name not in environment_secrets_names
177
+ ]
178
+ return Response.html(
179
+ await datasette.render_template(
180
+ "secrets_index.html",
181
+ {
182
+ "existing_secrets": existing_secrets.values(),
183
+ "unset_secrets": unset_secrets,
184
+ "environment_secrets": environment_secrets,
185
+ },
186
+ request=request,
187
+ )
188
+ )
189
+
190
+
191
+ async def secrets_update(datasette, request):
192
+ if not await datasette.permission_allowed(request.actor, "manage-secrets"):
193
+ raise Forbidden("Permission denied")
194
+ plugin_config = get_config(datasette)
195
+ if not plugin_config:
196
+ return Response.html("datasette-secrets has not been configured", status=400)
197
+
198
+ secret_name = request.url_vars["secret_name"]
199
+
200
+ # Try and find a secret matching this name
201
+ secret_details = None
202
+ secrets = await get_secrets(datasette)
203
+ for s in secrets:
204
+ if s.name == secret_name:
205
+ secret_details = s
206
+ break
207
+
208
+ db = get_database(datasette)
209
+
210
+ current_secret = (
211
+ await db.execute(
212
+ """
213
+ select * from datasette_secrets
214
+ where name = ?
215
+ order by version desc limit 1
216
+ """,
217
+ (secret_name,),
218
+ )
219
+ ).first()
220
+
221
+ if request.method == "POST":
222
+ data = await request.post_vars()
223
+ secret = (data.get("secret") or "").strip()
224
+ note = data.get("note") or ""
225
+ if len(note) > MAX_NOTE_LENGTH:
226
+ datasette.add_message(request, "Note is too long", datasette.ERROR)
227
+ return Response.redirect(request.path)
228
+
229
+ # Secret is required if adding
230
+ if not secret:
231
+ if current_secret:
232
+ # Update the note
233
+ await db.execute_write(
234
+ """
235
+ update datasette_secrets
236
+ set note = ?,
237
+ updated_at = datetime('now'),
238
+ updated_by = ?
239
+ where id = ?
240
+ """,
241
+ (note, request.actor.get("id"), current_secret["id"]),
242
+ )
243
+ if note and note != current_secret["note"]:
244
+ datasette.add_message(
245
+ request, "Note updated: {}".format(secret_name)
246
+ )
247
+ return Response.redirect(datasette.urls.path("/-/secrets"))
248
+ else:
249
+ datasette.add_message(request, "Secret is required", datasette.ERROR)
250
+ return Response.redirect(request.path)
251
+
252
+ encryption_key = plugin_config["encryption_key"]
253
+ key = Fernet(encryption_key.encode("utf-8"))
254
+ encrypted = key.encrypt(secret.encode("utf-8"))
255
+ encryption_key_name = "default"
256
+ actor_id = request.actor.get("id")
257
+ await db.execute_write(
258
+ """
259
+ insert into datasette_secrets (
260
+ name, version, note, encrypted, encryption_key_name,
261
+ created_at, created_by, updated_at, updated_by
262
+ ) values (
263
+ ?,
264
+ coalesce((select max(version) + 1 from datasette_secrets where name = ?), 1),
265
+ ?,
266
+ ?,
267
+ ?,
268
+ -- created_at, created_by
269
+ datetime('now'), ?,
270
+ -- updated_at, updated_by
271
+ datetime('now'), ?
272
+ )
273
+ """,
274
+ (
275
+ secret_name,
276
+ secret_name,
277
+ note,
278
+ encrypted,
279
+ encryption_key_name,
280
+ actor_id,
281
+ actor_id,
282
+ ),
283
+ )
284
+ datasette.add_message(request, "Secret {} updated".format(secret_name))
285
+ return Response.redirect(datasette.urls.path("/-/secrets"))
286
+
287
+ return Response.html(
288
+ await datasette.render_template(
289
+ "secrets_update.html",
290
+ {
291
+ "secret_name": secret_name,
292
+ "secret_details": secret_details,
293
+ "current_secret": current_secret,
294
+ "max_note_length": MAX_NOTE_LENGTH,
295
+ },
296
+ request=request,
297
+ )
298
+ )
299
+
300
+
301
+ @hookimpl
302
+ def register_routes():
303
+ return [
304
+ (r"^/-/secrets$", secrets_index),
305
+ (r"^/-/secrets/(?P<secret_name>[^/]+)$", secrets_update),
306
+ ]
307
+
308
+
309
+ @hookimpl
310
+ def menu_links(datasette, actor):
311
+ config = get_config(datasette)
312
+ if not config:
313
+ return
314
+
315
+ async def inner():
316
+ if not await datasette.permission_allowed(actor, "manage-secrets"):
317
+ return
318
+ return [
319
+ {"href": datasette.urls.path("/-/secrets"), "label": "Manage secrets"},
320
+ ]
321
+
322
+ return inner
@@ -0,0 +1,8 @@
1
+ from pluggy import HookspecMarker
2
+
3
+ hookspec = HookspecMarker("datasette")
4
+
5
+
6
+ @hookspec
7
+ def register_secrets(datasette):
8
+ "Return a list of Secret instances, or an awaitable function returning that list"
@@ -0,0 +1,43 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Manage secrets{% endblock %}
4
+
5
+ {% block content %}
6
+ <h1>Manage secrets</h1>
7
+
8
+ {% if existing_secrets %}
9
+ <table>
10
+ <tr><th>Secret</th><th>Note</th><th>Version</th><th>Last updated</th><th>Updated by</th></tr>
11
+ {% for secret in existing_secrets %}
12
+ <tr>
13
+ <td><strong><a href="{{ urls.path("/-/secrets/") }}{{ secret.name }}">{{ secret.name }}</a></strong></td>
14
+ <td>{{ secret.note }}</td>
15
+ <td>{{ secret.version }}</td>
16
+ <td>{{ secret.updated_at or "" }}</td>
17
+ <td>{{ secret.updated_by or "" }}</td>
18
+ </tr>
19
+ {% endfor %}
20
+ </table>
21
+ {% endif %}
22
+
23
+ {% if unset_secrets %}
24
+ <p style="margin-top: 2em">The following secret{% if unset_secrets|length == 1 %} has{% else %}s have{% endif %} not been set:</p>
25
+ <ul>
26
+ {% for secret in unset_secrets %}
27
+ <li><strong><a href="{{ urls.path("/-/secrets/") }}{{ secret.name }}">{{ secret.name }}</a></strong>
28
+ {% if secret.description_html %} - {{ secret.description_html|safe }}{% endif %}</li>
29
+ {% endfor %}
30
+ </ul>
31
+ {% endif %}
32
+
33
+ {% if environment_secrets %}
34
+ <p style="margin-top: 2em">The following secret{% if environment_secrets|length == 1 %} is{% else %}s are{% endif %} set using environment variables:</p>
35
+ <ul>
36
+ {% for secret in environment_secrets %}
37
+ <li><strong>{{ secret.name }}</a></strong>- {{ secret.description_html|safe }}<br>
38
+ <span style="font-size: 0.8 em">Set by <code>DATASETTE_SECRETS_{{ secret.name }}</code></span></li>
39
+ {% endfor %}
40
+ </ul>
41
+ {% endif %}
42
+
43
+ {% endblock %}
@@ -0,0 +1,43 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{% if current_secret %}Update{% else %}Add{% endif %} secret: {{ secret_name }}{% endblock %}
4
+
5
+ {% block crumbs %}
6
+ <p class="crumbs"><a href="{{ urls.path("/") }}">home</a> / <a href="{{ urls.path("/-/secrets") }}">secrets</a></p>
7
+ {% endblock %}
8
+
9
+ {% block content %}
10
+ <h1>{% if current_secret %}Update{% else %}Add{% endif %} secret: {{ secret_name }}</h1>
11
+
12
+ {% if secret_details and secret_details.description_html %}
13
+ <p>{{ secret_details.description_html|safe }}</p>
14
+ {% endif %}
15
+
16
+ {% if error %}
17
+ <p class="message-error">{{ error }}</p>
18
+ {% endif %}
19
+
20
+ <form action="{{ request.path }}" method="post">
21
+ <p>
22
+ <label for="secret">Secret:</label>
23
+ </p>
24
+ <p>
25
+ <textarea name="secret" style="width: 80%; height: 5em" id="secret"{% if not current_secret %} required{% endif %}
26
+ placeholder="{% if current_secret %}Leave this blank to leave the stored secret unchanged{% else %}Enter the secret here{% endif %}"
27
+ ></textarea>
28
+ </p>
29
+ <p>
30
+ <label for="note">Note (optional):</label>
31
+ </p>
32
+ <p>
33
+ <input type="text" name="note" id="note" maxlength="{{ max_note_length}}"
34
+ placeholder="{% if current_secret %}Add a note to the secret{% else %}Optional note{% endif %}"
35
+ value="{% if current_secret %}{{ current_secret.note }}{% endif %}"
36
+ </p>
37
+ <p>
38
+ <input type="submit" value="Add secret">
39
+ <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
40
+ </p>
41
+ </form>
42
+
43
+ {% endblock %}
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.1
2
+ Name: datasette-secrets
3
+ Version: 0.1a0
4
+ Summary: Manage secrets such as API keys for use with other Datasette plugins
5
+ Author: Datasette
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/datasette/datasette-secrets
8
+ Project-URL: Changelog, https://github.com/datasette/datasette-secrets/releases
9
+ Project-URL: Issues, https://github.com/datasette/datasette-secrets/issues
10
+ Project-URL: CI, https://github.com/datasette/datasette-secrets/actions
11
+ Classifier: Framework :: Datasette
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: datasette>=1.0a13
17
+ Requires-Dist: cryptography
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == "test"
20
+ Requires-Dist: pytest-asyncio; extra == "test"
21
+
22
+ # datasette-secrets
23
+
24
+ [![PyPI](https://img.shields.io/pypi/v/datasette-secrets.svg)](https://pypi.org/project/datasette-secrets/)
25
+ [![Changelog](https://img.shields.io/github/v/release/datasette/datasette-secrets?include_prereleases&label=changelog)](https://github.com/datasette/datasette-secrets/releases)
26
+ [![Tests](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml/badge.svg)](https://github.com/datasette/datasette-secrets/actions/workflows/test.yml)
27
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/datasette/datasette-secrets/blob/main/LICENSE)
28
+
29
+ Manage secrets such as API keys for use with other Datasette plugins
30
+
31
+ This plugin requires a **Datasette 1.0 alpha** release.
32
+
33
+ Datasette plugins sometimes need access to secrets, such as API keys used to integrate with tools hosted outside of Datasette - things like geocoders or hosted AI language models.
34
+
35
+ This plugin provides ways to configure those secrets:
36
+
37
+ - Secrets can be configured using environment variables, such as `DATASETTE_SECRETS_OPENAI_API_KEY`
38
+ - Secrets can be stored, encrypted, in a SQLite database table which administrator users can then update through the Datasette web interface
39
+
40
+ ## Installation
41
+
42
+ Install this plugin in the same environment as Datasette.
43
+ ```bash
44
+ datasette install datasette-secrets
45
+ ```
46
+ ## Configuration
47
+
48
+ First you will need to generate an encryption key for this plugin to use. Run this command:
49
+
50
+ ```bash
51
+ datasette secrets generate-encryption-key
52
+ ```
53
+ Store this secret somewhere secure. It will be used to both encrypt and decrypt secrets stored by this plugin - if you lose it you will not be able to recover your secrets.
54
+
55
+ Configure the plugin with these these two plugin settings:
56
+
57
+ ```yaml
58
+ plugins:
59
+ datasette-secrets:
60
+ encryption_key:
61
+ $env: DATASETTE_SECRETS_ENCRYPTION_KEY
62
+ database: name_of_database
63
+ ```
64
+ The `encryption_key` setting should be set to the encryption key you generated earlier. You can store it in an environment variable if you prefer.
65
+
66
+ `database` is the name of the database that the encrypted keys should be stored in. Omit this setting to use the internal database.
67
+
68
+ ### Using the internal database
69
+
70
+ While the secrets stored in the `datasette_secrets` table are encrypted, we still recommend hiding that table from view.
71
+
72
+ One way to do that is to keep the table in Datasette's internal database, which is invisible to all users, even users who are logged in.
73
+
74
+ By default, the internal database is an in-memory database that is reset when Datasette restarts. This is no good for persistent secret storage!
75
+
76
+ Instead, you should switch Datasette to using an on-disk internal database. You can do this by starting Datasette with the `--internal` option:
77
+ ```bash
78
+ datasette data.db --internal internal.db
79
+ ```
80
+ Your secrets will be stored in the `datasette_secrets` table in that database file.
81
+
82
+ ### Permissions
83
+
84
+ Only users with the `manage-secrets` permission will have access to manage secrets through the Datasette web interface.
85
+
86
+ You can grant that permission to the `root` user (or the user with an ID of your choice) by including this in your `datasette.yml` file:
87
+
88
+ ```yaml
89
+ permissions:
90
+ manage-secrets:
91
+ id: root
92
+ ```
93
+ Then start Datasette like this (with `--root` to get a URL to login as the root user):
94
+ ```bash
95
+ datasette data.db --internal internal.db -c datasette.yml --root
96
+ ```
97
+ Alternatively, use the `-s` option to set that setting without creating a configuration file:
98
+ ```bash
99
+ datasette data.db --internal internal.db \
100
+ -s permissions.manage-secrets.id root \
101
+ --root
102
+ ```
103
+
104
+ ## Usage
105
+
106
+ users with the `manage-secrets` permission will see a new "Manage secrets" link in the Datasette navigation menu. This interface can also be accessed at `/-/secrets`.
107
+
108
+ ## For plugin authors
109
+
110
+ Plugins can depend on this plugin if they want to implement secrets.
111
+
112
+ `datasette-secrets` to the `dependencies` list in `pyproject.toml`.
113
+
114
+ Then declare the name and description of any secrets you need using the `register_secrets()` plugin hook:
115
+
116
+ ```python
117
+ from datasette import hookimpl
118
+ from datasette_secrets import Secret
119
+
120
+ @hookimpl
121
+ def register_secrets():
122
+ return [
123
+ Secret(
124
+ "OPENAI_API_KEY",
125
+ 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
126
+ ),
127
+ ]
128
+ ```
129
+ The hook can take an optional `datasette` argument. It can return a list or an `async def` function that, when awaited, returns a list.
130
+
131
+ The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
132
+
133
+ To obtain the current value of the secret, use the `await get_secret()` method:
134
+
135
+ ```python
136
+ from datasette_secrets import get_secret
137
+
138
+ secret = await get_secret(datasette, "OPENAI_API_KEY")
139
+ ```
140
+ If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
141
+
142
+ Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
143
+
144
+
145
+ ## Development
146
+
147
+ To set up this plugin locally, first checkout the code. Then create a new virtual environment:
148
+ ```bash
149
+ cd datasette-secrets
150
+ python3 -m venv venv
151
+ source venv/bin/activate
152
+ ```
153
+ Now install the dependencies and test dependencies:
154
+ ```bash
155
+ pip install -e '.[test]'
156
+ ```
157
+ To run the tests:
158
+ ```bash
159
+ pytest
160
+ ```
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ datasette_secrets/__init__.py
5
+ datasette_secrets/hookspecs.py
6
+ datasette_secrets.egg-info/PKG-INFO
7
+ datasette_secrets.egg-info/SOURCES.txt
8
+ datasette_secrets.egg-info/dependency_links.txt
9
+ datasette_secrets.egg-info/entry_points.txt
10
+ datasette_secrets.egg-info/requires.txt
11
+ datasette_secrets.egg-info/top_level.txt
12
+ datasette_secrets/templates/secrets_index.html
13
+ datasette_secrets/templates/secrets_update.html
14
+ tests/test_secrets.py
@@ -0,0 +1,2 @@
1
+ [datasette]
2
+ secrets = datasette_secrets
@@ -0,0 +1,6 @@
1
+ datasette>=1.0a13
2
+ cryptography
3
+
4
+ [test]
5
+ pytest
6
+ pytest-asyncio
@@ -0,0 +1 @@
1
+ datasette_secrets
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "datasette-secrets"
3
+ version = "0.1a0"
4
+ description = "Manage secrets such as API keys for use with other Datasette plugins"
5
+ readme = "README.md"
6
+ authors = [{name = "Datasette"}]
7
+ license = {text = "Apache-2.0"}
8
+ classifiers=[
9
+ "Framework :: Datasette",
10
+ "License :: OSI Approved :: Apache Software License"
11
+ ]
12
+ requires-python = ">=3.8"
13
+ dependencies = [
14
+ "datasette>=1.0a13",
15
+ "cryptography"
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/datasette/datasette-secrets"
20
+ Changelog = "https://github.com/datasette/datasette-secrets/releases"
21
+ Issues = "https://github.com/datasette/datasette-secrets/issues"
22
+ CI = "https://github.com/datasette/datasette-secrets/actions"
23
+
24
+ [project.entry-points.datasette]
25
+ secrets = "datasette_secrets"
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest", "pytest-asyncio"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "strict"
32
+
33
+ [tool.setuptools.package-data]
34
+ datasette_secrets = ["templates/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,166 @@
1
+ from click.testing import CliRunner
2
+ from cryptography.fernet import Fernet
3
+ from datasette.app import Datasette
4
+ from datasette.cli import cli
5
+ from datasette_secrets import get_secret
6
+ import pytest
7
+ from unittest.mock import ANY
8
+
9
+ TEST_ENCRYPTION_KEY = "-LujHtwFWGaBpznrV1zduoZBmCnMOW7J0H5hmeXgAVo="
10
+
11
+
12
+ def test_generate_command():
13
+ runner = CliRunner()
14
+ result = runner.invoke(cli, ["secrets", "generate-encryption-key"])
15
+ assert result.exit_code == 0
16
+ key = result.output.strip()
17
+ key_bytes = key.encode("utf-8")
18
+ # This will throw an exception if key is invalid:
19
+ key = Fernet(key_bytes)
20
+ message = b"Secret message"
21
+ assert key.decrypt(key.encrypt(message)) == message
22
+
23
+
24
+ @pytest.fixture
25
+ def ds():
26
+ return Datasette(
27
+ config={
28
+ "plugins": {
29
+ "datasette-secrets": {
30
+ "database": "_internal",
31
+ "encryption-key": TEST_ENCRYPTION_KEY,
32
+ }
33
+ },
34
+ "permissions": {"manage-secrets": {"id": "admin"}},
35
+ }
36
+ )
37
+
38
+
39
+ @pytest.mark.asyncio
40
+ @pytest.mark.parametrize(
41
+ "path,verb,data",
42
+ (
43
+ ("/-/secrets", "GET", None),
44
+ ("/-/secrets/EXAMPLE_SECRET", "GET", None),
45
+ ),
46
+ )
47
+ @pytest.mark.parametrize("user", (None, "admin", "other"))
48
+ async def test_permissions(ds, path, verb, data, user):
49
+ method = ds.client.get if verb == "GET" else ds.client.post
50
+ kwargs = {}
51
+ if user:
52
+ kwargs["cookies"] = {
53
+ "ds_actor": ds.client.actor_cookie({"id": user}),
54
+ }
55
+ if data:
56
+ kwargs["data"] = data
57
+ response = await method(path, **kwargs)
58
+ if user == "admin":
59
+ assert response.status_code != 403
60
+ # And check they have the menu item too
61
+ assert '<a href="/-/secrets">Manage secrets</a>' in response.text
62
+ else:
63
+ assert response.status_code == 403
64
+ assert '<a href="/-/secrets">Manage secrets</a>' not in response.text
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_set_secret(ds):
69
+ cookies = {"ds_actor": ds.client.actor_cookie({"id": "admin"})}
70
+ get_response = await ds.client.get("/-/secrets/EXAMPLE_SECRET", cookies=cookies)
71
+ csrftoken = get_response.cookies["ds_csrftoken"]
72
+ cookies["ds_csrftoken"] = csrftoken
73
+ post_response = await ds.client.post(
74
+ "/-/secrets/EXAMPLE_SECRET",
75
+ cookies=cookies,
76
+ data={"secret": "new-secret-value", "note": "new-note", "csrftoken": csrftoken},
77
+ )
78
+ assert post_response.status_code == 302
79
+ assert post_response.headers["Location"] == "/-/secrets"
80
+ internal_db = ds.get_internal_database()
81
+ secrets = await internal_db.execute("select * from datasette_secrets")
82
+ rows = [dict(r) for r in secrets.rows]
83
+ assert rows == [
84
+ {
85
+ "id": 1,
86
+ "name": "EXAMPLE_SECRET",
87
+ "note": "new-note",
88
+ "version": 1,
89
+ "encrypted": ANY,
90
+ "encryption_key_name": "default",
91
+ "redacted": None,
92
+ "created_at": ANY,
93
+ "created_by": "admin",
94
+ "updated_at": ANY,
95
+ "updated_by": "admin",
96
+ "deleted_at": None,
97
+ "deleted_by": None,
98
+ "last_used_at": None,
99
+ "last_used_by": None,
100
+ }
101
+ ]
102
+ # Decrypt the secret
103
+ key = Fernet(TEST_ENCRYPTION_KEY.encode("utf-8"))
104
+ encrypted = rows[0]["encrypted"]
105
+ decrypted = key.decrypt(encrypted)
106
+ assert decrypted == b"new-secret-value"
107
+
108
+ # Now let's edit it
109
+ post_response2 = await ds.client.post(
110
+ "/-/secrets/EXAMPLE_SECRET",
111
+ cookies=cookies,
112
+ data={"secret": "updated-secret-value", "note": "", "csrftoken": csrftoken},
113
+ )
114
+ assert post_response2.status_code == 302
115
+ assert post_response2.headers["Location"] == "/-/secrets"
116
+ secrets2 = await internal_db.execute("select * from datasette_secrets")
117
+ rows2 = [dict(r) for r in secrets2.rows]
118
+ assert len(rows2) == 2
119
+ # Should be version 1 and version 2
120
+ versions = {row["version"] for row in rows2}
121
+ assert versions == {1, 2}
122
+ # Version 2 should be the latest
123
+ latest = [row for row in rows2 if row["version"] == 2][0]
124
+ assert latest == {
125
+ "id": 2,
126
+ "name": "EXAMPLE_SECRET",
127
+ "note": "",
128
+ "version": 2,
129
+ "encrypted": ANY,
130
+ "encryption_key_name": "default",
131
+ "redacted": None,
132
+ "created_at": ANY,
133
+ "created_by": "admin",
134
+ "updated_at": ANY,
135
+ "updated_by": "admin",
136
+ "deleted_at": None,
137
+ "deleted_by": None,
138
+ "last_used_at": None,
139
+ "last_used_by": None,
140
+ }
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_get_secret(ds, monkeypatch):
145
+ # First set it manually
146
+ cookies = {"ds_actor": ds.client.actor_cookie({"id": "admin"})}
147
+ get_response = await ds.client.get("/-/secrets/EXAMPLE_SECRET", cookies=cookies)
148
+ csrftoken = get_response.cookies["ds_csrftoken"]
149
+ cookies["ds_csrftoken"] = csrftoken
150
+ post_response = await ds.client.post(
151
+ "/-/secrets/EXAMPLE_SECRET",
152
+ cookies=cookies,
153
+ data={
154
+ "secret": "manually-set-secret",
155
+ "note": "new-note",
156
+ "csrftoken": csrftoken,
157
+ },
158
+ )
159
+ assert post_response.status_code == 302
160
+
161
+ assert await get_secret(ds, "EXAMPLE_SECRET") == "manually-set-secret"
162
+
163
+ # Now over-ride with an environment variable
164
+ monkeypatch.setenv("DATASETTE_SECRETS_EXAMPLE_SECRET", "from env")
165
+
166
+ assert await get_secret(ds, "EXAMPLE_SECRET") == "from env"