datasette-secrets 0.1a0__tar.gz → 0.1a2__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: datasette-secrets
3
- Version: 0.1a0
3
+ Version: 0.1a2
4
4
  Summary: Manage secrets such as API keys for use with other Datasette plugins
5
5
  Author: Datasette
6
6
  License: Apache-2.0
@@ -105,6 +105,8 @@ datasette data.db --internal internal.db \
105
105
 
106
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
107
 
108
+ The page with the list of secrets will show the user who last updated each secret. This will use the [actors_from_ids()](https://docs.datasette.io/en/latest/plugin_hooks.html#actors-from-ids-datasette-actor-ids) mechanism, displaying the actor's `username` if available, otherwise the `name`, otherwise the `id`.
109
+
108
110
  ## For plugin authors
109
111
 
110
112
  Plugins can depend on this plugin if they want to implement secrets.
@@ -121,11 +123,24 @@ from datasette_secrets import Secret
121
123
  def register_secrets():
122
124
  return [
123
125
  Secret(
124
- "OPENAI_API_KEY",
125
- 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
126
+ name="OPENAI_API_KEY",
127
+ description="An OpenAI API key"
128
+ ),
129
+ ]
130
+ ```
131
+ You can also provide optional `obtain_url` and `obtain_label` fields to link to a page where a user can obtain an API key:
132
+ ```python
133
+ @hookimpl
134
+ def register_secrets():
135
+ return [
136
+ Secret(
137
+ name="OPENAI_API_KEY",
138
+ obtain_url="https://platform.openai.com/api-keys",
139
+ obtain_label="Get an OpenAI API key"
126
140
  ),
127
141
  ]
128
142
  ```
143
+
129
144
  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
145
 
131
146
  The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
@@ -135,12 +150,14 @@ To obtain the current value of the secret, use the `await get_secret()` method:
135
150
  ```python
136
151
  from datasette_secrets import get_secret
137
152
 
138
- secret = await get_secret(datasette, "OPENAI_API_KEY")
153
+ # Third argument is the actor_id, optional
154
+ secret = await get_secret(datasette, "OPENAI_API_KEY", "root")
139
155
  ```
140
156
  If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
141
157
 
142
158
  Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
143
159
 
160
+ The `last_used_at` column is updated every time a secret is accessed. The `last_used_by` column will be set to the actor ID passed to `get_secret()`, or `null` if no actor ID was passed.
144
161
 
145
162
  ## Development
146
163
 
@@ -84,6 +84,8 @@ datasette data.db --internal internal.db \
84
84
 
85
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
86
 
87
+ The page with the list of secrets will show the user who last updated each secret. This will use the [actors_from_ids()](https://docs.datasette.io/en/latest/plugin_hooks.html#actors-from-ids-datasette-actor-ids) mechanism, displaying the actor's `username` if available, otherwise the `name`, otherwise the `id`.
88
+
87
89
  ## For plugin authors
88
90
 
89
91
  Plugins can depend on this plugin if they want to implement secrets.
@@ -100,11 +102,24 @@ from datasette_secrets import Secret
100
102
  def register_secrets():
101
103
  return [
102
104
  Secret(
103
- "OPENAI_API_KEY",
104
- 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
105
+ name="OPENAI_API_KEY",
106
+ description="An OpenAI API key"
107
+ ),
108
+ ]
109
+ ```
110
+ You can also provide optional `obtain_url` and `obtain_label` fields to link to a page where a user can obtain an API key:
111
+ ```python
112
+ @hookimpl
113
+ def register_secrets():
114
+ return [
115
+ Secret(
116
+ name="OPENAI_API_KEY",
117
+ obtain_url="https://platform.openai.com/api-keys",
118
+ obtain_label="Get an OpenAI API key"
105
119
  ),
106
120
  ]
107
121
  ```
122
+
108
123
  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
124
 
110
125
  The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
@@ -114,12 +129,14 @@ To obtain the current value of the secret, use the `await get_secret()` method:
114
129
  ```python
115
130
  from datasette_secrets import get_secret
116
131
 
117
- secret = await get_secret(datasette, "OPENAI_API_KEY")
132
+ # Third argument is the actor_id, optional
133
+ secret = await get_secret(datasette, "OPENAI_API_KEY", "root")
118
134
  ```
119
135
  If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
120
136
 
121
137
  Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
122
138
 
139
+ The `last_used_at` column is updated every time a secret is accessed. The `last_used_by` column will be set to the actor ID passed to `get_secret()`, or `null` if no actor ID was passed.
123
140
 
124
141
  ## Development
125
142
 
@@ -13,7 +13,7 @@ MAX_NOTE_LENGTH = 100
13
13
  pm.add_hookspecs(hookspecs)
14
14
 
15
15
 
16
- async def get_secret(datasette, secret_name):
16
+ async def get_secret(datasette, secret_name, actor_id=None):
17
17
  secrets_by_name = {secret.name: secret for secret in await get_secrets(datasette)}
18
18
  if secret_name not in secrets_by_name:
19
19
  return None
@@ -24,23 +24,40 @@ async def get_secret(datasette, secret_name):
24
24
  # Now look it up in the database
25
25
  config = get_config(datasette)
26
26
  db = get_database(datasette)
27
- encrypted = (
27
+ db_secret = (
28
28
  await db.execute(
29
- "select encrypted from datasette_secrets where name = ? order by version desc limit 1",
29
+ "select id, encrypted from datasette_secrets where name = ? order by version desc limit 1",
30
30
  (secret_name,),
31
31
  )
32
32
  ).first()
33
- if not encrypted:
33
+ if not db_secret:
34
34
  return None
35
35
  key = Fernet(config["encryption_key"].encode("utf-8"))
36
- decrypted = key.decrypt(encrypted["encrypted"])
36
+ decrypted = key.decrypt(db_secret["encrypted"])
37
+ # Update the last used timestamp and actor_id
38
+ params = (actor_id, db_secret["id"])
39
+ if not actor_id:
40
+ params = (db_secret["id"],)
41
+ await db.execute_write(
42
+ """
43
+ update datasette_secrets
44
+ set last_used_at = datetime('now'),
45
+ last_used_by = {}
46
+ where id = ?
47
+ """.format(
48
+ "?" if actor_id else "null"
49
+ ),
50
+ params,
51
+ )
37
52
  return decrypted.decode("utf-8")
38
53
 
39
54
 
40
55
  @dataclasses.dataclass
41
56
  class Secret:
42
57
  name: str
43
- description_html: Optional[str] = None
58
+ description: Optional[str] = None
59
+ obtain_url: Optional[str] = None
60
+ obtain_label: Optional[str] = None
44
61
 
45
62
 
46
63
  SCHEMA = """
@@ -51,7 +68,6 @@ create table if not exists datasette_secrets (
51
68
  version integer not null default 1,
52
69
  encrypted blob,
53
70
  encryption_key_name text not null,
54
- redacted text,
55
71
  created_at text,
56
72
  created_by text,
57
73
  updated_at text,
@@ -100,25 +116,20 @@ def register_permissions(datasette):
100
116
 
101
117
  async def get_secrets(datasette):
102
118
  secrets = []
119
+ seen = set()
103
120
  for result in pm.hook.register_secrets(datasette=datasette):
104
121
  result = await await_me_maybe(result)
105
- secrets.extend(result)
122
+ for secret in result:
123
+ if secret.name in seen:
124
+ continue # Skip duplicates
125
+ seen.add(secret.name)
126
+ secrets.append(secret)
106
127
  # if not secrets:
107
128
  secrets.append(Secret("EXAMPLE_SECRET", "An example secret"))
108
129
 
109
130
  return secrets
110
131
 
111
132
 
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
133
  @hookimpl
123
134
  def register_commands(cli):
124
135
  @cli.group()
@@ -169,6 +180,16 @@ async def secrets_index(datasette, request):
169
180
  list(environment_secrets_names),
170
181
  )
171
182
  existing_secrets = {row["name"]: dict(row) for row in existing_secrets_result.rows}
183
+ # Try to turn updated_by into actors
184
+ actors = await datasette.actors_from_ids(
185
+ {row["updated_by"] for row in existing_secrets.values() if row["updated_by"]}
186
+ )
187
+ for secret in existing_secrets.values():
188
+ if secret["updated_by"]:
189
+ actor = actors.get(secret["updated_by"])
190
+ if actor:
191
+ display = actor.get("username") or actor.get("name") or actor.get("id")
192
+ secret["updated_by"] = display
172
193
  unset_secrets = [
173
194
  secret
174
195
  for secret in all_secrets
@@ -25,7 +25,10 @@
25
25
  <ul>
26
26
  {% for secret in unset_secrets %}
27
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>
28
+ {% if secret.description or secret.obtain_label %}
29
+ - {{ secret.description or "" }}{% if secret.description and secret.obtain_label %}, {% endif %}
30
+ {% if secret.obtain_label %}<a href="{{ secret.obtain_url }}">{{ secret.obtain_label }}</a>{% endif %}
31
+ {% endif %}</li>
29
32
  {% endfor %}
30
33
  </ul>
31
34
  {% endif %}
@@ -34,7 +37,7 @@
34
37
  <p style="margin-top: 2em">The following secret{% if environment_secrets|length == 1 %} is{% else %}s are{% endif %} set using environment variables:</p>
35
38
  <ul>
36
39
  {% for secret in environment_secrets %}
37
- <li><strong>{{ secret.name }}</a></strong>- {{ secret.description_html|safe }}<br>
40
+ <li><strong>{{ secret.name }}</a></strong>{% if secret.description %} - {{ secret.description }}{% endif %}<br>
38
41
  <span style="font-size: 0.8 em">Set by <code>DATASETTE_SECRETS_{{ secret.name }}</code></span></li>
39
42
  {% endfor %}
40
43
  </ul>
@@ -9,8 +9,10 @@
9
9
  {% block content %}
10
10
  <h1>{% if current_secret %}Update{% else %}Add{% endif %} secret: {{ secret_name }}</h1>
11
11
 
12
- {% if secret_details and secret_details.description_html %}
13
- <p>{{ secret_details.description_html|safe }}</p>
12
+ {% if secret_details.description or secret_details.obtain_label %}
13
+ <p>{{ secret_details.description or "" }}{% if secret_details.description and secret_details.obtain_label %}. {% endif %}
14
+ {% if secret_details.obtain_label %}<a href="{{ secret_details.obtain_url }}">{{ secret_details.obtain_label }}</a>{% endif %}
15
+ </p>
14
16
  {% endif %}
15
17
 
16
18
  {% if error %}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: datasette-secrets
3
- Version: 0.1a0
3
+ Version: 0.1a2
4
4
  Summary: Manage secrets such as API keys for use with other Datasette plugins
5
5
  Author: Datasette
6
6
  License: Apache-2.0
@@ -105,6 +105,8 @@ datasette data.db --internal internal.db \
105
105
 
106
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
107
 
108
+ The page with the list of secrets will show the user who last updated each secret. This will use the [actors_from_ids()](https://docs.datasette.io/en/latest/plugin_hooks.html#actors-from-ids-datasette-actor-ids) mechanism, displaying the actor's `username` if available, otherwise the `name`, otherwise the `id`.
109
+
108
110
  ## For plugin authors
109
111
 
110
112
  Plugins can depend on this plugin if they want to implement secrets.
@@ -121,11 +123,24 @@ from datasette_secrets import Secret
121
123
  def register_secrets():
122
124
  return [
123
125
  Secret(
124
- "OPENAI_API_KEY",
125
- 'An OpenAI API key. Get them from <a href="https://platform.openai.com/api-keys">here</a>.',
126
+ name="OPENAI_API_KEY",
127
+ description="An OpenAI API key"
128
+ ),
129
+ ]
130
+ ```
131
+ You can also provide optional `obtain_url` and `obtain_label` fields to link to a page where a user can obtain an API key:
132
+ ```python
133
+ @hookimpl
134
+ def register_secrets():
135
+ return [
136
+ Secret(
137
+ name="OPENAI_API_KEY",
138
+ obtain_url="https://platform.openai.com/api-keys",
139
+ obtain_label="Get an OpenAI API key"
126
140
  ),
127
141
  ]
128
142
  ```
143
+
129
144
  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
145
 
131
146
  The list should consist of `Secret()` instances, each with a name and an optional description. The description can contain HTML.
@@ -135,12 +150,14 @@ To obtain the current value of the secret, use the `await get_secret()` method:
135
150
  ```python
136
151
  from datasette_secrets import get_secret
137
152
 
138
- secret = await get_secret(datasette, "OPENAI_API_KEY")
153
+ # Third argument is the actor_id, optional
154
+ secret = await get_secret(datasette, "OPENAI_API_KEY", "root")
139
155
  ```
140
156
  If the Datasette administrator set a `DATASETTE_SECRETS_OPENAI_API_KEY` environment variable, that will be returned.
141
157
 
142
158
  Otherwise the encrypted value in the database table will be decrypted and returned - or `None` if there is no configured secret.
143
159
 
160
+ The `last_used_at` column is updated every time a secret is accessed. The `last_used_by` column will be set to the actor ID passed to `get_secret()`, or `null` if no actor ID was passed.
144
161
 
145
162
  ## Development
146
163
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datasette-secrets"
3
- version = "0.1a0"
3
+ version = "0.1a2"
4
4
  description = "Manage secrets such as API keys for use with other Datasette plugins"
5
5
  readme = "README.md"
6
6
  authors = [{name = "Datasette"}]
@@ -1,8 +1,10 @@
1
1
  from click.testing import CliRunner
2
2
  from cryptography.fernet import Fernet
3
+ from datasette import hookimpl
3
4
  from datasette.app import Datasette
4
5
  from datasette.cli import cli
5
- from datasette_secrets import get_secret
6
+ from datasette.plugins import pm
7
+ from datasette_secrets import get_secret, Secret
6
8
  import pytest
7
9
  from unittest.mock import ANY
8
10
 
@@ -21,6 +23,69 @@ def test_generate_command():
21
23
  assert key.decrypt(key.encrypt(message)) == message
22
24
 
23
25
 
26
+ @pytest.fixture
27
+ def use_actors_plugin():
28
+ class ActorPlugin:
29
+ __name__ = "ActorPlugin"
30
+
31
+ @hookimpl
32
+ def actors_from_ids(self, actor_ids):
33
+ return {
34
+ id: {
35
+ "id": id,
36
+ "username": id.upper(),
37
+ }
38
+ for id in actor_ids
39
+ }
40
+
41
+ pm.register(ActorPlugin(), name="ActorPlugin")
42
+ yield
43
+ pm.unregister(name="ActorPlugin")
44
+
45
+
46
+ @pytest.fixture
47
+ def register_multiple_secrets():
48
+ class SecretOnePlugin:
49
+ __name__ = "SecretOnePlugin"
50
+
51
+ @hookimpl
52
+ def register_secrets(self):
53
+ return [
54
+ Secret(
55
+ name="OPENAI_API_KEY",
56
+ obtain_url="https://platform.openai.com/api-keys",
57
+ obtain_label="Get an OpenAI API key",
58
+ ),
59
+ Secret(
60
+ name="ANTHROPIC_API_KEY", description="A key for Anthropic's API"
61
+ ),
62
+ ]
63
+
64
+ class SecretTwoPlugin:
65
+ __name__ = "SecretTwoPlugin"
66
+
67
+ @hookimpl
68
+ def register_secrets(self):
69
+ return [
70
+ Secret(
71
+ name="OPENAI_API_KEY",
72
+ description="Just a description but should be ignored",
73
+ ),
74
+ Secret(
75
+ name="OPENCAGE_API_KEY",
76
+ description="The OpenCage Geocoder",
77
+ obtain_url="https://opencagedata.com/dashboard",
78
+ obtain_label="Get an OpenCage API key",
79
+ ),
80
+ ]
81
+
82
+ pm.register(SecretTwoPlugin(), name="SecretTwoPlugin")
83
+ pm.register(SecretOnePlugin(), name="SecretOnePlugin")
84
+ yield
85
+ pm.unregister(name="SecretOnePlugin")
86
+ pm.unregister(name="SecretTwoPlugin")
87
+
88
+
24
89
  @pytest.fixture
25
90
  def ds():
26
91
  return Datasette(
@@ -65,7 +130,7 @@ async def test_permissions(ds, path, verb, data, user):
65
130
 
66
131
 
67
132
  @pytest.mark.asyncio
68
- async def test_set_secret(ds):
133
+ async def test_set_secret(ds, use_actors_plugin):
69
134
  cookies = {"ds_actor": ds.client.actor_cookie({"id": "admin"})}
70
135
  get_response = await ds.client.get("/-/secrets/EXAMPLE_SECRET", cookies=cookies)
71
136
  csrftoken = get_response.cookies["ds_csrftoken"]
@@ -88,7 +153,6 @@ async def test_set_secret(ds):
88
153
  "version": 1,
89
154
  "encrypted": ANY,
90
155
  "encryption_key_name": "default",
91
- "redacted": None,
92
156
  "created_at": ANY,
93
157
  "created_by": "admin",
94
158
  "updated_at": ANY,
@@ -105,6 +169,13 @@ async def test_set_secret(ds):
105
169
  decrypted = key.decrypt(encrypted)
106
170
  assert decrypted == b"new-secret-value"
107
171
 
172
+ # Check that the listing is as expected, including showing the actor username
173
+ response = await ds.client.get("/-/secrets", cookies=cookies)
174
+ assert response.status_code == 200
175
+ assert "EXAMPLE_SECRET" in response.text
176
+ assert "new-note" in response.text
177
+ assert "<td>ADMIN</td>" in response.text
178
+
108
179
  # Now let's edit it
109
180
  post_response2 = await ds.client.post(
110
181
  "/-/secrets/EXAMPLE_SECRET",
@@ -128,7 +199,6 @@ async def test_set_secret(ds):
128
199
  "version": 2,
129
200
  "encrypted": ANY,
130
201
  "encryption_key_name": "default",
131
- "redacted": None,
132
202
  "created_at": ANY,
133
203
  "created_by": "admin",
134
204
  "updated_at": ANY,
@@ -147,6 +217,11 @@ async def test_get_secret(ds, monkeypatch):
147
217
  get_response = await ds.client.get("/-/secrets/EXAMPLE_SECRET", cookies=cookies)
148
218
  csrftoken = get_response.cookies["ds_csrftoken"]
149
219
  cookies["ds_csrftoken"] = csrftoken
220
+ db = ds.get_internal_database()
221
+ # Reset state
222
+ await db.execute_write(
223
+ "update datasette_secrets set last_used_at = null, last_used_by = null"
224
+ )
150
225
  post_response = await ds.client.post(
151
226
  "/-/secrets/EXAMPLE_SECRET",
152
227
  cookies=cookies,
@@ -158,9 +233,73 @@ async def test_get_secret(ds, monkeypatch):
158
233
  )
159
234
  assert post_response.status_code == 302
160
235
 
236
+ assert await get_secret(ds, "EXAMPLE_SECRET", "actor") == "manually-set-secret"
237
+
238
+ # Should have updated last_used_at and last_used_by
239
+ secret = (
240
+ await db.execute(
241
+ "select * from datasette_secrets where name = ? order by version desc limit 1",
242
+ ["EXAMPLE_SECRET"],
243
+ )
244
+ ).first()
245
+ assert secret["last_used_by"] == "actor"
246
+ assert secret["last_used_at"] is not None
247
+
248
+ # Calling again without actor ID should set that to null
161
249
  assert await get_secret(ds, "EXAMPLE_SECRET") == "manually-set-secret"
250
+ secret2 = (
251
+ await db.execute(
252
+ "select * from datasette_secrets where name = ? order by version desc limit 1",
253
+ ["EXAMPLE_SECRET"],
254
+ )
255
+ ).first()
256
+ assert secret2["last_used_by"] is None
162
257
 
163
258
  # Now over-ride with an environment variable
164
259
  monkeypatch.setenv("DATASETTE_SECRETS_EXAMPLE_SECRET", "from env")
165
260
 
166
261
  assert await get_secret(ds, "EXAMPLE_SECRET") == "from env"
262
+
263
+ # And check that it's shown that way on the /-/secrets page
264
+ response = await ds.client.get("/-/secrets", cookies=cookies)
265
+ assert response.status_code == 200
266
+ expected_html = """
267
+ <li><strong>EXAMPLE_SECRET</a></strong> - An example secret<br>
268
+ <span style="font-size: 0.8 em">Set by <code>DATASETTE_SECRETS_EXAMPLE_SECRET</code></span></li>
269
+ """
270
+ assert remove_whitespace(expected_html) in remove_whitespace(response.text)
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_secret_index_page(ds, register_multiple_secrets):
275
+ response = await ds.client.get(
276
+ "/-/secrets",
277
+ cookies={
278
+ "ds_actor": ds.client.actor_cookie({"id": "admin"}),
279
+ },
280
+ )
281
+ assert response.status_code == 200
282
+ expected_html = """
283
+ <p style="margin-top: 2em">The following secrets have not been set:</p>
284
+ <ul>
285
+ <li><strong><a href="/-/secrets/OPENAI_API_KEY">OPENAI_API_KEY</a></strong>
286
+ -
287
+ <a href="https://platform.openai.com/api-keys">Get an OpenAI API key</a>
288
+ </li>
289
+ <li><strong><a href="/-/secrets/ANTHROPIC_API_KEY">ANTHROPIC_API_KEY</a></strong>
290
+ - A key for Anthropic&#39;s API
291
+ </li>
292
+ <li><strong><a href="/-/secrets/OPENCAGE_API_KEY">OPENCAGE_API_KEY</a></strong>
293
+ - The OpenCage Geocoder,
294
+ <a href="https://opencagedata.com/dashboard">Get an OpenCage API key</a>
295
+ </li>
296
+ <li><strong><a href="/-/secrets/EXAMPLE_SECRET">EXAMPLE_SECRET</a></strong>
297
+ - An example secret
298
+ </li>
299
+ </ul>
300
+ """
301
+ assert remove_whitespace(expected_html) in remove_whitespace(response.text)
302
+
303
+
304
+ def remove_whitespace(s):
305
+ return " ".join(s.split())