also looking at this
A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials
#15
9 files
+537
-17
| @@ -17,6 +17,7 @@ import { log } from '@stacksjs/logging' | ||
| 17 | 17 | // Shared with the page action in resources/views/projects/new.stx, so the two |
| 18 | 18 | // callers cannot drift on how an id or an ingest key is minted. |
| 19 | 19 | import { newIngestKey, newProjectId } from '../app/Support/projects' |
| 20 | import { newReadKey } from '../app/Support/read-key' | |
| 20 | 21 | import { utcNow } from '../app/Support/time' |
| 21 | 22 | import { GitHubRequestError, verifyAccess } from '../app/Fix/github' |
| 22 | 23 | import { |
| @@ -114,6 +115,9 @@ route.get('/api/projects', async (request: any) => { | ||
| 114 | 115 | return json({ error: 'unauthorized' }, 401) |
| 115 | 116 | const rows = await db.unsafe( |
| 116 | 117 | `SELECT DISTINCT p.id, p.name, p.platform, p.ingest_key, p.is_active, p.created_at, |
| 118 | -- Whether a read key exists and when it was last used, never the | |
| 119 | -- key: it is a secret, and it is shown once when it is issued. | |
| 120 | (p.read_key IS NOT NULL) AS has_read_key, p.read_key_last_used_at, | |
| 117 | 121 | (p.owner_id = $1) AS is_owner |
| 118 | 122 | FROM projects p |
| 119 | 123 | LEFT JOIN project_members m ON m.project_id = p.id AND lower(m.email) = $2 |
| @@ -183,6 +187,44 @@ route.post('/api/projects/{projectId}/rotate-key', async (request: any) => { | ||
| 183 | 187 | return json({ ingest_key: key }) |
| 184 | 188 | }).skipCsrf() |
| 185 | 189 | |
| 190 | // Issue (or replace) a project's read key. Owner-only. | |
| 191 | // | |
| 192 | // This is the credential a server presents to read the project's logs without | |
| 193 | // a person's account behind it (#14). It is returned here and nowhere else: | |
| 194 | // unlike the public ingest key, which the listing carries so it can be copied | |
| 195 | // into client code, this one is a secret, so it is shown once and the listing | |
| 196 | // afterwards says only that it exists. Losing it means issuing another, which | |
| 197 | // is the same call — and the previous key stops working the moment it lands. | |
| 198 | route.post('/api/projects/{projectId}/read-key', async (request: any) => { | |
| 199 | const user = await currentUser(request) | |
| 200 | if (!user) | |
| 201 | return json({ error: 'unauthorized' }, 401) | |
| 202 | const projectId = request.params.projectId | |
| 203 | if (!(await ownsProject(user, projectId))) | |
| 204 | return json({ error: 'not found' }, 404) | |
| 205 | ||
| 206 | const key = newReadKey() | |
| 207 | // The last-used stamp belongs to the key, so a new key starts with none: | |
| 208 | // leaving the old one would read as "this key was used before it existed". | |
| 209 | await db.unsafe('UPDATE projects SET read_key = $1, read_key_last_used_at = NULL, updated_at = $3 WHERE id = $2', [key, projectId, utcNow()]) | |
| 210 | return json({ read_key: key }) | |
| 211 | }).skipCsrf() | |
| 212 | ||
| 213 | // Revoke a project's read key. Owner-only. Anything still presenting it gets a | |
| 214 | // 404 from the read API on the next request, the same answer an unknown | |
| 215 | // project gets. | |
| 216 | route.delete('/api/projects/{projectId}/read-key', async (request: any) => { | |
| 217 | const user = await currentUser(request) | |
| 218 | if (!user) | |
| 219 | return json({ error: 'unauthorized' }, 401) | |
| 220 | const projectId = request.params.projectId | |
| 221 | if (!(await ownsProject(user, projectId))) | |
| 222 | return json({ error: 'not found' }, 404) | |
| 223 | ||
| 224 | await db.unsafe('UPDATE projects SET read_key = NULL, read_key_last_used_at = NULL, updated_at = $2 WHERE id = $1', [projectId, utcNow()]) | |
| 225 | return json({ revoked: true }) | |
| 226 | }).skipCsrf() | |
| 227 | ||
| 186 | 228 | // Delete a project and everything under it. Owner-only. Removes children first |
| 187 | 229 | // to satisfy the foreign keys (log entries/channels/members/invites -> project), |
| 188 | 230 | // then the project row. |