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/logs.tsmodified+15-12
Changes to routes/logs.ts
@@ -15,9 +15,9 @@ import { rateLimit } from '../app/Errors/limits'
1515import { extractEntries, LEVELS, MAX_BATCH, MAX_CORRELATION, normalizeBatch } from '../app/Logs/normalize'
1616// The owner/member predicate lives in one file so these routes and the pages
1717// that render the same data cannot drift on it. See app/Support/access.ts.
18import { ownsLog, ownsProject } from '../app/Support/access'
18import { ownsProject } from '../app/Support/access'
1919import { healthResponse } from '../app/Support/health'
20import { userFromRequest } from '../app/Support/request-auth'
20import { projectReadAccess } from '../app/Support/request-auth'
2121
2222// Ingest abuse bounds. The public key gate is not enough on its own — a script
2323// with the key (readable from any bundle) could flood the ingest. Per-entry
@@ -137,11 +137,10 @@ const STREAM_COLS = 'id, level, message, channel, environment, release, framewor
137137
138138route.get('/api/projects/{projectId}/logs', async (request: any) => {
139139 const projectId = request.params.projectId
140 const user = await userFromRequest(request)
141 if (!user)
142 return json({ error: 'unauthorized' }, 401)
143 if (!(await ownsProject(user, projectId)))
144 return json({ error: 'not found' }, 404)
140 // A person who owns the project, or a read key issued for it (#14).
141 const access = await projectReadAccess(request, projectId, ownsProject)
142 if (!access.ok)
143 return json({ error: access.error }, access.status)
145144
146145 const q = request.query ?? {}
147146 const where: string[] = ['project_id = $1']
@@ -207,12 +206,16 @@ route.get('/api/projects/{projectId}/logs', async (request: any) => {
207206
208207route.get('/api/logs/{logId}', async (request: any) => {
209208 const logId = request.params.logId
210 const user = await userFromRequest(request)
211 if (!user)
212 return json({ error: 'unauthorized' }, 401)
213 if (!(await ownsLog(user, logId)))
214 return json({ error: 'not found' }, 404)
209 // The entry is read before anyone is authorised, and returned only after.
210 // Authorising first would mean asking "may you read project X?" without
211 // knowing which project this entry is in; answering 404 for a missing entry
212 // before checking the caller would tell an unauthenticated one which log ids
213 // exist. So: fetch, authorise against the entry's project (an empty id when
214 // there is no entry, which no key and no user can read), then answer.
215215 const entry = (await db.unsafe('SELECT * FROM log_entries WHERE id = $1 LIMIT 1', [logId]))?.[0]
216 const access = await projectReadAccess(request, entry ? String(entry.project_id) : '', ownsProject)
217 if (!access.ok)
218 return json({ error: access.error }, access.status)
216219 if (!entry)
217220 return json({ error: 'not found' }, 404)
218221 return json({ entry })