ReviewOS

also looking at this

loghqorg/loghq

A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials

#15
Merged glennmichael123 wants to merge feat/read-scoped-project-key into main
9 files +537 -17
routes/projects.tsmodified+42-0
Changes to routes/projects.ts
@@ -17,6 +17,7 @@ import { log } from '@stacksjs/logging'
1717// Shared with the page action in resources/views/projects/new.stx, so the two
1818// callers cannot drift on how an id or an ingest key is minted.
1919import { newIngestKey, newProjectId } from '../app/Support/projects'
20import { newReadKey } from '../app/Support/read-key'
2021import { utcNow } from '../app/Support/time'
2122import { GitHubRequestError, verifyAccess } from '../app/Fix/github'
2223import {
@@ -114,6 +115,9 @@ route.get('/api/projects', async (request: any) => {
114115 return json({ error: 'unauthorized' }, 401)
115116 const rows = await db.unsafe(
116117 `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,
117121 (p.owner_id = $1) AS is_owner
118122 FROM projects p
119123 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) => {
183187 return json({ ingest_key: key })
184188}).skipCsrf()
185189
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.
198route.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.
216route.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
186228// Delete a project and everything under it. Owner-only. Removes children first
187229// to satisfy the foreign keys (log entries/channels/members/invites -> project),
188230// then the project row.