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
app/Support/request-auth.tsmodified+85-0
Changes to app/Support/request-auth.ts
@@ -14,6 +14,8 @@
1414 */
1515import { Auth } from '@stacksjs/auth'
1616import { config } from '@stacksjs/config'
17import { db } from '@stacksjs/database'
18import { authorizeRead, looksLikeReadKey, shouldRecordUse } from './read-key'
1719
1820export async function userFromRequest(request: any): Promise<any | null> {
1921 const authHeader = request.headers?.get?.('authorization') ?? ''
@@ -37,3 +39,86 @@ export async function userFromRequest(request: any): Promise<any | null> {
3739 return null
3840 }
3941}
42
43/**
44 * The bearer a request presents, whatever carries it.
45 *
46 * Split out of `userFromRequest` so a read key can be recognised before it is
47 * sent anywhere near `Auth.getUserFromToken`: the two credentials arrive in
48 * the same header and only their shape tells them apart.
49 */
50function bearerFrom(request: any): string {
51 const authHeader = request.headers?.get?.('authorization') ?? ''
52 const header = request.bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '')
53 if (header)
54 return header
55 const cookie = request.headers?.get?.('cookie') ?? ''
56 const name = (config.auth?.defaultTokenName || 'auth-token').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57 const m = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`))
58 return m ? decodeURIComponent(m[1]) : ''
59}
60
61export type ProjectReadAccess =
62 | { ok: true, as: 'user', user: any }
63 | { ok: true, as: 'key' }
64 | { ok: false, status: 401 | 403 | 404, error: string }
65
66/**
67 * Who may read this project: the person signed in, or a key issued for it.
68 *
69 * Order matters. A read key is checked first when one is presented, because a
70 * key is scoped to a single project and answering it does not need a user
71 * lookup at all. Anything that is not shaped like a read key falls through to
72 * the existing user path unchanged, so the dashboard's cookie and a person's
73 * bearer token behave exactly as they did.
74 *
75 * A key for a different project answers 404 rather than 403, matching what
76 * `ownsProject` already does: a caller who cannot read a project should not be
77 * able to learn that it exists.
78 */
79export async function projectReadAccess(request: any, projectId: string, ownsProject: (user: any, id: string) => Promise<boolean>): Promise<ProjectReadAccess> {
80 const presented = bearerFrom(request)
81
82 if (looksLikeReadKey(presented)) {
83 // db.unsafe answers an untyped row, so the columns are narrowed here rather
84 // than asserted: authorizeRead decides who may read a project and should be
85 // reading values it can see the shape of.
86 const found = (await db.unsafe(
87 'SELECT id, read_key, read_key_last_used_at, is_active FROM projects WHERE id = $1 LIMIT 1',
88 [String(projectId)],
89 ))?.[0]
90 const row = found
91 ? {
92 id: String(found.id),
93 read_key: found.read_key == null ? null : String(found.read_key),
94 read_key_last_used_at: found.read_key_last_used_at == null ? null : String(found.read_key_last_used_at),
95 is_active: found.is_active as boolean | number | null,
96 }
97 : null
98 const verdict = authorizeRead(row, presented)
99 // A key that is valid for some other project must not reveal that this one
100 // exists, so its refusal is the same 404 an unknown id gets.
101 if (!verdict.ok)
102 return verdict.status === 401 ? { ok: false, status: 404, error: 'not found' } : verdict
103 if (row)
104 await recordReadKeyUse(row)
105 return { ok: true, as: 'key' }
106 }
107
108 const user = await userFromRequest(request)
109 if (!user)
110 return { ok: false, status: 401, error: 'unauthorized' }
111 if (!(await ownsProject(user, projectId)))
112 return { ok: false, status: 404, error: 'not found' }
113 return { ok: true, as: 'user', user }
114}
115
116/** Stamp the key as used, at most once a minute — see LAST_USED_RESOLUTION_MS. */
117async function recordReadKeyUse(project: { id: string, read_key_last_used_at?: string | null }): Promise<void> {
118 const now = new Date()
119 if (!shouldRecordUse(project.read_key_last_used_at, now))
120 return
121 // Never let the stamp fail a read: it is an aid to revoking a key, not part
122 // of authorising one.
123 await db.unsafe('UPDATE projects SET read_key_last_used_at = $1 WHERE id = $2', [now.toISOString(), String(project.id)]).catch(() => {})
124}