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
| @@ -15,9 +15,9 @@ import { rateLimit } from '../app/Errors/limits' | ||
| 15 | 15 | import { extractEntries, LEVELS, MAX_BATCH, MAX_CORRELATION, normalizeBatch } from '../app/Logs/normalize' |
| 16 | 16 | // The owner/member predicate lives in one file so these routes and the pages |
| 17 | 17 | // that render the same data cannot drift on it. See app/Support/access.ts. |
| 18 | import { ownsLog, ownsProject } from '../app/Support/access' | |
| 18 | import { ownsProject } from '../app/Support/access' | |
| 19 | 19 | import { healthResponse } from '../app/Support/health' |
| 20 | import { userFromRequest } from '../app/Support/request-auth' | |
| 20 | import { projectReadAccess } from '../app/Support/request-auth' | |
| 21 | 21 | |
| 22 | 22 | // Ingest abuse bounds. The public key gate is not enough on its own — a script |
| 23 | 23 | // 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 | ||
| 137 | 137 | |
| 138 | 138 | route.get('/api/projects/{projectId}/logs', async (request: any) => { |
| 139 | 139 | 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) | |
| 145 | 144 | |
| 146 | 145 | const q = request.query ?? {} |
| 147 | 146 | const where: string[] = ['project_id = $1'] |
| @@ -207,12 +206,16 @@ route.get('/api/projects/{projectId}/logs', async (request: any) => { | ||
| 207 | 206 | |
| 208 | 207 | route.get('/api/logs/{logId}', async (request: any) => { |
| 209 | 208 | 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. | |
| 215 | 215 | 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) | |
| 216 | 219 | if (!entry) |
| 217 | 220 | return json({ error: 'not found' }, 404) |
| 218 | 221 | return json({ entry }) |